diff --git a/cmd/mutest/run.go b/cmd/mutest/run.go index 6e002a6..2d097ac 100644 --- a/cmd/mutest/run.go +++ b/cmd/mutest/run.go @@ -10,24 +10,35 @@ import ( "os" "runtime" "runtime/debug" - "strings" "time" + "github.com/fchimpan/mutest/config" "github.com/fchimpan/mutest/diff" "github.com/fchimpan/mutest/engine" "github.com/fchimpan/mutest/mutator" + "github.com/fchimpan/mutest/output" "github.com/fchimpan/mutest/runner" ) -// Set via ldflags at build time; fallback to debug.ReadBuildInfo for go install. +var ( + ErrTestsFailed = errors.New("mutation tests failed") + ErrInvalidConfig = errors.New("invalid config") + ErrDiscovery = errors.New("error discovering mutations") + ErrDiff = errors.New("diff parse error") + ErrInstrumentation = errors.New("instrumentation error") + ErrBuild = errors.New("build error") +) + +// Set via ldflags at build time; resolveVersion falls back to debug.ReadBuildInfo for go install. var ( version = "dev" commit = "none" date = "unknown" ) -func init() { - if version != "dev" { +func resolveVersion() (v, c, d string) { + v, c, d = version, commit, date + if v != "dev" { return } info, ok := debug.ReadBuildInfo() @@ -35,38 +46,24 @@ func init() { return } if info.Main.Version != "" && info.Main.Version != "(devel)" { - version = info.Main.Version + v = info.Main.Version } for _, s := range info.Settings { switch s.Key { case "vcs.revision": if len(s.Value) > 7 { - commit = s.Value[:7] + c = s.Value[:7] } else { - commit = s.Value + c = s.Value } case "vcs.time": - date = s.Value + d = s.Value } } + return } -type config struct { - Patterns []string - Workers int - Timeout time.Duration - Verbose bool - Run string - JSON bool - DryRun bool - Threshold float64 - SkipErrPropagation bool - Diff string -} - -// Run parses CLI arguments and executes the mutation testing pipeline. -// It returns an exit code suitable for os.Exit. -func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int { +func Run(ctx context.Context, args []string, stdout, stderr io.Writer) error { fs := flag.NewFlagSet("mutest", flag.ContinueOnError) fs.SetOutput(stderr) @@ -83,18 +80,19 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int { if err := fs.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { - return 0 + return nil } - return 2 + return err } if *showVersion { - if commit != "none" { - fmt.Fprintf(stdout, "mutest %s (commit: %s, built: %s)\n", version, commit, date) + v, c, d := resolveVersion() + if c != "none" { + fmt.Fprintf(stdout, "mutest %s (commit: %s, built: %s)\n", v, c, d) } else { - fmt.Fprintf(stdout, "mutest %s\n", version) + fmt.Fprintf(stdout, "mutest %s\n", v) } - return 0 + return nil } patterns := fs.Args() @@ -102,7 +100,7 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int { patterns = []string{"./..."} } - cfg := config{ + cfg := config.Config{ Patterns: patterns, Workers: *workers, Timeout: *timeout, @@ -118,23 +116,9 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int { return run(ctx, cfg, stdout, stderr) } -func validateConfig(cfg config) error { - if cfg.Workers <= 0 { - return fmt.Errorf("-workers must be > 0, got %d", cfg.Workers) - } - if cfg.Timeout <= 0 { - return fmt.Errorf("-timeout must be > 0, got %s", cfg.Timeout) - } - if cfg.Threshold < 0 || cfg.Threshold > 100 { - return fmt.Errorf("-threshold must be between 0 and 100, got %.1f", cfg.Threshold) - } - return nil -} - -func run(ctx context.Context, cfg config, stdout, stderr io.Writer) int { - if err := validateConfig(cfg); err != nil { - fmt.Fprintf(stderr, "mutest: %v\n", err) - return 2 +func run(ctx context.Context, cfg config.Config, stdout, stderr io.Writer) error { + if err := config.Validate(cfg); err != nil { + return fmt.Errorf("%w: %w", ErrInvalidConfig, err) } eng := engine.New(cfg.Patterns, &mutator.ComparisonMutator{}, &mutator.EqualityMutator{ @@ -143,56 +127,41 @@ func run(ctx context.Context, cfg config, stdout, stderr io.Writer) int { points, err := eng.DiscoverAll() if err != nil { - fmt.Fprintf(stderr, "mutest: error discovering mutations: %v\n", err) - return 2 + return fmt.Errorf("%w: %w", ErrDiscovery, err) } - // Informational messages go to stderr in JSON mode to keep stdout machine-readable. - info := stdout - if cfg.JSON { - info = stderr + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory: %w", err) } + rep := output.NewReporter(cfg, stdout, stderr, cwd) if cfg.Diff != "" { //mutest:skip cl, err := diff.ParseGitDiff(cfg.Diff) if err != nil { - fmt.Fprintf(stderr, "mutest: %v\n", err) - return 2 + return fmt.Errorf("%w: %w", ErrDiff, err) } before := len(points) points = diff.FilterPoints(points, cl) - fmt.Fprintf(info, "mutest: diff mode: filtered to %d of %d mutation points (changed vs %s)\n", len(points), before, cfg.Diff) + fmt.Fprintf(rep.Info(), "mutest: diff mode: filtered to %d of %d mutation points (changed vs %s)\n", len(points), before, cfg.Diff) } - cwd, _ := os.Getwd() - rpc := newRelPathCache(cwd) - - if len(points) == 0 { - if cfg.JSON { - if cfg.DryRun { - fmt.Fprintln(stdout, "[]") - } else { - writeJSONSummary(stdout, &runner.Summary{}, rpc, true) - } - } else { - fmt.Fprintln(stdout, "mutest: no mutation points found") - } - return 0 + if cfg.DryRun { + rep.DryRun(points) + return nil } - // dry-run: list discovered mutations and exit - if cfg.DryRun { - return runDryRun(cfg, stdout, points, rpc) + if len(points) == 0 { + rep.NoMutationPoints() + return nil } - fmt.Fprintf(info, "mutest: discovered %d mutation points\n", len(points)) + fmt.Fprintf(rep.Info(), "mutest: discovered %d mutation points\n", len(points)) + fmt.Fprintf(rep.Info(), "mutest: instrumenting packages...\n") - // Instrument all packages and build test binaries. - fmt.Fprintf(info, "mutest: instrumenting packages...\n") pkgs, err := eng.InstrumentAll(points) if err != nil { - fmt.Fprintf(stderr, "mutest: instrumentation error: %v\n", err) - return 2 + return fmt.Errorf("%w: %w", ErrInstrumentation, err) } // Ensure temp dirs are cleaned up even on SIGINT/SIGTERM. // defer alone is insufficient: os.Exit bypasses defers, and @@ -211,13 +180,12 @@ func run(ctx context.Context, cfg config, stdout, stderr io.Writer) int { } }() - fmt.Fprintf(info, "mutest: building test binaries...\n") + fmt.Fprintf(rep.Info(), "mutest: building test binaries...\n") if err := eng.BuildTestBinaries(ctx, pkgs); err != nil { - fmt.Fprintf(stderr, "mutest: build error: %v\n", err) - return 2 + return fmt.Errorf("%w: %w", ErrBuild, err) } - fmt.Fprintf(info, "mutest: testing with %d workers, %s timeout per mutant\n\n", cfg.Workers, cfg.Timeout) + fmt.Fprintf(rep.Info(), "mutest: testing with %d workers, %s timeout per mutant\n\n", cfg.Workers, cfg.Timeout) runCfg := runner.Config{ Workers: cfg.Workers, @@ -225,53 +193,18 @@ func run(ctx context.Context, cfg config, stdout, stderr io.Writer) int { Run: cfg.Run, } - var progress runner.ProgressFunc - if cfg.JSON { - if cfg.Verbose { - enc := newJSONEncoder(stdout) - progress = func(r runner.Result, done, total int) { - enc.Encode(toJSONResult(r, rpc)) - } - } - } else { - progress = func(r runner.Result, done, total int) { - status := "KILLED" - if r.Err != nil { - status = "ERROR" - } else if r.TimedOut { - status = "TIMEOUT" - } else if !r.Killed { - status = "SURVIVED" - } - fmt.Fprintf(stdout, "--- %s: %s:%d:%d %s (%.2fs)\n", - status, rpc.get(r.Point.File), r.Point.Line, r.Point.Column, r.Point.Desc, r.Duration.Seconds()) - if cfg.Verbose && r.Output != "" { - for line := range strings.SplitSeq(strings.TrimRight(r.Output, "\n"), "\n") { - fmt.Fprintf(stdout, " %s\n", line) - } - } - } - } + summary := runner.RunInstrumented(ctx, pkgs, runCfg, rep.ProgressFunc()) - summary := runner.RunInstrumented(ctx, pkgs, runCfg, progress) - - if cfg.JSON { - // When verbose, results were already streamed as NDJSON; - // emit summary without duplicating them. - writeJSONSummary(stdout, summary, rpc, !cfg.Verbose) - } else { - printReport(stdout, summary, rpc) - } + rep.Summary(summary) if cfg.Threshold > 0 { - killRate := calcKillRate(summary) - if killRate < cfg.Threshold { - return 1 + if output.CalcKillRate(summary) < cfg.Threshold { + return ErrTestsFailed } - return 0 + return nil } if summary.Survived > 0 { - return 1 + return ErrTestsFailed } - return 0 + return nil } diff --git a/cmd/mutest/run_test.go b/cmd/mutest/run_test.go index 6660d50..69298d9 100644 --- a/cmd/mutest/run_test.go +++ b/cmd/mutest/run_test.go @@ -4,15 +4,15 @@ import ( "bytes" "context" "encoding/json" - "fmt" + "errors" "os" "path/filepath" "strings" "testing" "time" - "github.com/fchimpan/mutest/mutator" - "github.com/fchimpan/mutest/runner" + "github.com/fchimpan/mutest/config" + "github.com/fchimpan/mutest/output" ) func chdir(t *testing.T, dir string) { @@ -27,32 +27,38 @@ func chdir(t *testing.T, dir string) { t.Cleanup(func() { os.Chdir(orig) }) } -// --- Existing tests (unchanged behavior) --- +// requireIs asserts that err matches the given sentinel via errors.Is. +func requireIs(t *testing.T, err, sentinel error) { + t.Helper() + if !errors.Is(err, sentinel) { + t.Fatalf("expected errors.Is(err, %v), got %v", sentinel, err) + } +} func TestRun_WithTestProject(t *testing.T) { chdir(t, "../../testdata/project") var stdout, stderr bytes.Buffer - cfg := config{ + cfg := config.Config{ Patterns: []string{"./..."}, Workers: 2, Timeout: 30 * time.Second, Verbose: true, } - code := run(context.Background(), cfg, &stdout, &stderr) - output := stdout.String() + err := run(context.Background(), cfg, &stdout, &stderr) + out := stdout.String() - if code != 1 { - t.Errorf("expected exit code 1, got %d\nstdout: %s\nstderr: %s", code, output, stderr.String()) + if !errors.Is(err, ErrTestsFailed) { + t.Errorf("expected ErrTestsFailed, got %v\nstdout: %s\nstderr: %s", err, out, stderr.String()) } - if !strings.Contains(output, "Mutation Testing Summary") { + if !strings.Contains(out, "Mutation Testing Summary") { t.Error("output should contain summary header") } - if !strings.Contains(output, "--- KILLED:") && !strings.Contains(output, "--- SURVIVED:") { + if !strings.Contains(out, "--- KILLED:") && !strings.Contains(out, "--- SURVIVED:") { t.Error("output should contain --- KILLED: or --- SURVIVED: markers") } - if !strings.Contains(output, "Survived mutants (test gaps):") { + if !strings.Contains(out, "Survived mutants (test gaps):") { t.Error("output should list survived mutants") } if stderr.Len() > 0 { @@ -76,16 +82,14 @@ func TestRun_NoMutationPoints(t *testing.T) { chdir(t, tmpDir) var stdout, stderr bytes.Buffer - cfg := config{ + cfg := config.Config{ Patterns: []string{"./..."}, Workers: 1, Timeout: 10 * time.Second, } - code := run(context.Background(), cfg, &stdout, &stderr) - - if code != 0 { - t.Errorf("expected exit code 0, got %d", code) + if err := run(context.Background(), cfg, &stdout, &stderr); err != nil { + t.Errorf("expected nil error, got %v", err) } if !strings.Contains(stdout.String(), "no mutation points found") { t.Errorf("expected 'no mutation points found', got: %s", stdout.String()) @@ -94,44 +98,37 @@ func TestRun_NoMutationPoints(t *testing.T) { func TestRun_InvalidPattern(t *testing.T) { var stdout, stderr bytes.Buffer - cfg := config{ + cfg := config.Config{ Patterns: []string{"./nonexistent_package_xyz"}, Workers: 1, Timeout: 10 * time.Second, } - code := run(context.Background(), cfg, &stdout, &stderr) - - if code != 2 { - t.Errorf("expected exit code 2, got %d", code) - } - if !strings.Contains(stderr.String(), "error discovering mutations") { - t.Errorf("expected error message in stderr, got: %s", stderr.String()) - } + err := run(context.Background(), cfg, &stdout, &stderr) + requireIs(t, err, ErrDiscovery) } func TestRun_NonVerbose(t *testing.T) { chdir(t, "../../testdata/project") var stdout, stderr bytes.Buffer - cfg := config{ + cfg := config.Config{ Patterns: []string{"./..."}, Workers: 2, Timeout: 30 * time.Second, Verbose: false, } - code := run(context.Background(), cfg, &stdout, &stderr) - output := stdout.String() + err := run(context.Background(), cfg, &stdout, &stderr) + out := stdout.String() - if code != 1 { - t.Errorf("expected exit code 1, got %d", code) + if !errors.Is(err, ErrTestsFailed) { + t.Errorf("expected ErrTestsFailed, got %v", err) } - // Default mode now shows per-mutant progress - if !strings.Contains(output, "--- KILLED:") && !strings.Contains(output, "--- SURVIVED:") { + if !strings.Contains(out, "--- KILLED:") && !strings.Contains(out, "--- SURVIVED:") { t.Error("default output should contain --- KILLED: or --- SURVIVED: markers") } - if !strings.Contains(output, "Mutation Testing Summary") { + if !strings.Contains(out, "Mutation Testing Summary") { t.Error("output should contain summary") } } @@ -140,149 +137,81 @@ func TestRun_Verbose_ShowsTestOutput(t *testing.T) { chdir(t, "../../testdata/project") var stdout, stderr bytes.Buffer - cfg := config{ + cfg := config.Config{ Patterns: []string{"./..."}, Workers: 2, Timeout: 30 * time.Second, Verbose: true, } - code := run(context.Background(), cfg, &stdout, &stderr) - output := stdout.String() + err := run(context.Background(), cfg, &stdout, &stderr) + out := stdout.String() - if code != 1 { - t.Errorf("expected exit code 1, got %d", code) + if !errors.Is(err, ErrTestsFailed) { + t.Errorf("expected ErrTestsFailed, got %v", err) } - if !strings.Contains(output, "--- KILLED:") && !strings.Contains(output, "--- SURVIVED:") { + if !strings.Contains(out, "--- KILLED:") && !strings.Contains(out, "--- SURVIVED:") { t.Error("verbose output should contain --- KILLED: or --- SURVIVED: markers") } - // Verbose mode should include indented test output from killed mutants - if !strings.Contains(output, " ") { + if !strings.Contains(out, " ") { t.Error("verbose output should contain indented test output lines") } } -func TestPrintReport_AllKilled(t *testing.T) { - var buf bytes.Buffer - summary := &runner.Summary{ - Total: 3, - Killed: 3, - Survived: 0, - Duration: 500 * time.Millisecond, - } - - printReport(&buf, summary, newRelPathCache("/base")) - output := buf.String() - - if !strings.Contains(output, "Score: 100.0%") { - t.Errorf("expected Score: 100.0%%, got: %s", output) - } - if strings.Contains(output, "Survived mutants") { - t.Error("should not list survived mutants when all are killed") - } -} - -func TestPrintReport_WithErrors(t *testing.T) { - var buf bytes.Buffer - summary := &runner.Summary{ - Total: 5, - Killed: 2, - Survived: 1, - Errors: 2, - Duration: 1 * time.Second, - } - - printReport(&buf, summary, newRelPathCache("/base")) - output := buf.String() - - if !strings.Contains(output, "Errors: 2") { - t.Errorf("expected Errors: 2 in output, got: %s", output) - } -} - -func TestPrintReport_AllErrors(t *testing.T) { - var buf bytes.Buffer - summary := &runner.Summary{ - Total: 2, - Killed: 0, - Survived: 0, - Errors: 2, - Duration: 100 * time.Millisecond, - } - - printReport(&buf, summary, newRelPathCache("/base")) - output := buf.String() - - if !strings.Contains(output, "Score: 0.0%") { - t.Errorf("expected Score: 0.0%%, got: %s", output) - } -} - -// --- Input validation tests --- - func TestValidateConfig_InvalidWorkers(t *testing.T) { var stdout, stderr bytes.Buffer - cfg := config{ + cfg := config.Config{ Patterns: []string{"./..."}, Workers: 0, Timeout: 10 * time.Second, } - code := run(context.Background(), cfg, &stdout, &stderr) - - if code != 2 { - t.Errorf("expected exit code 2, got %d", code) - } - if !strings.Contains(stderr.String(), "-workers must be > 0") { - t.Errorf("expected workers validation error, got: %s", stderr.String()) + err := run(context.Background(), cfg, &stdout, &stderr) + requireIs(t, err, ErrInvalidConfig) + if !strings.Contains(err.Error(), "-workers must be > 0") { + t.Errorf("expected workers validation error, got: %v", err) } } func TestValidateConfig_InvalidTimeout(t *testing.T) { var stdout, stderr bytes.Buffer - cfg := config{ + cfg := config.Config{ Patterns: []string{"./..."}, Workers: 1, Timeout: 0, } - code := run(context.Background(), cfg, &stdout, &stderr) - - if code != 2 { - t.Errorf("expected exit code 2, got %d", code) - } - if !strings.Contains(stderr.String(), "-timeout must be > 0") { - t.Errorf("expected timeout validation error, got: %s", stderr.String()) + err := run(context.Background(), cfg, &stdout, &stderr) + requireIs(t, err, ErrInvalidConfig) + if !strings.Contains(err.Error(), "-timeout must be > 0") { + t.Errorf("expected timeout validation error, got: %v", err) } } -// --- Dry-run tests --- - func TestRun_DryRun_Text(t *testing.T) { chdir(t, "../../testdata/project") var stdout, stderr bytes.Buffer - cfg := config{ + cfg := config.Config{ Patterns: []string{"./..."}, Workers: 1, Timeout: 10 * time.Second, DryRun: true, } - code := run(context.Background(), cfg, &stdout, &stderr) - output := stdout.String() + err := run(context.Background(), cfg, &stdout, &stderr) + out := stdout.String() - if code != 0 { - t.Errorf("expected exit code 0, got %d", code) + if err != nil { + t.Errorf("expected nil error, got %v", err) } - if !strings.Contains(output, "dry run") { + if !strings.Contains(out, "dry run") { t.Error("output should mention dry run") } - if !strings.Contains(output, "mutation points") { + if !strings.Contains(out, "mutation points") { t.Error("output should mention mutation points") } - // Should NOT contain test summary (tests were not run) - if strings.Contains(output, "Mutation Testing Summary") { + if strings.Contains(out, "Mutation Testing Summary") { t.Error("dry-run should not run tests or show summary") } } @@ -291,7 +220,7 @@ func TestRun_DryRun_JSON(t *testing.T) { chdir(t, "../../testdata/project") var stdout, stderr bytes.Buffer - cfg := config{ + cfg := config.Config{ Patterns: []string{"./..."}, Workers: 1, Timeout: 10 * time.Second, @@ -299,13 +228,11 @@ func TestRun_DryRun_JSON(t *testing.T) { JSON: true, } - code := run(context.Background(), cfg, &stdout, &stderr) - - if code != 0 { - t.Errorf("expected exit code 0, got %d", code) + if err := run(context.Background(), cfg, &stdout, &stderr); err != nil { + t.Errorf("expected nil error, got %v", err) } - var points []jsonMutationPoint + var points []output.JSONMutationPoint if err := json.Unmarshal(stdout.Bytes(), &points); err != nil { t.Fatalf("invalid JSON output: %v\nraw: %s", err, stdout.String()) } @@ -337,16 +264,15 @@ func TestRun_DryRun_NoMutations(t *testing.T) { chdir(t, tmpDir) var stdout, stderr bytes.Buffer - cfg := config{ + cfg := config.Config{ Patterns: []string{"./..."}, Workers: 1, Timeout: 10 * time.Second, DryRun: true, } - code := run(context.Background(), cfg, &stdout, &stderr) - if code != 0 { - t.Errorf("expected exit code 0, got %d", code) + if err := run(context.Background(), cfg, &stdout, &stderr); err != nil { + t.Errorf("expected nil error, got %v", err) } if !strings.Contains(stdout.String(), "no mutation points found") { t.Errorf("expected 'no mutation points found', got: %s", stdout.String()) @@ -367,7 +293,7 @@ func TestRun_DryRun_JSON_NoMutations(t *testing.T) { chdir(t, tmpDir) var stdout, stderr bytes.Buffer - cfg := config{ + cfg := config.Config{ Patterns: []string{"./..."}, Workers: 1, Timeout: 10 * time.Second, @@ -375,12 +301,11 @@ func TestRun_DryRun_JSON_NoMutations(t *testing.T) { JSON: true, } - code := run(context.Background(), cfg, &stdout, &stderr) - if code != 0 { - t.Errorf("expected exit code 0, got %d", code) + if err := run(context.Background(), cfg, &stdout, &stderr); err != nil { + t.Errorf("expected nil error, got %v", err) } - var points []jsonMutationPoint + var points []output.JSONMutationPoint if err := json.Unmarshal(stdout.Bytes(), &points); err != nil { t.Fatalf("invalid JSON: %v\nraw: %s", err, stdout.String()) } @@ -389,26 +314,23 @@ func TestRun_DryRun_JSON_NoMutations(t *testing.T) { } } -// --- JSON output tests --- - func TestRun_JSON_Summary(t *testing.T) { chdir(t, "../../testdata/project") var stdout, stderr bytes.Buffer - cfg := config{ + cfg := config.Config{ Patterns: []string{"./..."}, Workers: 2, Timeout: 30 * time.Second, JSON: true, } - code := run(context.Background(), cfg, &stdout, &stderr) - - if code != 1 { - t.Errorf("expected exit code 1, got %d\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String()) + err := run(context.Background(), cfg, &stdout, &stderr) + if !errors.Is(err, ErrTestsFailed) { + t.Errorf("expected ErrTestsFailed, got %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String()) } - var summary jsonSummary + var summary output.JSONSummary if err := json.Unmarshal(stdout.Bytes(), &summary); err != nil { t.Fatalf("invalid JSON: %v\nraw: %s", err, stdout.String()) } @@ -429,7 +351,6 @@ func TestRun_JSON_Summary(t *testing.T) { t.Errorf("unexpected kill rate: %f", summary.KillRate) } - // Informational messages should be on stderr, not stdout if strings.Contains(stdout.String(), "mutest:") { t.Error("JSON stdout should not contain informational messages") } @@ -439,7 +360,7 @@ func TestRun_JSON_Verbose_NDJSON(t *testing.T) { chdir(t, "../../testdata/project") var stdout, stderr bytes.Buffer - cfg := config{ + cfg := config.Config{ Patterns: []string{"./..."}, Workers: 2, Timeout: 30 * time.Second, @@ -447,10 +368,9 @@ func TestRun_JSON_Verbose_NDJSON(t *testing.T) { Verbose: true, } - code := run(context.Background(), cfg, &stdout, &stderr) - - if code != 1 { - t.Errorf("expected exit code 1, got %d", code) + err := run(context.Background(), cfg, &stdout, &stderr) + if !errors.Is(err, ErrTestsFailed) { + t.Errorf("expected ErrTestsFailed, got %v", err) } lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") @@ -458,9 +378,8 @@ func TestRun_JSON_Verbose_NDJSON(t *testing.T) { t.Fatalf("expected at least 2 NDJSON lines, got %d: %s", len(lines), stdout.String()) } - // All lines except the last should be individual results for i, line := range lines[:len(lines)-1] { - var result jsonResult + var result output.JSONResult if err := json.Unmarshal([]byte(line), &result); err != nil { t.Errorf("line %d: invalid JSON: %v\nraw: %s", i, err, line) continue @@ -470,15 +389,13 @@ func TestRun_JSON_Verbose_NDJSON(t *testing.T) { } } - // Last line should be the summary - var summary jsonSummary + var summary output.JSONSummary if err := json.Unmarshal([]byte(lines[len(lines)-1]), &summary); err != nil { t.Fatalf("last line not a valid summary: %v\nraw: %s", err, lines[len(lines)-1]) } if summary.Total == 0 { t.Error("summary total should be > 0") } - // In verbose mode, results are already streamed, so summary.Results should be nil/empty if len(summary.Results) != 0 { t.Error("verbose JSON summary should not duplicate results") } @@ -498,19 +415,18 @@ func TestRun_JSON_NoMutations(t *testing.T) { chdir(t, tmpDir) var stdout, stderr bytes.Buffer - cfg := config{ + cfg := config.Config{ Patterns: []string{"./..."}, Workers: 1, Timeout: 10 * time.Second, JSON: true, } - code := run(context.Background(), cfg, &stdout, &stderr) - if code != 0 { - t.Errorf("expected exit code 0, got %d", code) + if err := run(context.Background(), cfg, &stdout, &stderr); err != nil { + t.Errorf("expected nil error, got %v", err) } - var summary jsonSummary + var summary output.JSONSummary if err := json.Unmarshal(stdout.Bytes(), &summary); err != nil { t.Fatalf("invalid JSON: %v\nraw: %s", err, stdout.String()) } @@ -519,117 +435,19 @@ func TestRun_JSON_NoMutations(t *testing.T) { } } -// --- Unit tests for JSON helpers --- - -func TestWriteJSONSummary(t *testing.T) { - var buf bytes.Buffer - summary := &runner.Summary{ - Total: 4, - Killed: 3, - Survived: 1, - Errors: 0, - Duration: 1234 * time.Millisecond, - } - - writeJSONSummary(&buf, summary, newRelPathCache("/base"), true) - - var js jsonSummary - if err := json.Unmarshal(buf.Bytes(), &js); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - if js.Total != 4 { - t.Errorf("expected total 4, got %d", js.Total) - } - if js.KillRate != 75.0 { - t.Errorf("expected kill rate 75.0, got %f", js.KillRate) - } - if js.Duration != "1.234s" { - t.Errorf("expected duration '1.234s', got %s", js.Duration) - } -} - -func TestToJSONResult(t *testing.T) { - rpc := newRelPathCache("/work") - - t.Run("killed", func(t *testing.T) { - r := runner.Result{ - Point: mutator.MutationPoint{File: "/work/foo.go", Package: "foo", Line: 10, Column: 5}, - Killed: true, - Duration: 123 * time.Millisecond, - } - jr := toJSONResult(r, rpc) - if jr.Status != "killed" { - t.Errorf("expected status killed, got %s", jr.Status) - } - if jr.File != "foo.go" { - t.Errorf("expected relative path foo.go, got %s", jr.File) - } - if jr.Error != "" { - t.Error("killed result should have no error") - } - }) - - t.Run("survived", func(t *testing.T) { - r := runner.Result{ - Point: mutator.MutationPoint{File: "/work/bar.go"}, - Killed: false, - Duration: 50 * time.Millisecond, - } - jr := toJSONResult(r, rpc) - if jr.Status != "survived" { - t.Errorf("expected status survived, got %s", jr.Status) - } - }) - - t.Run("error", func(t *testing.T) { - r := runner.Result{ - Point: mutator.MutationPoint{File: "/work/baz.go"}, - Err: fmt.Errorf("prepare failed"), - Duration: 1 * time.Millisecond, - } - jr := toJSONResult(r, rpc) - if jr.Status != "error" { - t.Errorf("expected status error, got %s", jr.Status) - } - if jr.Error != "prepare failed" { - t.Errorf("expected error message, got %s", jr.Error) - } - }) - - t.Run("timed_out", func(t *testing.T) { - r := runner.Result{ - Point: mutator.MutationPoint{File: "/work/timeout.go"}, - Killed: false, - TimedOut: true, - Duration: 30 * time.Second, - } - jr := toJSONResult(r, rpc) - if jr.Status != "timeout" { - t.Errorf("expected status timeout, got %s", jr.Status) - } - if !jr.TimedOut { - t.Error("expected timed_out to be true") - } - }) -} - -// --- Threshold tests --- - func TestRun_Threshold_Met(t *testing.T) { chdir(t, "../../testdata/project") var stdout, stderr bytes.Buffer - cfg := config{ + cfg := config.Config{ Patterns: []string{"./..."}, Workers: 2, Timeout: 30 * time.Second, - Threshold: 20.0, // kill rate is 25%, so 20% threshold should pass + Threshold: 20.0, } - code := run(context.Background(), cfg, &stdout, &stderr) - - if code != 0 { - t.Errorf("expected exit code 0 (threshold met), got %d\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String()) + if err := run(context.Background(), cfg, &stdout, &stderr); err != nil { + t.Errorf("expected nil error (threshold met), got %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String()) } } @@ -637,17 +455,16 @@ func TestRun_Threshold_NotMet(t *testing.T) { chdir(t, "../../testdata/project") var stdout, stderr bytes.Buffer - cfg := config{ + cfg := config.Config{ Patterns: []string{"./..."}, Workers: 2, Timeout: 30 * time.Second, - Threshold: 90.0, // kill rate is 50%, so 90% threshold should fail + Threshold: 90.0, } - code := run(context.Background(), cfg, &stdout, &stderr) - - if code != 1 { - t.Errorf("expected exit code 1 (threshold not met), got %d", code) + err := run(context.Background(), cfg, &stdout, &stderr) + if !errors.Is(err, ErrTestsFailed) { + t.Errorf("expected ErrTestsFailed (threshold not met), got %v", err) } } @@ -655,18 +472,16 @@ func TestRun_Threshold_Zero_DefaultBehavior(t *testing.T) { chdir(t, "../../testdata/project") var stdout, stderr bytes.Buffer - cfg := config{ + cfg := config.Config{ Patterns: []string{"./..."}, Workers: 2, Timeout: 30 * time.Second, - Threshold: 0, // default: any survived = fail + Threshold: 0, } - code := run(context.Background(), cfg, &stdout, &stderr) - - // testdata has survived mutants, so with threshold=0 (default) it should return 1 - if code != 1 { - t.Errorf("expected exit code 1 (default: survived > 0), got %d", code) + err := run(context.Background(), cfg, &stdout, &stderr) + if !errors.Is(err, ErrTestsFailed) { + t.Errorf("expected ErrTestsFailed (default: survived > 0), got %v", err) } } @@ -681,25 +496,21 @@ func TestValidateConfig_InvalidThreshold(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var stdout, stderr bytes.Buffer - cfg := config{ + cfg := config.Config{ Patterns: []string{"./..."}, Workers: 1, Timeout: 10 * time.Second, Threshold: tt.threshold, } - code := run(context.Background(), cfg, &stdout, &stderr) - if code != 2 { - t.Errorf("expected exit code 2, got %d", code) - } - if !strings.Contains(stderr.String(), "-threshold") { - t.Errorf("expected threshold validation error, got: %s", stderr.String()) + err := run(context.Background(), cfg, &stdout, &stderr) + requireIs(t, err, ErrInvalidConfig) + if !strings.Contains(err.Error(), "-threshold") { + t.Errorf("expected threshold validation error, got: %v", err) } }) } } -// --- MutatorName in discovery --- - func TestRun_EqualityMutator_Discovered(t *testing.T) { tmpDir := t.TempDir() for name, content := range map[string]string{ @@ -714,7 +525,7 @@ func TestRun_EqualityMutator_Discovered(t *testing.T) { chdir(t, tmpDir) var stdout, stderr bytes.Buffer - cfg := config{ + cfg := config.Config{ Patterns: []string{"./..."}, Workers: 1, Timeout: 30 * time.Second, @@ -722,12 +533,11 @@ func TestRun_EqualityMutator_Discovered(t *testing.T) { JSON: true, } - code := run(context.Background(), cfg, &stdout, &stderr) - if code != 0 { - t.Errorf("expected exit code 0, got %d", code) + if err := run(context.Background(), cfg, &stdout, &stderr); err != nil { + t.Errorf("expected nil error, got %v", err) } - var points []jsonMutationPoint + var points []output.JSONMutationPoint if err := json.Unmarshal(stdout.Bytes(), &points); err != nil { t.Fatalf("invalid JSON: %v\nraw: %s", err, stdout.String()) } @@ -738,28 +548,3 @@ func TestRun_EqualityMutator_Discovered(t *testing.T) { t.Errorf("expected == to != mutation, got %s to %s", points[0].Original, points[0].Mutated) } } - -func TestCalcKillRate(t *testing.T) { - tests := []struct { - name string - summary *runner.Summary - want float64 - }{ - {"all killed", &runner.Summary{Total: 3, Killed: 3}, 100.0}, - {"none killed", &runner.Summary{Total: 3, Survived: 3}, 0.0}, - {"with errors", &runner.Summary{Total: 5, Killed: 2, Survived: 1, Errors: 2}, float64(2) / float64(3) * 100}, - {"all errors", &runner.Summary{Total: 2, Errors: 2}, 0.0}, - {"empty", &runner.Summary{}, 0.0}, - {"with timeout", &runner.Summary{Total: 5, Killed: 2, TimedOut: 1, Survived: 2}, float64(3) / float64(5) * 100}, - {"timeout and errors", &runner.Summary{Total: 6, Killed: 2, TimedOut: 1, Survived: 1, Errors: 2}, float64(3) / float64(4) * 100}, - {"all timeout", &runner.Summary{Total: 3, TimedOut: 3}, 100.0}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := calcKillRate(tt.summary) - if got != tt.want { - t.Errorf("calcKillRate() = %f, want %f", got, tt.want) - } - }) - } -} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..4ba256d --- /dev/null +++ b/config/config.go @@ -0,0 +1,35 @@ +// Package config defines the parsed CLI configuration for mutest. +package config + +import ( + "fmt" + "time" +) + +// Config holds the parsed CLI flags that drive the mutation testing pipeline. +type Config struct { + Patterns []string + Workers int + Timeout time.Duration + Verbose bool + Run string + JSON bool + DryRun bool + Threshold float64 + SkipErrPropagation bool + Diff string +} + +// Validate returns an error if any field has an invalid value. +func Validate(c Config) error { + if c.Workers <= 0 { + return fmt.Errorf("-workers must be > 0, got %d", c.Workers) + } + if c.Timeout <= 0 { + return fmt.Errorf("-timeout must be > 0, got %s", c.Timeout) + } + if c.Threshold < 0 || c.Threshold > 100 { + return fmt.Errorf("-threshold must be between 0 and 100, got %.1f", c.Threshold) + } + return nil +} diff --git a/main.go b/main.go index cab045c..665fac0 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "os" "os/signal" @@ -11,5 +12,9 @@ import ( func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() - os.Exit(mutest.Run(ctx, os.Args[1:], os.Stdout, os.Stderr)) + + if err := mutest.Run(ctx, os.Args[1:], os.Stdout, os.Stderr); err != nil { + fmt.Fprintf(os.Stderr, "mutest: %v\n", err) + os.Exit(1) + } } diff --git a/cmd/mutest/json.go b/output/json.go similarity index 52% rename from cmd/mutest/json.go rename to output/json.go index a2e8b9c..c556b33 100644 --- a/cmd/mutest/json.go +++ b/output/json.go @@ -1,4 +1,4 @@ -package mutest +package output import ( "encoding/json" @@ -6,10 +6,12 @@ import ( "math" "time" + "github.com/fchimpan/mutest/mutator" "github.com/fchimpan/mutest/runner" ) -type jsonMutationPoint struct { +// JSONMutationPoint is the JSON wire format for a discovered mutation point. +type JSONMutationPoint struct { File string `json:"file"` Package string `json:"package"` Line int `json:"line"` @@ -19,7 +21,8 @@ type jsonMutationPoint struct { Desc string `json:"desc"` } -type jsonResult struct { +// JSONResult is the JSON wire format for a single mutant test result. +type JSONResult struct { Status string `json:"status"` File string `json:"file"` Package string `json:"package"` @@ -33,7 +36,8 @@ type jsonResult struct { Error string `json:"error,omitempty"` } -type jsonSummary struct { +// JSONSummary is the JSON wire format for the aggregate run summary. +type JSONSummary struct { Total int `json:"total"` Killed int `json:"killed"` TimedOut int `json:"timed_out"` @@ -41,10 +45,11 @@ type jsonSummary struct { Errors int `json:"errors"` KillRate float64 `json:"kill_rate"` Duration string `json:"duration"` - Results []jsonResult `json:"results"` + Results []JSONResult `json:"results"` } -func toJSONResult(r runner.Result, rpc *relPathCache) jsonResult { +// ToJSONResult converts a runner.Result into the JSON wire format. +func ToJSONResult(r runner.Result, rpc *RelPathCache) JSONResult { status := "survived" if r.Err != nil { status = "error" @@ -53,9 +58,9 @@ func toJSONResult(r runner.Result, rpc *relPathCache) jsonResult { } else if r.Killed { status = "killed" } - jr := jsonResult{ + jr := JSONResult{ Status: status, - File: rpc.get(r.Point.File), + File: rpc.Get(r.Point.File), Package: r.Point.Package, Line: r.Point.Line, Column: r.Point.Column, @@ -71,18 +76,21 @@ func toJSONResult(r runner.Result, rpc *relPathCache) jsonResult { return jr } -func writeJSONSummary(w io.Writer, s *runner.Summary, rpc *relPathCache, includeResults bool) { - killRate := calcKillRate(s) +// WriteJSONSummary writes the aggregate JSON summary to w. When includeResults +// is false, the per-mutant Results slice is omitted (used for verbose NDJSON +// streaming where individual results were already emitted). +func WriteJSONSummary(w io.Writer, s *runner.Summary, rpc *RelPathCache, includeResults bool) { + killRate := CalcKillRate(s) - var results []jsonResult + var results []JSONResult if includeResults { - results = make([]jsonResult, len(s.Results)) + results = make([]JSONResult, len(s.Results)) for i, r := range s.Results { - results[i] = toJSONResult(r, rpc) + results[i] = ToJSONResult(r, rpc) } } - summary := jsonSummary{ + summary := JSONSummary{ Total: s.Total, Killed: s.Killed, TimedOut: s.TimedOut, @@ -92,11 +100,32 @@ func writeJSONSummary(w io.Writer, s *runner.Summary, rpc *relPathCache, include Duration: s.Duration.Round(time.Millisecond).String(), Results: results, } - enc := newJSONEncoder(w) + enc := NewJSONEncoder(w) enc.Encode(summary) } -func newJSONEncoder(w io.Writer) *json.Encoder { +// DryRunJSON writes the discovered mutation points as a JSON array to w. +func DryRunJSON(w io.Writer, points []mutator.MutationPoint, rpc *RelPathCache) { + pts := make([]JSONMutationPoint, len(points)) + for i, p := range points { + pts[i] = JSONMutationPoint{ + File: rpc.Get(p.File), + Package: p.Package, + Line: p.Line, + Column: p.Column, + Original: p.Original.String(), + Mutated: p.Mutated.String(), + Desc: p.Desc, + } + } + enc := NewJSONEncoder(w) + enc.SetIndent("", " ") + enc.Encode(pts) +} + +// NewJSONEncoder returns an encoder configured for mutest's JSON output +// (HTML escaping disabled). +func NewJSONEncoder(w io.Writer) *json.Encoder { enc := json.NewEncoder(w) enc.SetEscapeHTML(false) return enc diff --git a/output/json_test.go b/output/json_test.go new file mode 100644 index 0000000..128deeb --- /dev/null +++ b/output/json_test.go @@ -0,0 +1,104 @@ +package output + +import ( + "bytes" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/fchimpan/mutest/mutator" + "github.com/fchimpan/mutest/runner" +) + +func TestWriteJSONSummary(t *testing.T) { + var buf bytes.Buffer + summary := &runner.Summary{ + Total: 4, + Killed: 3, + Survived: 1, + Errors: 0, + Duration: 1234 * time.Millisecond, + } + + WriteJSONSummary(&buf, summary, NewRelPathCache("/base"), true) + + var js JSONSummary + if err := json.Unmarshal(buf.Bytes(), &js); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if js.Total != 4 { + t.Errorf("expected total 4, got %d", js.Total) + } + if js.KillRate != 75.0 { + t.Errorf("expected kill rate 75.0, got %f", js.KillRate) + } + if js.Duration != "1.234s" { + t.Errorf("expected duration '1.234s', got %s", js.Duration) + } +} + +func TestToJSONResult(t *testing.T) { + rpc := NewRelPathCache("/work") + + t.Run("killed", func(t *testing.T) { + r := runner.Result{ + Point: mutator.MutationPoint{File: "/work/foo.go", Package: "foo", Line: 10, Column: 5}, + Killed: true, + Duration: 123 * time.Millisecond, + } + jr := ToJSONResult(r, rpc) + if jr.Status != "killed" { + t.Errorf("expected status killed, got %s", jr.Status) + } + if jr.File != "foo.go" { + t.Errorf("expected relative path foo.go, got %s", jr.File) + } + if jr.Error != "" { + t.Error("killed result should have no error") + } + }) + + t.Run("survived", func(t *testing.T) { + r := runner.Result{ + Point: mutator.MutationPoint{File: "/work/bar.go"}, + Killed: false, + Duration: 50 * time.Millisecond, + } + jr := ToJSONResult(r, rpc) + if jr.Status != "survived" { + t.Errorf("expected status survived, got %s", jr.Status) + } + }) + + t.Run("error", func(t *testing.T) { + r := runner.Result{ + Point: mutator.MutationPoint{File: "/work/baz.go"}, + Err: fmt.Errorf("prepare failed"), + Duration: 1 * time.Millisecond, + } + jr := ToJSONResult(r, rpc) + if jr.Status != "error" { + t.Errorf("expected status error, got %s", jr.Status) + } + if jr.Error != "prepare failed" { + t.Errorf("expected error message, got %s", jr.Error) + } + }) + + t.Run("timed_out", func(t *testing.T) { + r := runner.Result{ + Point: mutator.MutationPoint{File: "/work/timeout.go"}, + Killed: false, + TimedOut: true, + Duration: 30 * time.Second, + } + jr := ToJSONResult(r, rpc) + if jr.Status != "timeout" { + t.Errorf("expected status timeout, got %s", jr.Status) + } + if !jr.TimedOut { + t.Error("expected timed_out to be true") + } + }) +} diff --git a/cmd/mutest/output.go b/output/output.go similarity index 52% rename from cmd/mutest/output.go rename to output/output.go index e675060..777384d 100644 --- a/cmd/mutest/output.go +++ b/output/output.go @@ -1,4 +1,5 @@ -package mutest +// Package output formats mutation testing results for the CLI (text and JSON). +package output import ( "fmt" @@ -10,33 +11,35 @@ import ( "github.com/fchimpan/mutest/runner" ) -func runDryRun(cfg config, stdout io.Writer, points []mutator.MutationPoint, rpc *relPathCache) int { - if cfg.JSON { - pts := make([]jsonMutationPoint, len(points)) - for i, p := range points { - pts[i] = jsonMutationPoint{ - File: rpc.get(p.File), - Package: p.Package, - Line: p.Line, - Column: p.Column, - Original: p.Original.String(), - Mutated: p.Mutated.String(), - Desc: p.Desc, - } - } - enc := newJSONEncoder(stdout) - enc.SetIndent("", " ") - enc.Encode(pts) - } else { - fmt.Fprintf(stdout, "mutest: discovered %d mutation points (dry run)\n\n", len(points)) - for i, p := range points { - fmt.Fprintf(stdout, " %d. %s:%d:%d %s\n", i+1, rpc.get(p.File), p.Line, p.Column, p.Desc) - } +// RelPathCache avoids repeated filepath.Rel calls for the same absolute path. +// Mutation testing typically targets a handful of files with many mutation points each, +// so caching the relative path per file avoids redundant work. +type RelPathCache struct { + base string + cache map[string]string +} + +// NewRelPathCache returns a cache that resolves paths relative to base. +func NewRelPathCache(base string) *RelPathCache { + return &RelPathCache{base: base, cache: make(map[string]string)} +} + +// Get returns the path relative to the cache's base, falling back to the +// absolute path on error. +func (c *RelPathCache) Get(path string) string { + if rel, ok := c.cache[path]; ok { + return rel } - return 0 + rel, err := filepath.Rel(c.base, path) + if err != nil { + rel = path + } + c.cache[path] = rel + return rel } -func printReport(w io.Writer, s *runner.Summary, rpc *relPathCache) { +// PrintReport writes the human-readable summary to w. +func PrintReport(w io.Writer, s *runner.Summary, rpc *RelPathCache) { fmt.Fprintln(w) fmt.Fprintln(w, "===== Mutation Testing Summary =====") fmt.Fprintf(w, "Total: %d\n", s.Total) @@ -48,7 +51,7 @@ func printReport(w io.Writer, s *runner.Summary, rpc *relPathCache) { if s.Errors > 0 { fmt.Fprintf(w, "Errors: %d\n", s.Errors) } - fmt.Fprintf(w, "Score: %.1f%%\n", calcKillRate(s)) + fmt.Fprintf(w, "Score: %.1f%%\n", CalcKillRate(s)) fmt.Fprintf(w, "Duration: %s\n", s.Duration.Round(time.Millisecond)) var survived []runner.Result @@ -62,36 +65,22 @@ func printReport(w io.Writer, s *runner.Summary, rpc *relPathCache) { fmt.Fprintln(w) fmt.Fprintln(w, "Survived mutants (test gaps):") for i, r := range survived { - fmt.Fprintf(w, " %d. %s:%d:%d %s\n", i+1, rpc.get(r.Point.File), r.Point.Line, r.Point.Column, r.Point.Desc) + fmt.Fprintf(w, " %d. %s:%d:%d %s\n", i+1, rpc.Get(r.Point.File), r.Point.Line, r.Point.Column, r.Point.Desc) } } } -// relPathCache avoids repeated filepath.Rel calls for the same absolute path. -// Mutation testing typically targets a handful of files with many mutation points each, -// so caching the relative path per file avoids redundant work. -type relPathCache struct { - base string - cache map[string]string -} - -func newRelPathCache(base string) *relPathCache { - return &relPathCache{base: base, cache: make(map[string]string)} -} - -func (c *relPathCache) get(path string) string { - if rel, ok := c.cache[path]; ok { - return rel - } - rel, err := filepath.Rel(c.base, path) - if err != nil { - rel = path +// DryRunText writes the discovered mutation points in human-readable form. +func DryRunText(w io.Writer, points []mutator.MutationPoint, rpc *RelPathCache) { + fmt.Fprintf(w, "mutest: discovered %d mutation points (dry run)\n\n", len(points)) + for i, p := range points { + fmt.Fprintf(w, " %d. %s:%d:%d %s\n", i+1, rpc.Get(p.File), p.Line, p.Column, p.Desc) } - c.cache[path] = rel - return rel } -func calcKillRate(s *runner.Summary) float64 { +// CalcKillRate returns the percentage of testable mutants that were detected +// (killed or timed out). Errors are excluded from the denominator. +func CalcKillRate(s *runner.Summary) float64 { detected := s.Killed + s.TimedOut testable := s.Total - s.Errors if testable > 0 { diff --git a/output/output_test.go b/output/output_test.go new file mode 100644 index 0000000..ab5e549 --- /dev/null +++ b/output/output_test.go @@ -0,0 +1,91 @@ +package output + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/fchimpan/mutest/runner" +) + +func TestPrintReport_AllKilled(t *testing.T) { + var buf bytes.Buffer + summary := &runner.Summary{ + Total: 3, + Killed: 3, + Survived: 0, + Duration: 500 * time.Millisecond, + } + + PrintReport(&buf, summary, NewRelPathCache("/base")) + out := buf.String() + + if !strings.Contains(out, "Score: 100.0%") { + t.Errorf("expected Score: 100.0%%, got: %s", out) + } + if strings.Contains(out, "Survived mutants") { + t.Error("should not list survived mutants when all are killed") + } +} + +func TestPrintReport_WithErrors(t *testing.T) { + var buf bytes.Buffer + summary := &runner.Summary{ + Total: 5, + Killed: 2, + Survived: 1, + Errors: 2, + Duration: 1 * time.Second, + } + + PrintReport(&buf, summary, NewRelPathCache("/base")) + out := buf.String() + + if !strings.Contains(out, "Errors: 2") { + t.Errorf("expected Errors: 2 in output, got: %s", out) + } +} + +func TestPrintReport_AllErrors(t *testing.T) { + var buf bytes.Buffer + summary := &runner.Summary{ + Total: 2, + Killed: 0, + Survived: 0, + Errors: 2, + Duration: 100 * time.Millisecond, + } + + PrintReport(&buf, summary, NewRelPathCache("/base")) + out := buf.String() + + if !strings.Contains(out, "Score: 0.0%") { + t.Errorf("expected Score: 0.0%%, got: %s", out) + } +} + +func TestCalcKillRate(t *testing.T) { + tests := []struct { + name string + summary *runner.Summary + want float64 + }{ + {"all killed", &runner.Summary{Total: 3, Killed: 3}, 100.0}, + {"none killed", &runner.Summary{Total: 3, Survived: 3}, 0.0}, + {"with errors", &runner.Summary{Total: 5, Killed: 2, Survived: 1, Errors: 2}, float64(2) / float64(3) * 100}, + {"all errors", &runner.Summary{Total: 2, Errors: 2}, 0.0}, + {"empty", &runner.Summary{}, 0.0}, + {"with timeout", &runner.Summary{Total: 5, Killed: 2, TimedOut: 1, Survived: 2}, float64(3) / float64(5) * 100}, + {"timeout and errors", &runner.Summary{Total: 6, Killed: 2, TimedOut: 1, Survived: 1, Errors: 2}, float64(3) / float64(4) * 100}, + {"all timeout", &runner.Summary{Total: 3, TimedOut: 3}, 100.0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CalcKillRate(tt.summary) + if got != tt.want { + t.Errorf("CalcKillRate() = %f, want %f", got, tt.want) + } + }) + } +} diff --git a/output/reporter.go b/output/reporter.go new file mode 100644 index 0000000..9f784c3 --- /dev/null +++ b/output/reporter.go @@ -0,0 +1,112 @@ +package output + +import ( + "fmt" + "io" + "strings" + + "github.com/fchimpan/mutest/config" + "github.com/fchimpan/mutest/mutator" + "github.com/fchimpan/mutest/runner" +) + +// Reporter encapsulates mode-dependent output (text vs JSON, verbose vs not) +// so that callers can dispatch to the right formatter without inspecting +// config flags themselves. +type Reporter struct { + cfg config.Config + stdout io.Writer + stderr io.Writer + rpc *RelPathCache +} + +// NewReporter builds a Reporter for the given config. baseDir is the directory +// against which file paths are reported (typically the current working dir). +func NewReporter(cfg config.Config, stdout, stderr io.Writer, baseDir string) *Reporter { + return &Reporter{ + cfg: cfg, + stdout: stdout, + stderr: stderr, + rpc: NewRelPathCache(baseDir), + } +} + +// Info returns the writer for progress/status messages. In JSON mode these +// go to stderr so stdout stays machine-readable. +func (r *Reporter) Info() io.Writer { + if r.cfg.JSON { + return r.stderr + } + return r.stdout +} + +// DryRun emits the discovered mutation points without running tests. +func (r *Reporter) DryRun(points []mutator.MutationPoint) { + if r.cfg.JSON { + // DryRunJSON encodes an empty slice as "[]" too, so no special-case needed. + DryRunJSON(r.stdout, points, r.rpc) + return + } + if len(points) == 0 { + fmt.Fprintln(r.stdout, "mutest: no mutation points found") + return + } + DryRunText(r.stdout, points, r.rpc) +} + +// NoMutationPoints emits the "0 points found" output when the full pipeline +// has nothing to do. +func (r *Reporter) NoMutationPoints() { + if r.cfg.JSON { + WriteJSONSummary(r.stdout, &runner.Summary{}, r.rpc, true) + return + } + fmt.Fprintln(r.stdout, "mutest: no mutation points found") +} + +// ProgressFunc builds the per-mutant callback for the runner. Returns nil +// for JSON non-verbose mode where only the final summary is emitted. +func (r *Reporter) ProgressFunc() runner.ProgressFunc { + if r.cfg.JSON { + if !r.cfg.Verbose { + return nil + } + enc := NewJSONEncoder(r.stdout) + return func(res runner.Result, done, total int) { + enc.Encode(ToJSONResult(res, r.rpc)) + } + } + return func(res runner.Result, done, total int) { + fmt.Fprintf(r.stdout, "--- %s: %s:%d:%d %s (%.2fs)\n", + statusOf(res), r.rpc.Get(res.Point.File), res.Point.Line, res.Point.Column, res.Point.Desc, res.Duration.Seconds()) + if r.cfg.Verbose && res.Output != "" { + for line := range strings.SplitSeq(strings.TrimRight(res.Output, "\n"), "\n") { + fmt.Fprintf(r.stdout, " %s\n", line) + } + } + } +} + +// Summary emits the aggregate post-run report. +func (r *Reporter) Summary(s *runner.Summary) { + if r.cfg.JSON { + // When verbose, results were already streamed as NDJSON; + // emit summary without duplicating them. + WriteJSONSummary(r.stdout, s, r.rpc, !r.cfg.Verbose) + return + } + PrintReport(r.stdout, s, r.rpc) +} + +func statusOf(r runner.Result) string { + switch { + case r.Err != nil: + return "ERROR" + case r.TimedOut: + return "TIMEOUT" + case !r.Killed: + return "SURVIVED" + default: + return "KILLED" + } +} diff --git a/output/reporter_test.go b/output/reporter_test.go new file mode 100644 index 0000000..65b219d --- /dev/null +++ b/output/reporter_test.go @@ -0,0 +1,201 @@ +package output + +import ( + "bytes" + "fmt" + "strings" + "testing" + "time" + + "github.com/fchimpan/mutest/config" + "github.com/fchimpan/mutest/mutator" + "github.com/fchimpan/mutest/runner" +) + +func TestReporter_Info(t *testing.T) { + var stdout, stderr bytes.Buffer + + textRep := NewReporter(config.Config{}, &stdout, &stderr, "/") + if textRep.Info() != &stdout { + t.Error("text mode: Info() should return stdout") + } + + jsonRep := NewReporter(config.Config{JSON: true}, &stdout, &stderr, "/") + if jsonRep.Info() != &stderr { + t.Error("json mode: Info() should return stderr") + } +} + +func TestReporter_DryRun_TextEmpty(t *testing.T) { + var stdout, stderr bytes.Buffer + rep := NewReporter(config.Config{}, &stdout, &stderr, "/") + rep.DryRun(nil) + if !strings.Contains(stdout.String(), "no mutation points found") { + t.Errorf("expected 'no mutation points found', got: %q", stdout.String()) + } +} + +func TestReporter_DryRun_TextWithPoints(t *testing.T) { + var stdout, stderr bytes.Buffer + rep := NewReporter(config.Config{}, &stdout, &stderr, "/") + rep.DryRun([]mutator.MutationPoint{{File: "/foo.go", Line: 1, Column: 1, Desc: "test"}}) + out := stdout.String() + if strings.Contains(out, "no mutation points found") { + t.Errorf("with points, should not say 'no mutation points found'; got: %q", out) + } + if !strings.Contains(out, "discovered 1 mutation points (dry run)") { + t.Errorf("expected dry run header, got: %q", out) + } +} + +func TestReporter_DryRun_JSON(t *testing.T) { + var stdout, stderr bytes.Buffer + rep := NewReporter(config.Config{JSON: true}, &stdout, &stderr, "/") + rep.DryRun([]mutator.MutationPoint{{File: "/foo.go", Line: 1, Column: 1, Desc: "test"}}) + out := stdout.String() + if !strings.HasPrefix(strings.TrimSpace(out), "[") { + t.Errorf("expected JSON array, got: %q", out) + } + if !strings.Contains(out, "foo.go") { + t.Errorf("expected file name in JSON, got: %q", out) + } +} + +func TestReporter_NoMutationPoints_Text(t *testing.T) { + var stdout, stderr bytes.Buffer + rep := NewReporter(config.Config{}, &stdout, &stderr, "/") + rep.NoMutationPoints() + if !strings.Contains(stdout.String(), "no mutation points found") { + t.Errorf("expected 'no mutation points found', got: %q", stdout.String()) + } +} + +func TestReporter_NoMutationPoints_JSON(t *testing.T) { + var stdout, stderr bytes.Buffer + rep := NewReporter(config.Config{JSON: true}, &stdout, &stderr, "/") + rep.NoMutationPoints() + out := strings.TrimSpace(stdout.String()) + if !strings.HasPrefix(out, "{") { + t.Errorf("expected JSON object, got: %q", out) + } + if !strings.Contains(out, `"total":0`) { + t.Errorf("expected total:0 in JSON, got: %q", out) + } +} + +func TestReporter_ProgressFunc_JSONNonVerboseReturnsNil(t *testing.T) { + var stdout, stderr bytes.Buffer + rep := NewReporter(config.Config{JSON: true, Verbose: false}, &stdout, &stderr, "/") + if rep.ProgressFunc() != nil { + t.Error("JSON non-verbose: ProgressFunc() should return nil") + } +} + +func TestReporter_ProgressFunc_JSONVerboseStreams(t *testing.T) { + var stdout, stderr bytes.Buffer + rep := NewReporter(config.Config{JSON: true, Verbose: true}, &stdout, &stderr, "/") + pf := rep.ProgressFunc() + if pf == nil { + t.Fatal("JSON verbose: ProgressFunc() should not be nil") + } + pf(runner.Result{ + Point: mutator.MutationPoint{File: "/foo.go", Line: 1, Column: 1, Desc: "test"}, + Killed: true, + Duration: time.Millisecond, + }, 1, 1) + out := strings.TrimSpace(stdout.String()) + if !strings.HasPrefix(out, "{") || !strings.Contains(out, `"status":"killed"`) { + t.Errorf("expected JSON result with killed status, got: %q", out) + } +} + +func TestReporter_ProgressFunc_TextEmitsStatus(t *testing.T) { + var stdout, stderr bytes.Buffer + rep := NewReporter(config.Config{}, &stdout, &stderr, "/") + pf := rep.ProgressFunc() + pf(runner.Result{ + Point: mutator.MutationPoint{File: "/foo.go", Line: 1, Column: 1, Desc: "test"}, + Killed: true, + Duration: time.Millisecond, + }, 1, 1) + if !strings.Contains(stdout.String(), "--- KILLED:") { + t.Errorf("expected '--- KILLED:' marker, got: %q", stdout.String()) + } +} + +func TestReporter_ProgressFunc_TextVerboseWithOutput(t *testing.T) { + var stdout, stderr bytes.Buffer + rep := NewReporter(config.Config{Verbose: true}, &stdout, &stderr, "/") + pf := rep.ProgressFunc() + pf(runner.Result{ + Point: mutator.MutationPoint{File: "/foo.go", Line: 1, Column: 1, Desc: "test"}, + Killed: true, + Output: "first line\nsecond line", + Duration: time.Millisecond, + }, 1, 1) + out := stdout.String() + if !strings.Contains(out, " first line") { + t.Errorf("expected indented 'first line', got: %q", out) + } + if !strings.Contains(out, " second line") { + t.Errorf("expected indented 'second line', got: %q", out) + } +} + +func TestReporter_ProgressFunc_TextVerboseEmptyOutput(t *testing.T) { + var stdout, stderr bytes.Buffer + rep := NewReporter(config.Config{Verbose: true}, &stdout, &stderr, "/") + pf := rep.ProgressFunc() + pf(runner.Result{ + Point: mutator.MutationPoint{File: "/foo.go", Line: 1, Column: 1, Desc: "test"}, + Killed: true, + Output: "", + Duration: time.Millisecond, + }, 1, 1) + for _, line := range strings.Split(stdout.String(), "\n") { + if strings.HasPrefix(line, " ") { + t.Errorf("expected no indented output for empty Output, got line: %q", line) + } + } +} + +func TestReporter_Summary_Text(t *testing.T) { + var stdout, stderr bytes.Buffer + rep := NewReporter(config.Config{}, &stdout, &stderr, "/") + rep.Summary(&runner.Summary{Total: 3, Killed: 3}) + if !strings.Contains(stdout.String(), "Mutation Testing Summary") { + t.Errorf("expected text summary header, got: %q", stdout.String()) + } +} + +func TestReporter_Summary_JSON(t *testing.T) { + var stdout, stderr bytes.Buffer + rep := NewReporter(config.Config{JSON: true}, &stdout, &stderr, "/") + rep.Summary(&runner.Summary{Total: 3, Killed: 3}) + out := strings.TrimSpace(stdout.String()) + if !strings.HasPrefix(out, "{") { + t.Errorf("expected JSON object, got: %q", out) + } +} + +func TestStatusOf(t *testing.T) { + tests := []struct { + name string + res runner.Result + want string + }{ + {"error", runner.Result{Err: fmt.Errorf("boom")}, "ERROR"}, + {"timeout", runner.Result{TimedOut: true}, "TIMEOUT"}, + {"survived", runner.Result{}, "SURVIVED"}, + {"killed", runner.Result{Killed: true}, "KILLED"}, + {"err takes precedence over timeout", runner.Result{Err: fmt.Errorf("x"), TimedOut: true}, "ERROR"}, + {"timeout takes precedence over killed", runner.Result{TimedOut: true, Killed: true}, "TIMEOUT"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := statusOf(tt.res); got != tt.want { + t.Errorf("statusOf() = %q, want %q", got, tt.want) + } + }) + } +}