diff --git a/packages/go/driver/internal/app/format.go b/packages/go/driver/internal/app/format.go index 8433b11..83aaf66 100644 --- a/packages/go/driver/internal/app/format.go +++ b/packages/go/driver/internal/app/format.go @@ -3,13 +3,11 @@ package app import ( "context" "fmt" - "io" + "strings" + "go.ollin.sh/fmtkit/driver/internal/console" "go.ollin.sh/fmtkit/driver/internal/gitfiles" - "go.ollin.sh/fmtkit/driver/internal/gotool" - "go.ollin.sh/fmtkit/driver/internal/orchestrator" - "go.ollin.sh/fmtkit/driver/internal/tsruntime" - report "go.ollin.sh/fmtkit/driver/report" + "go.ollin.sh/fmtkit/driver/internal/pipeline" ) // runFormat formats what diverges from HEAD — modified files, staged or not, @@ -48,37 +46,32 @@ func (d *deps) runFormatAll(ctx context.Context, args []string) int { return d.runPipeline(ctx, []string{"."}, opts, gitfiles.SelectionAll) } +// runPipeline frames the format run (target header, completion footer) around +// the typed steps it builds for the selection, handing them to the generic +// pipeline. Color is resolved once here, at the composition root. func (d *deps) runPipeline(ctx context.Context, paths []string, opts formatOptions, selection gitfiles.Selection) int { - pipeline := orchestrator.Pipeline{ - Tools: orchestrator.Tools{ - TS: func(ctx context.Context, scopes []string, output io.Writer) error { - assets, err := tsruntime.Resolve(d.version) - - if err != nil { - return err - } - - return tsruntime.NewInvoker(assets).RunPipeline(ctx, tsruntime.Request{Scopes: scopes, Selection: selection, Stdout: output, Stderr: output}) - }, - Lint: func(ctx context.Context, scopes []string, output io.Writer) error { - assets, err := tsruntime.Resolve(d.version) - - if err != nil { - return err - } - - return tsruntime.NewInvoker(assets).RunLint(ctx, tsruntime.Request{Scopes: scopes, Selection: selection, Fix: true, Stdout: output, Stderr: output}) - }, - Go: func(ctx context.Context, args []string, output io.Writer) int { - return gotool. - Runner{Stdout: output, Stderr: output, Scope: selection}. - Run(ctx, report.ModeFormat, args[1:]) - }, - }, - Steps: opts.steps, - Quiet: opts.quiet, - Stderr: d.stderr, + if len(paths) == 0 { + paths = []string{"."} } - return pipeline.RunFormat(ctx, paths) + printer := console.NewPrinter(d.stderr, console.DetectColor(d.stderr)) + + printer.Section("Formatting target(s)") + printer.Detail("paths", strings.Join(paths, " ")) + + pipe := pipeline.Pipeline{ + Steps: d.formatSteps(paths, opts.steps, selection), + Quiet: opts.quiet, + Printer: printer, + Stderr: d.stderr, + } + + if code := pipe.Run(ctx); code != 0 { + return code + } + + printer.Section("Formatting complete") + printer.SuccessDetail("status", "done") + + return 0 } diff --git a/packages/go/driver/internal/app/options.go b/packages/go/driver/internal/app/options.go index 75d548e..3bec3f0 100644 --- a/packages/go/driver/internal/app/options.go +++ b/packages/go/driver/internal/app/options.go @@ -3,12 +3,10 @@ package app import ( "fmt" "strings" - - "go.ollin.sh/fmtkit/driver/internal/orchestrator" ) type formatOptions struct { - steps orchestrator.Steps + steps stepSelection quiet bool } diff --git a/packages/go/driver/internal/app/steps.go b/packages/go/driver/internal/app/steps.go new file mode 100644 index 0000000..e8bb8fe --- /dev/null +++ b/packages/go/driver/internal/app/steps.go @@ -0,0 +1,309 @@ +package app + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os/exec" + "strings" + + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + "go.ollin.sh/fmtkit/driver/internal/gotool" + "go.ollin.sh/fmtkit/driver/internal/pipeline" + "go.ollin.sh/fmtkit/driver/internal/sidecarproto" + "go.ollin.sh/fmtkit/driver/internal/tsruntime" + report "go.ollin.sh/fmtkit/driver/report" + formatterengine "go.ollin.sh/fmtkit/formatter/engine" + "go.ollin.sh/fmtkit/vet" +) + +// stepSelection selects which parts of the format pipeline run; the zero value +// (no --ts/--go flags) runs everything. +type stepSelection struct { + TS bool + Go bool +} + +// tsLintStep lints TS/Vue files, applying oxlint's safe fixes (--fix). +type tsLintStep struct { + version string + paths []string + selection gitfiles.Selection +} + +// tsFormatStep runs the full TS/Vue formatting pipeline (oxfmt plus the project +// passes). +type tsFormatStep struct { + version string + paths []string + selection gitfiles.Selection +} + +// goFormatStep formats Go files and runs go vet, deriving its details from the +// typed outcome rather than the rendered report text. +type goFormatStep struct { + paths []string + selection gitfiles.Selection +} + +func (s stepSelection) normalized() stepSelection { + if !s.TS && !s.Go { + return stepSelection{TS: true, Go: true} + } + + return s +} + +// formatSteps builds the ordered pipeline steps for the selection. Lint runs +// first so the formatting passes normalize whatever oxlint rewrites. +func (d *deps) formatSteps(paths []string, selected stepSelection, selection gitfiles.Selection) []pipeline.Step { + selected = selected.normalized() + + var steps []pipeline.Step + + if selected.TS { + steps = append(steps, + tsLintStep{version: d.version, paths: paths, selection: selection}, + tsFormatStep{version: d.version, paths: paths, selection: selection}, + ) + } + + if selected.Go { + steps = append(steps, goFormatStep{paths: paths, selection: selection}) + } + + return steps +} + +// Driver-owned bookkeeping lines the TS steps recognize in their captured +// output. The sidecar's own wire lines are parsed by sidecarproto; these are +// notices the Go driver prints around the sidecar, so they stay here. +const ( + sourcesMissingPrefix = "[sources] path not found, skipping:" + lintNothingToLintLine = "[lint] no TS/Vue files to lint." +) + +func (s tsLintStep) Label() string { return "Running TS/Vue lint" } + +func (s tsLintStep) Run(ctx context.Context, output io.Writer) pipeline.Result { + var captured bytes.Buffer + + err := invokeTS(s.version, io.MultiWriter(output, &captured), func(invoker tsruntime.Invoker, w io.Writer) error { + return invoker.RunLint(ctx, tsruntime.Request{Scopes: s.paths, Selection: s.selection, Fix: true, Stdout: w, Stderr: w}) + }) + + if code := tsExitCode(err, output); code != 0 { + return pipeline.Result{ExitCode: code} + } + + return pipeline.Result{Details: tsLintDetails(captured.String())} +} + +func (s tsFormatStep) Label() string { return "Running TS/Vue formatting" } + +func (s tsFormatStep) Run(ctx context.Context, output io.Writer) pipeline.Result { + var captured bytes.Buffer + + err := invokeTS(s.version, io.MultiWriter(output, &captured), func(invoker tsruntime.Invoker, w io.Writer) error { + return invoker.RunPipeline(ctx, tsruntime.Request{Scopes: s.paths, Selection: s.selection, Stdout: w, Stderr: w}) + }) + + if code := tsExitCode(err, output); code != 0 { + return pipeline.Result{ExitCode: code} + } + + return pipeline.Result{Details: tsFormatDetails(captured.String())} +} + +func (s goFormatStep) Label() string { return "Running Go formatting" } + +func (s goFormatStep) Run(ctx context.Context, output io.Writer) pipeline.Result { + outcome, code := gotool. + Runner{Stdout: output, Stderr: output, Scope: s.selection}. + RunReport(ctx, report.ModeFormat, s.paths) + + if code != 0 { + return pipeline.Result{ExitCode: code} + } + + return pipeline.Result{Details: goFormatDetails(outcome)} +} + +// invokeTS resolves the TS toolchain and invokes it through spawn, which +// receives the constructed Invoker and the writer to stream tool output to. +func invokeTS(version string, output io.Writer, spawn func(tsruntime.Invoker, io.Writer) error) error { + assets, err := tsruntime.Resolve(version) + + if err != nil { + return err + } + + return spawn(tsruntime.NewInvoker(assets), output) +} + +// tsExitCode maps a TS step error to its exit code. Failures that never +// produced tool output (a missing sidecar, an unreadable working tree) surface +// their message through output so they are visible both live and in the quiet +// failure dump. +func tsExitCode(err error, output io.Writer) int { + if err == nil { + return 0 + } + + var exit *exec.ExitError + + if errors.As(err, &exit) { + return exit.ExitCode() + } + + _, _ = io.WriteString(output, err.Error()+"\n") + + return 1 +} + +// tsLintDetails derives the oxlint summary line. A driver "no files" notice +// wins; otherwise oxlint's own result line; otherwise a clean fallback. +func tsLintDetails(log string) []pipeline.Detail { + for _, line := range strings.Split(log, "\n") { + if strings.HasPrefix(line, lintNothingToLintLine) { + return []pipeline.Detail{{Label: "oxlint", Value: strings.TrimPrefix(lintNothingToLintLine, "[lint] ")}} + } + } + + if result := sidecarproto.ParseLintSummary(log).Result; result != "" { + return []pipeline.Detail{{Label: "oxlint", Value: result}} + } + + return []pipeline.Detail{{Label: "oxlint", Value: "no issues found"}} +} + +// tsFormatDetails derives the TS pipeline's detail lines from the sidecar's +// progress output plus the driver's missing-source notices. +func tsFormatDetails(log string) []pipeline.Detail { + summary := sidecarproto.ParsePipelineSummary(log) + + var details []pipeline.Detail + + if summary.BlankLines != "" { + details = append(details, pipeline.Detail{Label: "blank-lines", Value: summary.BlankLines}) + } + + missing := 0 + + for _, line := range strings.Split(log, "\n") { + if strings.HasPrefix(line, sourcesMissingPrefix) { + missing++ + } + } + + if missing > 0 { + details = append(details, pipeline.Detail{Label: "skipped", Value: fmt.Sprintf("%d missing tracked file(s)", missing)}) + } + + if summary.Oxfmt != "" { + details = append(details, pipeline.Detail{Label: "oxfmt", Value: summary.Oxfmt}) + } + + if summary.FluentChains != "" { + details = append(details, pipeline.Detail{Label: "fluent", Value: summary.FluentChains}) + } + + if summary.ValidateSyntax != "" { + details = append(details, pipeline.Detail{Label: "validated", Value: summary.ValidateSyntax}) + } + + return details +} + +// goFormatDetails computes the Go step's detail lines from the typed outcome, +// reproducing the exact strings the text report renders (which the pipeline +// previously scraped back out of that rendered text). +func goFormatDetails(outcome gotool.Outcome) []pipeline.Detail { + fm := outcome.Combined.Formatter + vt := outcome.Combined.Vet + + var details []pipeline.Detail + + if summary := goFileSummary(fm, outcome.Mode); summary != "" { + details = append(details, pipeline.Detail{Label: "fmtkit", Value: summary}) + } + + // The formatter renders a Result line unless it found no files and hit no + // errors; the vet Result line always renders. The "result" detail is the + // first Result line (the formatter's when present, else the vet's), matching + // the text report's top-to-bottom order. + formatterResult := "" + + if fm.Files != 0 || len(fm.Errors) != 0 { + formatterResult = fmt.Sprintf("%s. %d changed, %d violation(s), %d error(s).", fm.Result, fm.Changed, fm.ViolationCount(), fm.ErrorCount()) + } + + vetResult := fmt.Sprintf("%s. %d error(s).", goVetStatus(vt), vt.ErrorCount()) + + resultLine := formatterResult + + if resultLine == "" { + resultLine = vetResult + } + + details = append(details, pipeline.Detail{Label: "result", Value: resultLine}) + + if summary := goVetSummary(vt); summary != "" { + details = append(details, pipeline.Detail{Label: "vet", Value: summary}) + } + + if vetResult != resultLine { + details = append(details, pipeline.Detail{Label: "vet result", Value: vetResult}) + } + + return details +} + +// goFileSummary is the formatter's file-count line: "No Go files found." when it +// owns none, otherwise the mode's verb and count. +func goFileSummary(fm formatterengine.Report, mode report.Mode) string { + if fm.Files == 0 { + return "No Go files found." + } + + action := "Checked" + + if mode == report.ModeFormat { + action = "Formatted" + } + + return fmt.Sprintf("%s %d file(s).", action, fm.Files) +} + +// goVetStatus classifies the vet report the same way the text report does. +func goVetStatus(vt vet.Report) string { + switch { + case vt.Skipped || vt.Root == "": + return "skipped" + case vt.ErrorCount() > 0: + return "fail" + default: + return "pass" + } +} + +// goVetSummary is the vet status line, or "" for a failure (whose per-error +// lines the text report shows instead of a one-line summary). +func goVetSummary(vt vet.Report) string { + switch goVetStatus(vt) { + case "skipped": + reason := "no Go module or workspace was detected" + + if vt.Skipped { + reason = "the Go toolchain is not available" + } + + return "Skipped automatic go vet ./... because " + reason + "." + case "pass": + return "go vet ./... passed." + default: + return "" + } +} diff --git a/packages/go/driver/internal/app/steps_test.go b/packages/go/driver/internal/app/steps_test.go new file mode 100644 index 0000000..c9d5725 --- /dev/null +++ b/packages/go/driver/internal/app/steps_test.go @@ -0,0 +1,235 @@ +package app + +import ( + "bytes" + "errors" + "fmt" + "testing" + + "go.ollin.sh/fmtkit/driver/internal/gotool" + "go.ollin.sh/fmtkit/driver/internal/pipeline" + report "go.ollin.sh/fmtkit/driver/report" + formatterengine "go.ollin.sh/fmtkit/formatter/engine" + "go.ollin.sh/fmtkit/vet" +) + +func detailStrings(details []pipeline.Detail) []string { + out := make([]string, 0, len(details)) + + for _, d := range details { + out = append(out, d.Label+"|"+d.Value) + } + + return out +} + +func assertDetails(t *testing.T, got []pipeline.Detail, want ...string) { + t.Helper() + + if g := fmt.Sprint(detailStrings(got)); g != fmt.Sprint(want) { + t.Fatalf("details mismatch\n--- got ---\n%s\n--- want ---\n%s", g, fmt.Sprint(want)) + } +} + +// goOutcome builds a Go outcome for the given mode, formatter report, and vet +// report. +func goOutcome(mode report.Mode, fm formatterengine.Report, vt vet.Report) gotool.Outcome { + return gotool.Outcome{Mode: mode, Combined: report.Combined{Formatter: fm, Vet: vt}} +} + +func TestGoFormatDetailsPass(t *testing.T) { + outcome := goOutcome( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 2}, + vet.Report{Root: "/work"}, + ) + + assertDetails(t, goFormatDetails(outcome), + "fmtkit|Formatted 2 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet|go vet ./... passed.", + "vet result|pass. 0 error(s).", + ) +} + +func TestGoFormatDetailsCheckModeVerb(t *testing.T) { + outcome := goOutcome( + report.ModeCheck, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 3}, + vet.Report{Root: "/work"}, + ) + + assertDetails(t, goFormatDetails(outcome), + "fmtkit|Checked 3 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet|go vet ./... passed.", + "vet result|pass. 0 error(s).", + ) +} + +// TestGoFormatDetailsNoFiles reproduces the scraper's quirk: with no formatter +// Result line rendered, the "result" detail borrows the vet Result line and the +// separate "vet result" line is suppressed (they are identical). +func TestGoFormatDetailsNoFiles(t *testing.T) { + outcome := goOutcome( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 0}, + vet.Report{Root: "/work"}, + ) + + assertDetails(t, goFormatDetails(outcome), + "fmtkit|No Go files found.", + "result|pass. 0 error(s).", + "vet|go vet ./... passed.", + ) +} + +func TestGoFormatDetailsVetSkippedNoModule(t *testing.T) { + outcome := goOutcome( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 1}, + vet.Report{Root: ""}, + ) + + assertDetails(t, goFormatDetails(outcome), + "fmtkit|Formatted 1 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet|Skipped automatic go vet ./... because no Go module or workspace was detected.", + "vet result|skipped. 0 error(s).", + ) +} + +func TestGoFormatDetailsVetSkippedToolchain(t *testing.T) { + outcome := goOutcome( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 1}, + vet.Report{Root: "/work", Skipped: true}, + ) + + assertDetails(t, goFormatDetails(outcome), + "fmtkit|Formatted 1 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet|Skipped automatic go vet ./... because the Go toolchain is not available.", + "vet result|skipped. 0 error(s).", + ) +} + +// TestGoFormatDetailsVetFailure: a vet failure renders per-error lines instead +// of a status summary, so there is no "vet" detail, but the differing vet Result +// line still appears. +func TestGoFormatDetailsVetFailure(t *testing.T) { + outcome := goOutcome( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 2}, + vet.Report{Root: "/work", Errors: []vet.ErrorResult{{File: "a.go", Message: "boom"}}}, + ) + + assertDetails(t, goFormatDetails(outcome), + "fmtkit|Formatted 2 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet result|fail. 1 error(s).", + ) +} + +func TestTSFormatDetails(t *testing.T) { + log := "[blank-lines] processed 3 file(s) in /work, 0 changed\n" + + "Finished in 10ms on 3 files using 8 threads.\n" + + "[fluent-chains] processed 3 file(s) in /work, 1 changed\n" + + "[validate-syntax] checked 3 file(s).\n" + + assertDetails(t, tsFormatDetails(log), + "blank-lines|processed 3 file(s) in /work, 0 changed", + "oxfmt|Finished in 10ms on 3 files using 8 threads.", + "fluent|processed 3 file(s) in /work, 1 changed", + "validated|checked 3 file(s).", + ) +} + +func TestTSFormatDetailsCountsMissing(t *testing.T) { + log := "[sources] path not found, skipping: /work/a\n" + + "[sources] path not found, skipping: /work/b\n" + + assertDetails(t, tsFormatDetails(log), "skipped|2 missing tracked file(s)") +} + +func TestTSLintDetailsResult(t *testing.T) { + assertDetails(t, tsLintDetails("Found 0 warnings and 0 errors.\n"), "oxlint|Found 0 warnings and 0 errors.") +} + +func TestTSLintDetailsNoFiles(t *testing.T) { + assertDetails(t, tsLintDetails("[lint] no TS/Vue files to lint.\n"), "oxlint|no TS/Vue files to lint.") +} + +func TestTSLintDetailsFallback(t *testing.T) { + assertDetails(t, tsLintDetails("nothing interesting\n"), "oxlint|no issues found") +} + +func TestTSExitCodePlainErrorWritesToOutput(t *testing.T) { + var buf bytes.Buffer + + if code := tsExitCode(errors.New("boom"), &buf); code != 1 { + t.Fatalf("tsExitCode = %d, want 1", code) + } + + if buf.String() != "boom\n" { + t.Fatalf("tsExitCode output = %q, want %q", buf.String(), "boom\n") + } +} + +func TestTSExitCodeNil(t *testing.T) { + var buf bytes.Buffer + + if code := tsExitCode(nil, &buf); code != 0 { + t.Fatalf("tsExitCode(nil) = %d, want 0", code) + } + + if buf.Len() != 0 { + t.Fatalf("tsExitCode(nil) wrote %q", buf.String()) + } +} + +func TestStepSelectionNormalized(t *testing.T) { + if got := (stepSelection{}).normalized(); !got.TS || !got.Go { + t.Fatalf("zero selection = %+v, want both set", got) + } + + if got := (stepSelection{TS: true}).normalized(); !got.TS || got.Go { + t.Fatalf("TS-only selection = %+v, want TS only", got) + } + + if got := (stepSelection{Go: true}).normalized(); got.TS || !got.Go { + t.Fatalf("Go-only selection = %+v, want Go only", got) + } +} + +func TestFormatStepsSelection(t *testing.T) { + d := &deps{version: "dev"} + + labels := func(steps []pipeline.Step) []string { + out := make([]string, 0, len(steps)) + + for _, s := range steps { + out = append(out, s.Label()) + } + + return out + } + + all := labels(d.formatSteps([]string{"."}, stepSelection{}, 0)) + + if fmt.Sprint(all) != fmt.Sprint([]string{"Running TS/Vue lint", "Running TS/Vue formatting", "Running Go formatting"}) { + t.Fatalf("default steps = %v", all) + } + + tsOnly := labels(d.formatSteps([]string{"."}, stepSelection{TS: true}, 0)) + + if fmt.Sprint(tsOnly) != fmt.Sprint([]string{"Running TS/Vue lint", "Running TS/Vue formatting"}) { + t.Fatalf("--ts steps = %v", tsOnly) + } + + goOnly := labels(d.formatSteps([]string{"."}, stepSelection{Go: true}, 0)) + + if fmt.Sprint(goOnly) != fmt.Sprint([]string{"Running Go formatting"}) { + t.Fatalf("--go steps = %v", goOnly) + } +} diff --git a/packages/go/driver/internal/console/printer.go b/packages/go/driver/internal/console/printer.go new file mode 100644 index 0000000..117da12 --- /dev/null +++ b/packages/go/driver/internal/console/printer.go @@ -0,0 +1,144 @@ +// Package console renders the pipeline's sectioned, ANSI-colored progress +// output: section headers, aligned detail lines, failure banners, and the +// indented live stream of a child tool's output. Color detection is resolved +// once by the caller (see DetectColor) and handed to NewPrinter, so the printer +// itself never reads the environment. +package console + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/mattn/go-isatty" +) + +// ColorMode is whether a Printer emits ANSI escape sequences. +type ColorMode int + +// Printer renders progress output to a writer. The palette fields are empty +// strings when color is off, so the same format strings render plain text. +type Printer struct { + w io.Writer + + bold string + dim string + cyan string + green string + red string + reset string +} + +type indentWriter struct { + printer *Printer + partial strings.Builder +} + +const ( + // ColorAuto defers the decision to DetectColor. NewPrinter treats it as + // no-color, so callers resolve it through DetectColor before constructing a + // Printer rather than passing it through. + ColorAuto ColorMode = iota + + // ColorAlways forces ANSI color on. + ColorAlways + + // ColorNever forces ANSI color off. + ColorNever +) + +// DetectColor resolves whether color should be used when writing to w. It +// honors FORCE_COLOR (always on) and NO_COLOR (always off) before falling back +// to whether w is a terminal. This is the single place the environment is read; +// callers resolve it once and pass the result to NewPrinter. +func DetectColor(w io.Writer) ColorMode { + if os.Getenv("FORCE_COLOR") != "" { + return ColorAlways + } + + if os.Getenv("NO_COLOR") != "" { + return ColorNever + } + + if file, ok := w.(*os.File); ok && isatty.IsTerminal(file.Fd()) { + return ColorAlways + } + + return ColorNever +} + +// NewPrinter builds a Printer writing to w. ANSI color is enabled only for +// ColorAlways; ColorAuto and ColorNever both render plain text, so callers pass +// the resolved result of DetectColor. +func NewPrinter(w io.Writer, mode ColorMode) *Printer { + p := &Printer{w: w} + + if mode == ColorAlways { + p.bold = "\033[1m" + p.dim = "\033[2m" + p.cyan = "\033[36m" + p.green = "\033[32m" + p.red = "\033[31m" + p.reset = "\033[0m" + } + + return p +} + +// Section prints a bold, cyan-arrowed section header preceded by a blank line. +func (p *Printer) Section(msg string) { + _, _ = fmt.Fprintf(p.w, "\n%s==>%s %s%s%s\n", p.cyan, p.reset, p.bold, msg, p.reset) +} + +// Detail prints an aligned label/value line under the current section. +func (p *Printer) Detail(label, value string) { + _, _ = fmt.Fprintf(p.w, " %s%-12s%s %s\n", p.dim, label, p.reset, value) +} + +// SuccessDetail prints an aligned label/value line in green. +func (p *Printer) SuccessDetail(label, value string) { + _, _ = fmt.Fprintf(p.w, " %s%-12s%s %s%s%s\n", p.green, label, p.reset, p.green, value, p.reset) +} + +// Failure prints a red, banged failure banner preceded by a blank line. +func (p *Printer) Failure(msg string) { + _, _ = fmt.Fprintf(p.w, "\n%s!!%s %s%s%s\n", p.red, p.reset, p.bold, msg, p.reset) +} + +// Stream returns a writer that renders a child tool's output live, dimmed and +// indented under the current section. Callers must Close it to flush a trailing +// partial line. +func (p *Printer) Stream() io.WriteCloser { + return &indentWriter{printer: p} +} + +func (w *indentWriter) Write(p []byte) (int, error) { + for _, b := range p { + if b != '\n' { + w.partial.WriteByte(b) + + continue + } + + w.flushLine() + } + + return len(p), nil +} + +func (w *indentWriter) Close() error { + if w.partial.Len() > 0 { + w.flushLine() + } + + return nil +} + +func (w *indentWriter) flushLine() { + p := w.printer + + _, _ = fmt.Fprintf(p.w, " %s%s%s\n", p.dim, w.partial.String(), p.reset) + + w.partial.Reset() +} diff --git a/packages/go/driver/internal/console/printer_test.go b/packages/go/driver/internal/console/printer_test.go new file mode 100644 index 0000000..2182c1f --- /dev/null +++ b/packages/go/driver/internal/console/printer_test.go @@ -0,0 +1,103 @@ +package console + +import ( + "strings" + "testing" +) + +func TestDetectColorHonorsForceColor(t *testing.T) { + t.Setenv("NO_COLOR", "1") + t.Setenv("FORCE_COLOR", "1") + + if got := DetectColor(&strings.Builder{}); got != ColorAlways { + t.Fatalf("DetectColor with FORCE_COLOR = %v, want ColorAlways", got) + } +} + +func TestDetectColorHonorsNoColor(t *testing.T) { + t.Setenv("FORCE_COLOR", "") + t.Setenv("NO_COLOR", "1") + + if got := DetectColor(&strings.Builder{}); got != ColorNever { + t.Fatalf("DetectColor with NO_COLOR = %v, want ColorNever", got) + } +} + +func TestDetectColorNonTerminalIsNever(t *testing.T) { + t.Setenv("FORCE_COLOR", "") + t.Setenv("NO_COLOR", "") + + // A strings.Builder is not an *os.File, so it is never a terminal. + if got := DetectColor(&strings.Builder{}); got != ColorNever { + t.Fatalf("DetectColor for non-tty = %v, want ColorNever", got) + } +} + +func TestPrinterPlainRendering(t *testing.T) { + var buf strings.Builder + + p := NewPrinter(&buf, ColorNever) + + p.Section("Running Go formatting") + p.Detail("fmtkit", "Formatted 2 file(s).") + p.SuccessDetail("status", "done") + p.Failure("Running Go formatting failed") + + want := "\n==> Running Go formatting\n" + + " fmtkit Formatted 2 file(s).\n" + + " status done\n" + + "\n!! Running Go formatting failed\n" + + if buf.String() != want { + t.Fatalf("plain rendering mismatch\n--- got ---\n%q\n--- want ---\n%q", buf.String(), want) + } +} + +func TestPrinterColorRendering(t *testing.T) { + var buf strings.Builder + + p := NewPrinter(&buf, ColorAlways) + + p.Section("Formatting complete") + + got := buf.String() + + for _, want := range []string{"\033[36m", "\033[1m", "\033[0m", "Formatting complete"} { + if !strings.Contains(got, want) { + t.Fatalf("color section missing %q:\n%q", want, got) + } + } +} + +func TestPrinterColorAutoRendersPlain(t *testing.T) { + var buf strings.Builder + + NewPrinter(&buf, ColorAuto).Detail("label", "value") + + if strings.Contains(buf.String(), "\033[") { + t.Fatalf("ColorAuto emitted ANSI escapes: %q", buf.String()) + } +} + +func TestStreamIndentsAndFlushesPartialLine(t *testing.T) { + var buf strings.Builder + + p := NewPrinter(&buf, ColorNever) + + stream := p.Stream() + + _, _ = stream.Write([]byte("first line\nsecond ")) + _, _ = stream.Write([]byte("half\ntrailing")) + + if err := stream.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + want := " first line\n" + + " second half\n" + + " trailing\n" + + if buf.String() != want { + t.Fatalf("stream mismatch\n--- got ---\n%q\n--- want ---\n%q", buf.String(), want) + } +} diff --git a/packages/go/driver/internal/gotool/runner.go b/packages/go/driver/internal/gotool/runner.go index 0b6bb2d..8895dca 100644 --- a/packages/go/driver/internal/gotool/runner.go +++ b/packages/go/driver/internal/gotool/runner.go @@ -27,10 +27,21 @@ type Runner struct { // Run parses args for mode, executes the Go formatter and vet, renders the // report, and returns the process exit code. func (r Runner) Run(ctx context.Context, mode report.Mode, args []string) int { + _, code := r.RunReport(ctx, mode, args) + + return code +} + +// RunReport is Run that also returns the typed outcome so pipeline callers can +// derive their summary details from it rather than scraping the rendered text. +// On a setup failure it returns the zero Outcome and a non-zero code after +// reporting the problem to Stderr, so the outcome is only meaningful when the +// returned code is zero. +func (r Runner) RunReport(ctx context.Context, mode report.Mode, args []string) (Outcome, int) { inv, err := ParseInvocation(mode, args, r.Stderr) if err != nil { - return 1 + return Outcome{}, 1 } workRoot, err := os.Getwd() @@ -38,7 +49,7 @@ func (r Runner) Run(ctx context.Context, mode report.Mode, args []string) int { if err != nil { r.errf("resolve cwd: %v\n", err) - return 1 + return Outcome{}, 1 } reportRoot := workRoot @@ -52,7 +63,7 @@ func (r Runner) Run(ctx context.Context, mode report.Mode, args []string) int { if err != nil { r.errf("%v\n", err) - return 1 + return Outcome{}, 1 } outcome, err := Execute(ctx, Request{ @@ -66,7 +77,7 @@ func (r Runner) Run(ctx context.Context, mode report.Mode, args []string) int { if err != nil { r.errf("%v\n", err) - return 1 + return Outcome{}, 1 } renderer := report.Renderer{Root: reportRoot, Mode: mode} @@ -74,10 +85,10 @@ func (r Runner) Run(ctx context.Context, mode report.Mode, args []string) int { if err := renderer.Render(r.Stdout, inv.Output, outcome.Combined); err != nil { r.errf("render report: %v\n", err) - return 1 + return Outcome{}, 1 } - return outcome.ExitCode() + return outcome, outcome.ExitCode() } func (r Runner) errf(format string, args ...any) { diff --git a/packages/go/driver/internal/orchestrator/logging.go b/packages/go/driver/internal/orchestrator/logging.go deleted file mode 100644 index c93f71c..0000000 --- a/packages/go/driver/internal/orchestrator/logging.go +++ /dev/null @@ -1,114 +0,0 @@ -// Package orchestrator drives the full fmtkit formatting pipeline (TS/Vue -// formatting, TS/Vue lint, Go formatting) with sectioned, colorized progress -// output: each step's tool output streams live, indented under its section -// header, and is followed by the condensed summary lines. -package orchestrator - -import ( - "fmt" - "io" - "os" - "strings" - - "github.com/mattn/go-isatty" -) - -type logger struct { - w io.Writer - quiet bool - - bold string - dim string - cyan string - green string - red string - reset string -} - -// stream returns a writer that renders tool output live, dimmed and indented -// under the current section. Callers must Close it to flush a trailing -// partial line. - -type indentWriter struct { - logger *logger - partial strings.Builder -} - -func newLogger(w io.Writer, quiet bool) *logger { - l := &logger{w: w, quiet: quiet} - - if colorEnabled(w) { - l.bold = "\033[1m" - l.dim = "\033[2m" - l.cyan = "\033[36m" - l.green = "\033[32m" - l.red = "\033[31m" - l.reset = "\033[0m" - } - - return l -} - -func colorEnabled(w io.Writer) bool { - if os.Getenv("FORCE_COLOR") != "" { - return true - } - - if os.Getenv("NO_COLOR") != "" { - return false - } - - file, ok := w.(*os.File) - - return ok && isatty.IsTerminal(file.Fd()) -} - -func (l *logger) section(msg string) { - _, _ = fmt.Fprintf(l.w, "\n%s==>%s %s%s%s\n", l.cyan, l.reset, l.bold, msg, l.reset) -} - -func (l *logger) detail(label, value string) { - _, _ = fmt.Fprintf(l.w, " %s%-12s%s %s\n", l.dim, label, l.reset, value) -} - -func (l *logger) successDetail(label, value string) { - _, _ = fmt.Fprintf(l.w, " %s%-12s%s %s%s%s\n", l.green, label, l.reset, l.green, value, l.reset) -} - -func (l *logger) failure(msg string) { - _, _ = fmt.Fprintf(l.w, "\n%s!!%s %s%s%s\n", l.red, l.reset, l.bold, msg, l.reset) -} - -func (l *logger) stream() io.WriteCloser { - return &indentWriter{logger: l} -} - -func (w *indentWriter) Write(p []byte) (int, error) { - for _, b := range p { - if b != '\n' { - w.partial.WriteByte(b) - - continue - } - - w.flushLine() - } - - return len(p), nil -} - -func (w *indentWriter) Close() error { - if w.partial.Len() > 0 { - w.flushLine() - } - - return nil -} - -func (w *indentWriter) flushLine() { - l := w.logger - - _, _ = fmt.Fprintf(l.w, " %s%s%s\n", l.dim, w.partial.String(), l.reset) - - w.partial.Reset() -} diff --git a/packages/go/driver/internal/orchestrator/pipeline.go b/packages/go/driver/internal/orchestrator/pipeline.go deleted file mode 100644 index 0aedfe3..0000000 --- a/packages/go/driver/internal/orchestrator/pipeline.go +++ /dev/null @@ -1,166 +0,0 @@ -package orchestrator - -import ( - "bytes" - "context" - "errors" - "io" - "os/exec" - "strings" -) - -// Tools carries the three pipeline steps. The TS steps return an error whose -// exec.ExitError code propagates; the Go step reports its exit code directly. -type Tools struct { - TS func(ctx context.Context, scopes []string, output io.Writer) error - Lint func(ctx context.Context, scopes []string, output io.Writer) error - Go func(ctx context.Context, args []string, output io.Writer) int -} - -// Steps selects which parts of the pipeline run; the zero value (no -// selection flags) runs everything. -type Steps struct { - TS bool - Go bool -} - -// Pipeline renders sectioned progress on Stderr while running the steps. -type Pipeline struct { - Tools Tools - Steps Steps - - // Quiet restores the entrypoint's summary-only output; tool logs then - // only appear when a step fails. - Quiet bool - - Stderr io.Writer -} - -func (s Steps) normalized() Steps { - if !s.TS && !s.Go { - return Steps{TS: true, Go: true} - } - - return s -} - -// RunFormat runs TS/Vue lint (applying oxlint's safe fixes), TS/Vue formatting, -// and Go formatting against the given paths. Lint runs first so the formatting -// passes normalize whatever oxlint rewrites. -func (p Pipeline) RunFormat(ctx context.Context, paths []string) int { - if len(paths) == 0 { - paths = []string{"."} - } - - log := newLogger(p.Stderr, p.Quiet) - - log.section("Formatting target(s)") - log.detail("paths", strings.Join(paths, " ")) - - selected := p.Steps.normalized() - - type step struct { - label string - summarize func(string, *logger) - run func(ctx context.Context, output io.Writer) int - } - - var steps []step - - if selected.TS { - steps = append(steps, - step{ - label: "Running TS/Vue lint", - summarize: summarizeTSLint, - run: func(ctx context.Context, output io.Writer) int { - return exitCode(p.Tools.Lint(ctx, paths, output), output) - }, - }, - step{ - label: "Running TS/Vue formatting", - summarize: summarizeTSFormat, - run: func(ctx context.Context, output io.Writer) int { - return exitCode(p.Tools.TS(ctx, paths, output), output) - }, - }, - ) - } - - if selected.Go { - steps = append(steps, step{ - label: "Running Go formatting", - summarize: summarizeGoFormat, - run: func(ctx context.Context, output io.Writer) int { - return p.Tools.Go(ctx, append([]string{"format"}, paths...), output) - }, - }) - } - - for _, step := range steps { - if code := p.runStep(ctx, log, step.label, step.summarize, step.run); code != 0 { - return code - } - } - - log.section("Formatting complete") - log.successDetail("status", "done") - - return 0 -} - -// runStep captures a step's combined output, streaming it live unless quiet, -// and prints either its summary details or (on failure) the captured log. -func (p Pipeline) runStep(ctx context.Context, log *logger, label string, summarize func(string, *logger), run func(context.Context, io.Writer) int) int { - log.section(label) - - var captured bytes.Buffer - - output := io.Writer(&captured) - - var live io.WriteCloser - - if !p.Quiet { - live = log.stream() - output = io.MultiWriter(&captured, live) - } - - code := run(ctx, output) - - if live != nil { - _ = live.Close() - } - - if code != 0 { - log.failure(label + " failed") - - if p.Quiet { - _, _ = io.Copy(p.Stderr, bytes.NewReader(captured.Bytes())) - } - - return code - } - - summarize(captured.String(), log) - - return 0 -} - -// exitCode maps a step error to its exit code. Failures that never produced -// tool output (a missing sidecar, an unreadable working tree) surface their -// message through the step's output writer so they are visible both live and -// in the failure dump. -func exitCode(err error, output io.Writer) int { - if err == nil { - return 0 - } - - var exit *exec.ExitError - - if errors.As(err, &exit) { - return exit.ExitCode() - } - - _, _ = io.WriteString(output, err.Error()+"\n") - - return 1 -} diff --git a/packages/go/driver/internal/orchestrator/pipeline_test.go b/packages/go/driver/internal/orchestrator/pipeline_test.go deleted file mode 100644 index d09f62b..0000000 --- a/packages/go/driver/internal/orchestrator/pipeline_test.go +++ /dev/null @@ -1,307 +0,0 @@ -package orchestrator - -import ( - "bytes" - "context" - "errors" - "flag" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "testing" -) - -type invocation struct { - tool string - args []string -} - -var updateGolden = flag.Bool("update", false, "rewrite pipeline transcript golden files") - -// TestMain pins a color-free environment: CI task runners export FORCE_COLOR, -// which would inject ANSI codes into the captured output these tests assert. - -// The stub outputs mirror infra/test-binary-smoke.sh so -// the Go orchestrator preserves the entrypoint's summary contract. - -func TestMain(m *testing.M) { - _ = os.Unsetenv("FORCE_COLOR") - _ = os.Setenv("NO_COLOR", "1") - - os.Exit(m.Run()) -} - -const ( - stubTSOutput = "[blank-lines] processed 3 file(s) in /work, 0 changed\n" + - "Finished in 10ms on 3 files using 8 threads.\n" + - "[fluent-chains] processed 3 file(s) in /work, 1 changed\n" - - stubLintOutput = "Found 0 warnings and 0 errors.\n" - - stubGoOutput = "\nFormatter\n\n" + - " Formatted 2 file(s).\n\n" + - " Result: pass. 0 changed, 0 violation(s), 0 error(s).\n\n" + - "Vet\n\n" + - " go vet ./... passed.\n\n" + - " Result: pass. 0 error(s).\n" -) - -func stubTools(log *[]invocation, tsErr, lintErr error, goCode int) Tools { - return Tools{ - TS: func(_ context.Context, scopes []string, output io.Writer) error { - *log = append(*log, invocation{"ts", scopes}) - - _, _ = io.WriteString(output, stubTSOutput) - - return tsErr - }, - Lint: func(_ context.Context, scopes []string, output io.Writer) error { - *log = append(*log, invocation{"lint", scopes}) - - _, _ = io.WriteString(output, stubLintOutput) - - return lintErr - }, - Go: func(_ context.Context, args []string, output io.Writer) int { - *log = append(*log, invocation{"go", args}) - - _, _ = io.WriteString(output, stubGoOutput) - - return goCode - }, - } -} - -// TestRunFormatTranscriptGoldens pins the complete stderr transcript the -// pipeline renders, byte for byte, across the success and failure paths in both -// streaming and quiet modes. Color is forced off by TestMain, so the golden -// files carry no ANSI escapes. These goldens characterize the current -// rendering so later refactor stages cannot silently change it; regenerate with -// `go test ./driver/internal/orchestrator -run TestRunFormatTranscriptGoldens -update`. -func TestRunFormatTranscriptGoldens(t *testing.T) { - cases := []struct { - name string - quiet bool - tsErr error - goCode int - golden string - }{ - {"success", false, nil, 0, "transcript_success.txt"}, - {"success_quiet", true, nil, 0, "transcript_success_quiet.txt"}, - {"go_failure", false, nil, 3, "transcript_go_failure.txt"}, - {"go_failure_quiet", true, nil, 3, "transcript_go_failure_quiet.txt"}, - {"ts_failure", false, errors.New("sidecar exploded"), 0, "transcript_ts_failure.txt"}, - } - - for _, tc := range cases { - tc := tc - - t.Run(tc.name, func(t *testing.T) { - var log []invocation - - var stderr bytes.Buffer - - pipeline := Pipeline{ - Tools: stubTools(&log, tc.tsErr, nil, tc.goCode), - Quiet: tc.quiet, - Stderr: &stderr, - } - - pipeline.RunFormat(context.Background(), []string{"."}) - - path := filepath.Join("testdata", tc.golden) - - if *updateGolden { - if err := os.WriteFile(path, stderr.Bytes(), 0o644); err != nil { - t.Fatalf("update golden: %v", err) - } - - return - } - - want, err := os.ReadFile(path) - - if err != nil { - t.Fatalf("read golden: %v", err) - } - - if stderr.String() != string(want) { - t.Fatalf("transcript mismatch for %s\n--- got ---\n%s\n--- want ---\n%s", tc.golden, stderr.String(), want) - } - }) - } -} - -func TestRunFormatRunsStepsInOrder(t *testing.T) { - var log []invocation - - var stderr bytes.Buffer - - pipeline := Pipeline{ - Tools: stubTools(&log, nil, nil, 0), - Stderr: &stderr, - } - - if code := pipeline.RunFormat(context.Background(), []string{"."}); code != 0 { - t.Fatalf("RunFormat = %d, want 0\n%s", code, stderr.String()) - } - - want := []invocation{ - {"lint", []string{"."}}, - {"ts", []string{"."}}, - {"go", []string{"format", "."}}, - } - - if fmt.Sprint(log) != fmt.Sprint(want) { - t.Fatalf("invocations = %v, want %v", log, want) - } - - for _, needle := range []string{ - "==> Formatting target(s)", - "paths .", - "==> Running TS/Vue lint", - "oxlint Found 0 warnings and 0 errors.", - "==> Running TS/Vue formatting", - "blank-lines processed 3 file(s) in /work, 0 changed", - "oxfmt Finished in 10ms on 3 files using 8 threads.", - "fluent processed 3 file(s) in /work, 1 changed", - "==> Running Go formatting", - "fmtkit Formatted 2 file(s).", - "result pass. 0 changed, 0 violation(s), 0 error(s).", - "vet go vet ./... passed.", - "vet result pass. 0 error(s).", - "==> Formatting complete", - "status", - "done", - } { - if !strings.Contains(stderr.String(), needle) { - t.Fatalf("stderr missing %q:\n%s", needle, stderr.String()) - } - } -} - -func TestRunFormatStreamsToolOutputLive(t *testing.T) { - var log []invocation - - var stderr bytes.Buffer - - pipeline := Pipeline{ - Tools: stubTools(&log, nil, nil, 0), - Stderr: &stderr, - } - - if code := pipeline.RunFormat(context.Background(), nil); code != 0 { - t.Fatalf("RunFormat = %d, want 0", code) - } - - // The raw tool line appears indented (live stream) in addition to the - // condensed summary line. - if !strings.Contains(stderr.String(), " [blank-lines] processed 3 file(s) in /work, 0 changed") { - t.Fatalf("stderr missing live-streamed tool output:\n%s", stderr.String()) - } -} - -func TestRunFormatQuietHidesToolOutput(t *testing.T) { - var log []invocation - - var stderr bytes.Buffer - - pipeline := Pipeline{ - Tools: stubTools(&log, nil, nil, 0), - Quiet: true, - Stderr: &stderr, - } - - if code := pipeline.RunFormat(context.Background(), nil); code != 0 { - t.Fatalf("RunFormat = %d, want 0", code) - } - - if strings.Contains(stderr.String(), " [blank-lines]") { - t.Fatalf("quiet mode streamed tool output:\n%s", stderr.String()) - } - - if !strings.Contains(stderr.String(), "blank-lines processed 3 file(s) in /work, 0 changed") { - t.Fatalf("quiet mode lost summary:\n%s", stderr.String()) - } -} - -func TestRunFormatShortCircuitsOnTSFailure(t *testing.T) { - var log []invocation - - var stderr bytes.Buffer - - pipeline := Pipeline{ - Tools: stubTools(&log, errors.New("sidecar exploded"), nil, 0), - Stderr: &stderr, - } - - if code := pipeline.RunFormat(context.Background(), nil); code != 1 { - t.Fatalf("RunFormat = %d, want 1", code) - } - - if len(log) != 2 || log[0].tool != "lint" || log[1].tool != "ts" { - t.Fatalf("invocations = %v, want lint then ts (Go short-circuited)", log) - } - - if !strings.Contains(stderr.String(), "!! Running TS/Vue formatting failed") { - t.Fatalf("stderr missing failure banner:\n%s", stderr.String()) - } - - if !strings.Contains(stderr.String(), "sidecar exploded") { - t.Fatalf("stderr missing error message:\n%s", stderr.String()) - } -} - -func TestRunFormatQuietDumpsLogOnFailure(t *testing.T) { - var log []invocation - - var stderr bytes.Buffer - - pipeline := Pipeline{ - Tools: stubTools(&log, nil, nil, 3), - Quiet: true, - Stderr: &stderr, - } - - if code := pipeline.RunFormat(context.Background(), nil); code != 3 { - t.Fatalf("RunFormat = %d, want 3", code) - } - - if !strings.Contains(stderr.String(), "Formatted 2 file(s).") { - t.Fatalf("quiet failure did not dump captured log:\n%s", stderr.String()) - } -} - -func TestSummarizeTSLintFallbacks(t *testing.T) { - var out bytes.Buffer - - log := newLogger(&out, true) - - summarizeTSLint("[lint] no TS/Vue files to lint.\n", log) - - if !strings.Contains(out.String(), "oxlint no TS/Vue files to lint.") { - t.Fatalf("missing skip summary: %q", out.String()) - } - - out.Reset() - - summarizeTSLint("nothing interesting\n", log) - - if !strings.Contains(out.String(), "oxlint no issues found") { - t.Fatalf("missing fallback summary: %q", out.String()) - } -} - -func TestSummarizeTSFormatCountsMissing(t *testing.T) { - var out bytes.Buffer - - log := newLogger(&out, true) - - summarizeTSFormat("[sources] path not found, skipping: /work/a\n[sources] path not found, skipping: /work/b\n", log) - - if !strings.Contains(out.String(), "skipped 2 missing tracked file(s)") { - t.Fatalf("missing skipped summary: %q", out.String()) - } -} diff --git a/packages/go/driver/internal/orchestrator/summarize.go b/packages/go/driver/internal/orchestrator/summarize.go deleted file mode 100644 index 9508784..0000000 --- a/packages/go/driver/internal/orchestrator/summarize.go +++ /dev/null @@ -1,122 +0,0 @@ -package orchestrator - -import ( - "fmt" - "regexp" - "strings" - - "go.ollin.sh/fmtkit/driver/internal/sidecarproto" -) - -// The summarizers distill a step's captured output into the aligned detail -// lines shown under its section header. Lines the TS sidecar emits are parsed -// by sidecarproto; the Go-report scraping below stays here (G6 retires it). - -var ( - goFileSummaryPattern = regexp.MustCompile(`^ (Formatted|Checked) [0-9]+ file\(s\)\.$|^ No Go files found\.$`) - goVetSummaryPattern = regexp.MustCompile(`^ go vet \./\.\.\. passed\.$|^ Skipped automatic go vet `) - sourcesMissingPrefix = "[sources] path not found, skipping:" - lintNothingToLintLine = "[lint] no TS/Vue files to lint." - goResultPrefix = " Result: " -) - -func lines(log string) []string { - return strings.Split(log, "\n") -} - -func lastWithPrefix(logLines []string, prefix string) string { - var match string - - for _, line := range logLines { - if strings.HasPrefix(line, prefix) { - match = line - } - } - - return match -} - -func summarizeTSFormat(log string, l *logger) { - summary := sidecarproto.ParsePipelineSummary(log) - missing := 0 - - for _, line := range lines(log) { - if strings.HasPrefix(line, sourcesMissingPrefix) { - missing++ - } - } - - if summary.BlankLines != "" { - l.detail("blank-lines", summary.BlankLines) - } - - if missing > 0 { - l.detail("skipped", fmt.Sprintf("%d missing tracked file(s)", missing)) - } - - if summary.Oxfmt != "" { - l.detail("oxfmt", summary.Oxfmt) - } - - if summary.FluentChains != "" { - l.detail("fluent", summary.FluentChains) - } - - if summary.ValidateSyntax != "" { - l.detail("validated", summary.ValidateSyntax) - } -} - -func summarizeTSLint(log string, l *logger) { - if lastWithPrefix(lines(log), lintNothingToLintLine) != "" { - l.detail("oxlint", strings.TrimPrefix(lintNothingToLintLine, "[lint] ")) - - return - } - - if result := sidecarproto.ParseLintSummary(log).Result; result != "" { - l.detail("oxlint", result) - - return - } - - l.detail("oxlint", "no issues found") -} - -func summarizeGoFormat(log string, l *logger) { - var fileSummary, formatterResult, vetSummary, vetResult string - - for _, line := range lines(log) { - if fileSummary == "" && goFileSummaryPattern.MatchString(line) { - fileSummary = strings.TrimPrefix(line, " ") - } - - if vetSummary == "" && goVetSummaryPattern.MatchString(line) { - vetSummary = strings.TrimPrefix(line, " ") - } - - if strings.HasPrefix(line, goResultPrefix) { - if formatterResult == "" { - formatterResult = strings.TrimPrefix(line, goResultPrefix) - } - - vetResult = strings.TrimPrefix(line, goResultPrefix) - } - } - - if fileSummary != "" { - l.detail("fmtkit", fileSummary) - } - - if formatterResult != "" { - l.detail("result", formatterResult) - } - - if vetSummary != "" { - l.detail("vet", vetSummary) - } - - if vetResult != "" && vetResult != formatterResult { - l.detail("vet result", vetResult) - } -} diff --git a/packages/go/driver/internal/pipeline/pipeline.go b/packages/go/driver/internal/pipeline/pipeline.go new file mode 100644 index 0000000..cbf6b6b --- /dev/null +++ b/packages/go/driver/internal/pipeline/pipeline.go @@ -0,0 +1,103 @@ +// Package pipeline drives a sequence of typed pipeline steps, rendering +// sectioned, colorized progress: each step's tool output streams live, indented +// under its section header, followed by the condensed detail lines the step +// derives from its typed result. It owns only the section/tee/quiet-failure-dump +// mechanics; the concrete steps (and their detail computation) live with the +// composition root that builds them. +package pipeline + +import ( + "bytes" + "context" + "io" + + "go.ollin.sh/fmtkit/driver/internal/console" +) + +// Detail is one aligned label/value line shown under a step's section header. +type Detail struct { + Label string + Value string +} + +// Result is what a Step reports: the process exit code it wants (0 on success) +// and, on success, the detail lines to render under its section. +type Result struct { + ExitCode int + Details []Detail +} + +// Step is one unit of pipeline work. Label is the section header; Run writes the +// tool's live output to output (a tee of the live stream and, when a step needs +// it, its own capture) and returns the typed Result. +type Step interface { + Label() string + Run(ctx context.Context, output io.Writer) Result +} + +// Pipeline renders sectioned progress on Stderr while running the steps in +// order, short-circuiting on the first non-zero exit code. +type Pipeline struct { + Steps []Step + + // Quiet restores the summary-only output; a step's live tool log then only + // appears when it fails. + Quiet bool + + // Printer renders every section, detail, and failure banner. The caller + // constructs it once (resolving color at the boundary) and shares it. + Printer *console.Printer + + Stderr io.Writer +} + +// Run executes the steps in order, returning the first non-zero exit code or 0 +// when they all pass. +func (p Pipeline) Run(ctx context.Context) int { + for _, step := range p.Steps { + if code := p.runStep(ctx, step); code != 0 { + return code + } + } + + return 0 +} + +// runStep captures a step's combined output, streaming it live unless quiet, +// and prints either the step's detail lines or (on failure) the captured log. +func (p Pipeline) runStep(ctx context.Context, step Step) int { + p.Printer.Section(step.Label()) + + var captured bytes.Buffer + + output := io.Writer(&captured) + + var live io.WriteCloser + + if !p.Quiet { + live = p.Printer.Stream() + output = io.MultiWriter(&captured, live) + } + + result := step.Run(ctx, output) + + if live != nil { + _ = live.Close() + } + + if result.ExitCode != 0 { + p.Printer.Failure(step.Label() + " failed") + + if p.Quiet { + _, _ = io.Copy(p.Stderr, bytes.NewReader(captured.Bytes())) + } + + return result.ExitCode + } + + for _, detail := range result.Details { + p.Printer.Detail(detail.Label, detail.Value) + } + + return 0 +} diff --git a/packages/go/driver/internal/pipeline/pipeline_test.go b/packages/go/driver/internal/pipeline/pipeline_test.go new file mode 100644 index 0000000..c4d682c --- /dev/null +++ b/packages/go/driver/internal/pipeline/pipeline_test.go @@ -0,0 +1,302 @@ +package pipeline + +import ( + "bytes" + "context" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "go.ollin.sh/fmtkit/driver/internal/console" +) + +// fakeStep is a scripted Step: it streams a canned tool log to output, appends +// an optional trailing message (a non-exec error the real steps surface through +// output), and returns a fixed Result. It mirrors the tool stubs the earlier +// func-triple fakes used, now expressed against the Step interface. +type fakeStep struct { + label string + output string + trailing string + details []Detail + code int + + log *[]string +} + +var updateGolden = flag.Bool("update", false, "rewrite pipeline transcript golden files") + +func (s fakeStep) Label() string { return s.label } + +func (s fakeStep) Run(_ context.Context, output io.Writer) Result { + if s.log != nil { + *s.log = append(*s.log, s.label) + } + + _, _ = io.WriteString(output, s.output) + + if s.trailing != "" { + _, _ = io.WriteString(output, s.trailing) + } + + if s.code != 0 { + return Result{ExitCode: s.code} + } + + return Result{Details: s.details} +} + +const ( + stubTSOutput = "[blank-lines] processed 3 file(s) in /work, 0 changed\n" + + "Finished in 10ms on 3 files using 8 threads.\n" + + "[fluent-chains] processed 3 file(s) in /work, 1 changed\n" + + stubLintOutput = "Found 0 warnings and 0 errors.\n" + + stubGoOutput = "\nFormatter\n\n" + + " Formatted 2 file(s).\n\n" + + " Result: pass. 0 changed, 0 violation(s), 0 error(s).\n\n" + + "Vet\n\n" + + " go vet ./... passed.\n\n" + + " Result: pass. 0 error(s).\n" +) + +var ( + lintDetails = []Detail{{"oxlint", "Found 0 warnings and 0 errors."}} + + tsDetails = []Detail{ + {"blank-lines", "processed 3 file(s) in /work, 0 changed"}, + {"oxfmt", "Finished in 10ms on 3 files using 8 threads."}, + {"fluent", "processed 3 file(s) in /work, 1 changed"}, + } + + goDetails = []Detail{ + {"fmtkit", "Formatted 2 file(s)."}, + {"result", "pass. 0 changed, 0 violation(s), 0 error(s)."}, + {"vet", "go vet ./... passed."}, + {"vet result", "pass. 0 error(s)."}, + } +) + +// runFormat frames the three scripted steps exactly as the app composition root +// does (target header, completion footer), so the transcript the goldens pin is +// reproduced end to end without importing the app package. +func runFormat(t *testing.T, stderr io.Writer, quiet bool, steps []Step) int { + t.Helper() + + printer := console.NewPrinter(stderr, console.ColorNever) + + printer.Section("Formatting target(s)") + printer.Detail("paths", ".") + + code := Pipeline{Steps: steps, Quiet: quiet, Printer: printer, Stderr: stderr}.Run(context.Background()) + + if code == 0 { + printer.Section("Formatting complete") + printer.SuccessDetail("status", "done") + } + + return code +} + +// successSteps are the three passing steps in pipeline order (lint, TS, Go). +func successSteps(log *[]string) []Step { + return []Step{ + fakeStep{label: "Running TS/Vue lint", output: stubLintOutput, details: lintDetails, log: log}, + fakeStep{label: "Running TS/Vue formatting", output: stubTSOutput, details: tsDetails, log: log}, + fakeStep{label: "Running Go formatting", output: stubGoOutput, details: goDetails, log: log}, + } +} + +// TestRunFormatTranscriptGoldens pins the complete stderr transcript the +// pipeline renders, byte for byte, across the success and failure paths in both +// streaming and quiet modes. Color is forced off, so the golden files carry no +// ANSI escapes. These goldens characterize the rendering so refactors cannot +// silently change it; regenerate with +// `go test ./driver/internal/pipeline -run TestRunFormatTranscriptGoldens -update`. +func TestRunFormatTranscriptGoldens(t *testing.T) { + cases := []struct { + name string + quiet bool + steps []Step + golden string + }{ + {"success", false, successSteps(nil), "transcript_success.txt"}, + {"success_quiet", true, successSteps(nil), "transcript_success_quiet.txt"}, + { + "go_failure", false, + []Step{ + fakeStep{label: "Running TS/Vue lint", output: stubLintOutput, details: lintDetails}, + fakeStep{label: "Running TS/Vue formatting", output: stubTSOutput, details: tsDetails}, + fakeStep{label: "Running Go formatting", output: stubGoOutput, code: 3}, + }, + "transcript_go_failure.txt", + }, + { + "go_failure_quiet", true, + []Step{ + fakeStep{label: "Running TS/Vue lint", output: stubLintOutput, details: lintDetails}, + fakeStep{label: "Running TS/Vue formatting", output: stubTSOutput, details: tsDetails}, + fakeStep{label: "Running Go formatting", output: stubGoOutput, code: 3}, + }, + "transcript_go_failure_quiet.txt", + }, + { + "ts_failure", false, + []Step{ + fakeStep{label: "Running TS/Vue lint", output: stubLintOutput, details: lintDetails}, + fakeStep{label: "Running TS/Vue formatting", output: stubTSOutput, trailing: "sidecar exploded\n", code: 1}, + }, + "transcript_ts_failure.txt", + }, + } + + for _, tc := range cases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + var stderr bytes.Buffer + + runFormat(t, &stderr, tc.quiet, tc.steps) + + path := filepath.Join("testdata", tc.golden) + + if *updateGolden { + if err := os.WriteFile(path, stderr.Bytes(), 0o644); err != nil { + t.Fatalf("update golden: %v", err) + } + + return + } + + want, err := os.ReadFile(path) + + if err != nil { + t.Fatalf("read golden: %v", err) + } + + if stderr.String() != string(want) { + t.Fatalf("transcript mismatch for %s\n--- got ---\n%s\n--- want ---\n%s", tc.golden, stderr.String(), want) + } + }) + } +} + +func TestRunRunsStepsInOrder(t *testing.T) { + var log []string + + var stderr bytes.Buffer + + if code := runFormat(t, &stderr, false, successSteps(&log)); code != 0 { + t.Fatalf("Run = %d, want 0\n%s", code, stderr.String()) + } + + want := []string{"Running TS/Vue lint", "Running TS/Vue formatting", "Running Go formatting"} + + if fmt.Sprint(log) != fmt.Sprint(want) { + t.Fatalf("step order = %v, want %v", log, want) + } + + for _, needle := range []string{ + "==> Formatting target(s)", + "paths .", + "==> Running TS/Vue lint", + "oxlint Found 0 warnings and 0 errors.", + "==> Running TS/Vue formatting", + "blank-lines processed 3 file(s) in /work, 0 changed", + "oxfmt Finished in 10ms on 3 files using 8 threads.", + "fluent processed 3 file(s) in /work, 1 changed", + "==> Running Go formatting", + "fmtkit Formatted 2 file(s).", + "result pass. 0 changed, 0 violation(s), 0 error(s).", + "vet go vet ./... passed.", + "vet result pass. 0 error(s).", + "==> Formatting complete", + "status", + "done", + } { + if !strings.Contains(stderr.String(), needle) { + t.Fatalf("stderr missing %q:\n%s", needle, stderr.String()) + } + } +} + +func TestRunStreamsToolOutputLive(t *testing.T) { + var stderr bytes.Buffer + + if code := runFormat(t, &stderr, false, successSteps(nil)); code != 0 { + t.Fatalf("Run = %d, want 0", code) + } + + // The raw tool line appears indented (live stream) in addition to the + // condensed detail line. + if !strings.Contains(stderr.String(), " [blank-lines] processed 3 file(s) in /work, 0 changed") { + t.Fatalf("stderr missing live-streamed tool output:\n%s", stderr.String()) + } +} + +func TestRunQuietHidesToolOutput(t *testing.T) { + var stderr bytes.Buffer + + if code := runFormat(t, &stderr, true, successSteps(nil)); code != 0 { + t.Fatalf("Run = %d, want 0", code) + } + + if strings.Contains(stderr.String(), " [blank-lines]") { + t.Fatalf("quiet mode streamed tool output:\n%s", stderr.String()) + } + + if !strings.Contains(stderr.String(), "blank-lines processed 3 file(s) in /work, 0 changed") { + t.Fatalf("quiet mode lost detail:\n%s", stderr.String()) + } +} + +func TestRunShortCircuitsOnFailure(t *testing.T) { + var log []string + + var stderr bytes.Buffer + + steps := []Step{ + fakeStep{label: "Running TS/Vue lint", output: stubLintOutput, details: lintDetails, log: &log}, + fakeStep{label: "Running TS/Vue formatting", output: stubTSOutput, trailing: "sidecar exploded\n", code: 1, log: &log}, + fakeStep{label: "Running Go formatting", output: stubGoOutput, details: goDetails, log: &log}, + } + + if code := runFormat(t, &stderr, false, steps); code != 1 { + t.Fatalf("Run = %d, want 1", code) + } + + want := []string{"Running TS/Vue lint", "Running TS/Vue formatting"} + + if fmt.Sprint(log) != fmt.Sprint(want) { + t.Fatalf("step order = %v, want %v (Go should have been skipped)", log, want) + } + + if !strings.Contains(stderr.String(), "!! Running TS/Vue formatting failed") { + t.Fatalf("stderr missing failure banner:\n%s", stderr.String()) + } + + if !strings.Contains(stderr.String(), "sidecar exploded") { + t.Fatalf("stderr missing error message:\n%s", stderr.String()) + } +} + +func TestRunQuietDumpsLogOnFailure(t *testing.T) { + var stderr bytes.Buffer + + steps := []Step{ + fakeStep{label: "Running Go formatting", output: stubGoOutput, code: 3}, + } + + if code := runFormat(t, &stderr, true, steps); code != 3 { + t.Fatalf("Run = %d, want 3", code) + } + + if !strings.Contains(stderr.String(), "Formatted 2 file(s).") { + t.Fatalf("quiet failure did not dump captured log:\n%s", stderr.String()) + } +} diff --git a/packages/go/driver/internal/orchestrator/testdata/transcript_go_failure.txt b/packages/go/driver/internal/pipeline/testdata/transcript_go_failure.txt similarity index 100% rename from packages/go/driver/internal/orchestrator/testdata/transcript_go_failure.txt rename to packages/go/driver/internal/pipeline/testdata/transcript_go_failure.txt diff --git a/packages/go/driver/internal/orchestrator/testdata/transcript_go_failure_quiet.txt b/packages/go/driver/internal/pipeline/testdata/transcript_go_failure_quiet.txt similarity index 100% rename from packages/go/driver/internal/orchestrator/testdata/transcript_go_failure_quiet.txt rename to packages/go/driver/internal/pipeline/testdata/transcript_go_failure_quiet.txt diff --git a/packages/go/driver/internal/orchestrator/testdata/transcript_success.txt b/packages/go/driver/internal/pipeline/testdata/transcript_success.txt similarity index 100% rename from packages/go/driver/internal/orchestrator/testdata/transcript_success.txt rename to packages/go/driver/internal/pipeline/testdata/transcript_success.txt diff --git a/packages/go/driver/internal/orchestrator/testdata/transcript_success_quiet.txt b/packages/go/driver/internal/pipeline/testdata/transcript_success_quiet.txt similarity index 100% rename from packages/go/driver/internal/orchestrator/testdata/transcript_success_quiet.txt rename to packages/go/driver/internal/pipeline/testdata/transcript_success_quiet.txt diff --git a/packages/go/driver/internal/orchestrator/testdata/transcript_ts_failure.txt b/packages/go/driver/internal/pipeline/testdata/transcript_ts_failure.txt similarity index 100% rename from packages/go/driver/internal/orchestrator/testdata/transcript_ts_failure.txt rename to packages/go/driver/internal/pipeline/testdata/transcript_ts_failure.txt diff --git a/packages/go/driver/internal/sidecarproto/summary.go b/packages/go/driver/internal/sidecarproto/summary.go index 19c3219..25dc007 100644 --- a/packages/go/driver/internal/sidecarproto/summary.go +++ b/packages/go/driver/internal/sidecarproto/summary.go @@ -37,7 +37,7 @@ type LintSummary struct { // These prefixes and the oxlint result pattern are the sidecar's output // contract; only the lines the TS toolchain itself emits live here. Lines the // Go driver prints about its own bookkeeping (source-collection warnings, the -// no-files notice, the Go formatter report) stay with the orchestrator. +// no-files notice, the Go formatter report) stay with the pipeline steps. const ( blankLinesMatch = "[blank-lines] processed " blankLinesTrim = "[blank-lines] " diff --git a/packages/go/driver/internal/sidecarproto/summary_test.go b/packages/go/driver/internal/sidecarproto/summary_test.go index 752a92e..7b2db58 100644 --- a/packages/go/driver/internal/sidecarproto/summary_test.go +++ b/packages/go/driver/internal/sidecarproto/summary_test.go @@ -3,7 +3,7 @@ package sidecarproto import "testing" // sampleTSOutput mirrors the sidecar's pipeline stdout, lifted from the -// orchestrator's fake-tool fixtures. +// pipeline's fake-tool fixtures. const sampleTSOutput = "[blank-lines] processed 3 file(s) in /work, 0 changed\n" + "Finished in 10ms on 3 files using 8 threads.\n" + "[fluent-chains] processed 3 file(s) in /work, 1 changed\n" +