diff --git a/.mockery.yaml b/.mockery.yaml index 1525537..e46f34a 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -11,6 +11,7 @@ packages: Workflow: Orchestrator: Mutagen: + MutationStreamer: gooze.dev/pkg/gooze/internal/adapter: config: dir: internal/adapter/mocks diff --git a/cmd/list.go b/cmd/list.go index ff8c215..d9b353d 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -22,12 +22,15 @@ func newListCmd() *cobra.Command { paths := parsePaths(args) useCache := !viper.GetBool(noCacheFlagName) reportsPath := m.Path(viper.GetString(outputFlagName)) + shardIndex, totalShards := parseShardFlag(runShardFlag) return workflow.Estimate(context.Background(), domain.EstimateArgs{ - Paths: paths, - Exclude: viper.GetStringSlice(excludeConfigKey), - UseCache: useCache, - Reports: reportsPath, + Paths: paths, + Exclude: viper.GetStringSlice(excludeConfigKey), + UseCache: useCache, + Reports: reportsPath, + ShardIndex: shardIndex, + TotalShardCount: totalShards, }) }, } diff --git a/cmd/root.go b/cmd/root.go index 583d285..0f53071 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -24,6 +24,7 @@ var fsAdapter adapter.SourceFSAdapter var testAdapter adapter.TestRunnerAdapter var orchestrator domain.Orchestrator var mutagen domain.Mutagen +var mutationStreamer domain.MutationStreamer var workflow domain.Workflow var ui controller.UI @@ -40,6 +41,9 @@ var noCacheFlag bool // excludePatterns is a root-level flag that filters files for applicable commands. var excludePatterns []string +var runParallelFlag int +var runShardFlag string + func init() { configureRootFlags(rootCmd) @@ -52,12 +56,14 @@ func init() { testAdapter = adapter.NewLocalTestRunnerAdapter() orchestrator = domain.NewOrchestrator(fsAdapter, testAdapter) mutagen = domain.NewMutagen(goFileAdapter, soirceFSAdapter) + mutationStreamer = domain.NewMutationStreamer(fsAdapter, reportStore, mutagen) workflow = domain.NewWorkflow( soirceFSAdapter, reportStore, ui, orchestrator, mutagen, + mutationStreamer, ) } @@ -131,6 +137,8 @@ func configureRootFlags(cmd *cobra.Command) { cmd.PersistentFlags().StringArrayVarP(&excludePatterns, excludeFlagName, "x", viper.GetStringSlice(excludeConfigKey), "exclude files matching regex (can be repeated)") bindFlagToConfig(cmd.PersistentFlags().Lookup(excludeFlagName), excludeConfigKey) + cmd.PersistentFlags().StringVarP(&runShardFlag, "shard", "s", "", "shard index and total shard count in the format INDEX/TOTAL (e.g., 0/3)") + // Add verbose and log output flags (handled directly; not bound to Viper). cmd.PersistentFlags().BoolVarP(&verboseFlag, "verbose", "v", false, "enable verbose logging") cmd.PersistentFlags().StringVar(&logOutputFlag, "log-output", "", "path to the log output file") diff --git a/cmd/run.go b/cmd/run.go index d7fe4f3..6673b5c 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -13,8 +13,6 @@ import ( ) var mutationTimeoutFlag int64 -var runParallelFlag int -var runShardFlag string // runCmd represents the run command. var runCmd = newRunCmd() @@ -34,15 +32,14 @@ func newRunCmd() *cobra.Command { return workflow.Test(context.Background(), domain.TestArgs{ EstimateArgs: domain.EstimateArgs{ - Paths: paths, - Exclude: viper.GetStringSlice(excludeConfigKey), - UseCache: useCache, - Reports: reportsPath, + Paths: paths, + Exclude: viper.GetStringSlice(excludeConfigKey), + UseCache: useCache, + Reports: reportsPath, + ShardIndex: shardIndex, + TotalShardCount: totalShards, }, - Reports: reportsPath, Threads: threads, - ShardIndex: shardIndex, - TotalShardCount: totalShards, MutationTimeout: time.Duration(timeoutSeconds) * time.Second, }) }, @@ -63,7 +60,6 @@ func configureRunFlags(cmd *cobra.Command) { cmd.Flags().IntVarP(&runParallelFlag, runParallelFlagName, "p", viper.GetInt(runParallelConfigKey), "number of parallel workers for mutation testing") bindFlagToConfig(cmd.Flags().Lookup(runParallelFlagName), runParallelConfigKey) - cmd.Flags().StringVarP(&runShardFlag, "shard", "s", "", "shard index and total shard count in the format INDEX/TOTAL (e.g., 0/3)") } func parseShardFlag(shard string) (int, int) { diff --git a/internal/adapter/mocks/mock_ReportStore.go b/internal/adapter/mocks/mock_ReportStore.go index deb275f..9c908a0 100644 --- a/internal/adapter/mocks/mock_ReportStore.go +++ b/internal/adapter/mocks/mock_ReportStore.go @@ -345,6 +345,54 @@ func (_c *MockReportStore_SaveReports_Call) RunAndReturn(run func(context.Contex return _c } +// SaveReportsStream provides a mock function with given fields: ctx, path, reports +func (_m *MockReportStore) SaveReportsStream(ctx context.Context, path model.Path, reports <-chan model.Report) error { + ret := _m.Called(ctx, path, reports) + + if len(ret) == 0 { + panic("no return value specified for SaveReportsStream") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, model.Path, <-chan model.Report) error); ok { + r0 = rf(ctx, path, reports) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockReportStore_SaveReportsStream_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveReportsStream' +type MockReportStore_SaveReportsStream_Call struct { + *mock.Call +} + +// SaveReportsStream is a helper method to define mock.On call +// - ctx context.Context +// - path model.Path +// - reports <-chan model.Report +func (_e *MockReportStore_Expecter) SaveReportsStream(ctx interface{}, path interface{}, reports interface{}) *MockReportStore_SaveReportsStream_Call { + return &MockReportStore_SaveReportsStream_Call{Call: _e.mock.On("SaveReportsStream", ctx, path, reports)} +} + +func (_c *MockReportStore_SaveReportsStream_Call) Run(run func(ctx context.Context, path model.Path, reports <-chan model.Report)) *MockReportStore_SaveReportsStream_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(model.Path), args[2].(<-chan model.Report)) + }) + return _c +} + +func (_c *MockReportStore_SaveReportsStream_Call) Return(_a0 error) *MockReportStore_SaveReportsStream_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockReportStore_SaveReportsStream_Call) RunAndReturn(run func(context.Context, model.Path, <-chan model.Report) error) *MockReportStore_SaveReportsStream_Call { + _c.Call.Return(run) + return _c +} + // SaveSpillReports provides a mock function with given fields: ctx, path, reports func (_m *MockReportStore) SaveSpillReports(ctx context.Context, path model.Path, reports pkg.FileSpill[model.Report]) error { ret := _m.Called(ctx, path, reports) diff --git a/internal/adapter/mocks/mock_SourceFSAdapter.go b/internal/adapter/mocks/mock_SourceFSAdapter.go index cb147a2..d8e7e48 100644 --- a/internal/adapter/mocks/mock_SourceFSAdapter.go +++ b/internal/adapter/mocks/mock_SourceFSAdapter.go @@ -379,6 +379,80 @@ func (_c *MockSourceFSAdapter_Get_Call) RunAndReturn(run func(context.Context, [ return _c } +// GetStream provides a mock function with given fields: ctx, roots, ignore +func (_m *MockSourceFSAdapter) GetStream(ctx context.Context, roots []model.Path, ignore ...string) (<-chan model.Source, error) { + _va := make([]interface{}, len(ignore)) + for _i := range ignore { + _va[_i] = ignore[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, roots) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for GetStream") + } + + var r0 <-chan model.Source + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, []model.Path, ...string) (<-chan model.Source, error)); ok { + return rf(ctx, roots, ignore...) + } + if rf, ok := ret.Get(0).(func(context.Context, []model.Path, ...string) <-chan model.Source); ok { + r0 = rf(ctx, roots, ignore...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(<-chan model.Source) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, []model.Path, ...string) error); ok { + r1 = rf(ctx, roots, ignore...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockSourceFSAdapter_GetStream_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetStream' +type MockSourceFSAdapter_GetStream_Call struct { + *mock.Call +} + +// GetStream is a helper method to define mock.On call +// - ctx context.Context +// - roots []model.Path +// - ignore ...string +func (_e *MockSourceFSAdapter_Expecter) GetStream(ctx interface{}, roots interface{}, ignore ...interface{}) *MockSourceFSAdapter_GetStream_Call { + return &MockSourceFSAdapter_GetStream_Call{Call: _e.mock.On("GetStream", + append([]interface{}{ctx, roots}, ignore...)...)} +} + +func (_c *MockSourceFSAdapter_GetStream_Call) Run(run func(ctx context.Context, roots []model.Path, ignore ...string)) *MockSourceFSAdapter_GetStream_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]string, len(args)-2) + for i, a := range args[2:] { + if a != nil { + variadicArgs[i] = a.(string) + } + } + run(args[0].(context.Context), args[1].([]model.Path), variadicArgs...) + }) + return _c +} + +func (_c *MockSourceFSAdapter_GetStream_Call) Return(_a0 <-chan model.Source, _a1 error) *MockSourceFSAdapter_GetStream_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockSourceFSAdapter_GetStream_Call) RunAndReturn(run func(context.Context, []model.Path, ...string) (<-chan model.Source, error)) *MockSourceFSAdapter_GetStream_Call { + _c.Call.Return(run) + return _c +} + // HashFile provides a mock function with given fields: ctx, path func (_m *MockSourceFSAdapter) HashFile(ctx context.Context, path model.Path) (string, error) { ret := _m.Called(ctx, path) diff --git a/internal/adapter/report_store.go b/internal/adapter/report_store.go index 2009f6a..bcdc917 100644 --- a/internal/adapter/report_store.go +++ b/internal/adapter/report_store.go @@ -22,12 +22,16 @@ const indexFileName = "_index.yaml" // ReportStore persists and retrieves mutation reports. type ReportStore interface { + // Deprecated: Use SaveReportsStream for better performance and lower memory usage on large mutation sets. SaveReports(ctx context.Context, path m.Path, reports []m.Report) error + // Deprecated: Use SaveReportsStream or better performance and lower memory usage on large mutation sets. SaveSpillReports(ctx context.Context, path m.Path, reports pkg.FileSpill[m.Report]) error + SaveReportsStream(ctx context.Context, path m.Path, reports <-chan m.Report) error RegenerateIndex(ctx context.Context, path m.Path) error LoadReports(ctx context.Context, path m.Path) ([]m.Report, error) LoadSpillReports(ctx context.Context, path m.Path) (pkg.FileSpill[m.Report], error) CheckUpdates(ctx context.Context, path m.Path, sources []m.Source) ([]m.Source, error) + CheckUpdate(ctx context.Context, path m.Path, source m.Source) (bool, error) CleanReports(ctx context.Context, path m.Path, sources []m.Source) error } @@ -160,6 +164,39 @@ func (rs *LocalReportStore) SaveSpillReports(ctx context.Context, path m.Path, r }) } +// SaveReportsStream writes reports from the provided channel into the specified directory until the channel is closed. +func (rs *LocalReportStore) SaveReportsStream(ctx context.Context, path m.Path, reports <-chan m.Report) error { + if err := ctx.Err(); err != nil { + return err + } + dirPath := string(path) + if dirPath == "" { + return fmt.Errorf("reports directory path is required") + } + if err := os.MkdirAll(string(path), 0o750); err != nil { + return fmt.Errorf("create reports directory: %w", err) + } + for report := range reports { + reportHash := rs.computeReportHash(report.Result) + if reportHash == "" { + return nil + } + + data, err := rs.marshalReport(report) + if err != nil { + return fmt.Errorf("marshal report to YAML: %w", err) + } + + name := reportHash + ".yaml" + + fullPath := filepath.Join(dirPath, name) + if err := os.WriteFile(fullPath, data, 0o600); err != nil { + return fmt.Errorf("write report file %s: %w", fullPath, err) + } + } + return nil +} + // RegenerateIndex rebuilds and writes `_index.yaml` from the report files in `path`. func (rs *LocalReportStore) RegenerateIndex(ctx context.Context, path m.Path) error { if err := ctx.Err(); err != nil { @@ -467,6 +504,54 @@ func (rs *LocalReportStore) CheckUpdates(ctx context.Context, path m.Path, sourc return changed, nil } +func (rs *LocalReportStore) CheckUpdate(ctx context.Context, path m.Path, source m.Source) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + dirPath := string(path) + if dirPath == "" { + return false, fmt.Errorf("reports directory path is required") + } + + exists, err := rs.reportsDirExists(dirPath) + if err != nil { + return false, err + } + + if !exists { + // No prior reports: source needs to be tested + return true, nil + } + + if source.Origin == nil || source.Origin.FullPath == "" { + return false, fmt.Errorf("source missing origin information") + } + + reports, err := rs.loadReportsFromDir(dirPath) + if err != nil { + return false, err + } + + stored := rs.buildStoredSourceState(reports) + pathStr := string(source.Origin.FullPath) + + st, ok := stored[pathStr] + if !ok { + // No stored report for this source: needs testing + return true, nil + } + + if rs.sourceHashChanged(st.source, source) { + return true, nil + } + + if rs.mutatorsChanged(st.mutator) { + return true, nil + } + + return false, nil +} + func (rs *LocalReportStore) buildCurrentSourceMap(sources []m.Source) map[string]m.Source { currentByPath := map[string]m.Source{} diff --git a/internal/adapter/report_store_test.go b/internal/adapter/report_store_test.go index ed35511..08d9645 100644 --- a/internal/adapter/report_store_test.go +++ b/internal/adapter/report_store_test.go @@ -357,6 +357,141 @@ func TestLocalReportStore_LoadSpillReports_LoadsReportsIntoSpill(t *testing.T) { } } +func TestLocalReportStore_SaveReportsStream_WritesHashedYAMLPerReport(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + rs := &LocalReportStore{} + + report1 := m.Report{ + Source: m.Source{ + Origin: &m.File{FullPath: m.Path("/abs/path/file1.go"), Hash: "abc123"}, + Test: &m.File{FullPath: m.Path("/abs/path/file1_test.go"), Hash: "def456"}, + }, + Result: m.Result{ + m.MutationBoolean: { + {MutationID: "m1", Status: m.Killed, Err: nil}, + }, + }, + } + + report2 := m.Report{ + Source: m.Source{ + Origin: &m.File{FullPath: m.Path("/abs/path/file2.go"), Hash: "ghi789"}, + Test: &m.File{FullPath: m.Path("/abs/path/file2_test.go"), Hash: "jkl012"}, + }, + Result: m.Result{ + m.MutationArithmetic: { + {MutationID: "m2", Status: m.Survived, Err: nil}, + }, + }, + } + + // Create channel and send reports + reportsCh := make(chan m.Report, 2) + reportsCh <- report1 + reportsCh <- report2 + close(reportsCh) + + // Invoke method under test + if err := rs.SaveReportsStream(context.Background(), m.Path(dir), reportsCh); err != nil { + t.Fatalf("SaveReportsStream returned error: %v", err) + } + + // Verify both files were written + expectedHash1 := rs.computeReportHash(report1.Result) + expectedHash2 := rs.computeReportHash(report2.Result) + + expectedPath1 := filepath.Join(dir, expectedHash1+".yaml") + if _, err := os.Stat(expectedPath1); os.IsNotExist(err) { + t.Fatalf("expected report file to be written at %s", expectedPath1) + } + + expectedPath2 := filepath.Join(dir, expectedHash2+".yaml") + if _, err := os.Stat(expectedPath2); os.IsNotExist(err) { + t.Fatalf("expected report file to be written at %s", expectedPath2) + } +} + +func TestLocalReportStore_SaveReportsStream_ClosedChannelEndsWriting(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + rs := &LocalReportStore{} + + report := m.Report{ + Source: m.Source{ + Origin: &m.File{FullPath: m.Path("/abs/path/file.go"), Hash: "abc123"}, + }, + Result: m.Result{ + m.MutationBoolean: { + {MutationID: "m1", Status: m.Killed, Err: nil}, + }, + }, + } + + // Create channel, send one report, then close + reportsCh := make(chan m.Report, 1) + reportsCh <- report + close(reportsCh) + + // Invoke method under test + if err := rs.SaveReportsStream(context.Background(), m.Path(dir), reportsCh); err != nil { + t.Fatalf("SaveReportsStream returned error: %v", err) + } + + // Verify file was written + expectedHash := rs.computeReportHash(report.Result) + expectedPath := filepath.Join(dir, expectedHash+".yaml") + if _, err := os.Stat(expectedPath); os.IsNotExist(err) { + t.Fatalf("expected report file to be written at %s", expectedPath) + } +} + +func TestLocalReportStore_SaveReportsStream_EmptyChannel(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + rs := &LocalReportStore{} + + reportsCh := make(chan m.Report) + close(reportsCh) + + // Invoke method under test with empty channel + if err := rs.SaveReportsStream(context.Background(), m.Path(dir), reportsCh); err != nil { + t.Fatalf("SaveReportsStream returned error: %v", err) + } + + // Verify no files were written + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir returned error: %v", err) + } + if len(entries) != 0 { + t.Fatalf("expected no files to be written, found %d", len(entries)) + } +} + +func TestLocalReportStore_SaveReportsStream_ContextCancellation(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + rs := &LocalReportStore{} + + // Create a channel that will never close (so we can test cancellation) + reportsCh := make(chan m.Report) + + // Create a cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // Invoke method under test + err := rs.SaveReportsStream(ctx, m.Path(dir), reportsCh) + if err != context.Canceled { + t.Fatalf("expected context.Canceled error, got %v", err) + } +} + func TestLocalReportStore_CheckUpdates_NoReportsDir_ReturnsAllSources(t *testing.T) { t.Parallel() diff --git a/internal/adapter/source_fs_adapter.go b/internal/adapter/source_fs_adapter.go index 1470010..d77d137 100644 --- a/internal/adapter/source_fs_adapter.go +++ b/internal/adapter/source_fs_adapter.go @@ -10,9 +10,11 @@ import ( "go/parser" "go/token" "io" + "log/slog" "os" "path/filepath" "regexp" + "sort" "strings" m "gooze.dev/pkg/gooze/internal/model" @@ -24,8 +26,10 @@ import ( // //nolint:interfacebloat // A richer interface keeps workflow logic decoupled from os/fs. type SourceFSAdapter interface { + // Deprecated: Use GetStream for better performance and lower memory usage on large codebases. Get(ctx context.Context, roots []m.Path, ignore ...string) ([]m.Source, error) + GetStream(ctx context.Context, roots []m.Path, ignore ...string) (<-chan m.Source, error) // Walk traverses the provided root path. When recursive is false the // implementation should limit itself to the root directory (no sub-dirs). Walk(ctx context.Context, root m.Path, recursive bool, fn FilepathWalkFunc) error @@ -108,6 +112,126 @@ func (a *LocalSourceFSAdapter) Get(ctx context.Context, roots []m.Path, ignore . return sources, nil } +// GetStream collects Go source files for the provided roots and returns them via a channel. +// The channel is closed when all sources have been sent or an error occurs. +// This method is useful for processing large codebases without loading everything into memory. +func (a *LocalSourceFSAdapter) GetStream(ctx context.Context, roots []m.Path, ignore ...string) (<-chan m.Source, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + ch := make(chan m.Source) + + if len(roots) == 0 { + slog.Debug("GetStream called with empty roots") + close(ch) + return ch, nil + } + + slog.Debug("Starting GetStream", "roots", len(roots), "ignore_patterns", len(ignore)) + + ignoreRegexps, err := compileIgnoreRegexps(ignore) + if err != nil { + slog.Error("Failed to compile ignore regexps", "error", err) + return nil, err + } + + // Validate roots before starting goroutine + for _, root := range roots { + rootPath, _, err := normalizeRootPath(string(root)) + if err != nil { + slog.Error("Failed to normalize root path", "root", root, "error", err) + return nil, err + } + + _, err = a.FileInfo(ctx, m.Path(rootPath)) + if err != nil { + slog.Error("Root path validation failed", "root", rootPath, "error", err) + return nil, fmt.Errorf("root path error: %w", err) + } + } + + go func() { + defer close(ch) + defer slog.Debug("GetStream goroutine completed") + + seen := make(map[string]struct{}) + sources := make([]m.Source, 0) + + // Collect all sources first + for _, root := range roots { + if err := ctx.Err(); err != nil { + slog.Debug("GetStream context cancelled", "error", err) + return + } + + if err := a.collectSourcesForStream(ctx, root, ignoreRegexps, seen, &sources); err != nil { + slog.Error("Failed to collect sources from root", "root", root, "error", err) + return + } + } + + // Sort sources by hash for deterministic ordering + sort.Slice(sources, func(i, j int) bool { + if sources[i].Origin == nil || sources[j].Origin == nil { + return false + } + return sources[i].Origin.Hash < sources[j].Origin.Hash + }) + + slog.Debug("Sorted sources for streaming", "total_sources", len(sources)) + + // Stream sources in sorted order + for _, source := range sources { + select { + case <-ctx.Done(): + slog.Debug("GetStream cancelled while sending", "error", ctx.Err()) + return + case ch <- source: + } + } + + slog.Debug("Successfully streamed all sources", "total_sources", len(sources)) + }() + + return ch, nil +} + +func (a *LocalSourceFSAdapter) collectSourcesForStream(ctx context.Context, root m.Path, ignoreRegexps []*regexp.Regexp, seen map[string]struct{}, sources *[]m.Source) error { + rootPath, recursive, err := normalizeRootPath(string(root)) + if err != nil { + return err + } + + slog.Debug("Collecting sources from root for streaming", "root", rootPath, "recursive", recursive) + + info, err := a.FileInfo(ctx, m.Path(rootPath)) + if err != nil { + return fmt.Errorf("root path error: %w", err) + } + + if !info.IsDir() { + source, ok, err := a.processFilePath(ctx, rootPath, ignoreRegexps) + if err != nil { + if isInvalidSourceErr(err) { + return nil + } + + return err + } + + if ok { + addSourceIfNew(sources, seen, source) + slog.Debug("Collected single file source", "path", rootPath) + } + + return nil + } + + slog.Debug("Collecting sources from directory", "path", rootPath, "recursive", recursive) + return a.collectSourcesFromDir(ctx, rootPath, recursive, ignoreRegexps, seen, sources) +} + func (a *LocalSourceFSAdapter) collectSourcesFromRoot(ctx context.Context, root m.Path, ignoreRegexps []*regexp.Regexp, seen map[string]struct{}, sources *[]m.Source) error { rootPath, recursive, err := normalizeRootPath(string(root)) if err != nil { diff --git a/internal/adapter/source_fs_adapter_test.go b/internal/adapter/source_fs_adapter_test.go index 49ce6f6..055dbda 100644 --- a/internal/adapter/source_fs_adapter_test.go +++ b/internal/adapter/source_fs_adapter_test.go @@ -453,6 +453,226 @@ func TestLocalSourceFSAdapter_Get(t *testing.T) { }) } +func TestLocalSourceFSAdapter_GetStream(t *testing.T) { + adapter := NewLocalSourceFSAdapter() + + t.Run("streams sources from current directory", func(t *testing.T) { + root := t.TempDir() + mainPath := filepath.Join(root, "main.go") + copyExampleFile(t, filepath.Join(examplePath(t, "basic"), "main.go"), mainPath) + mainContent := readFileBytes(t, mainPath) + + ch, err := adapter.GetStream(context.Background(), []m.Path{m.Path(root)}) + require.NoError(t, err) + require.NotNil(t, ch) + + sources := collectFromChannel(t, ch) + require.Len(t, sources, 1) + + assertSourceV2(t, &sources[0], mainPath, "", mainContent, "main", "", "", nil) + }) + + t.Run("streams multiple sources", func(t *testing.T) { + root := t.TempDir() + file1Path := filepath.Join(root, "file1.go") + file2Path := filepath.Join(root, "file2.go") + writeTestFile(t, file1Path, "package main\nfunc Foo() {}\n") + writeTestFile(t, file2Path, "package main\nfunc Bar() {}\n") + + ch, err := adapter.GetStream(context.Background(), []m.Path{m.Path(root)}) + require.NoError(t, err) + + sources := collectFromChannel(t, ch) + require.Len(t, sources, 2) + + // Verify both files are present + source1 := findSourceV2ByOrigin(sources, file1Path) + source2 := findSourceV2ByOrigin(sources, file2Path) + assert.NotNil(t, source1) + assert.NotNil(t, source2) + }) + + t.Run("handles empty roots", func(t *testing.T) { + ch, err := adapter.GetStream(context.Background(), []m.Path{}) + require.NoError(t, err) + + sources := collectFromChannel(t, ch) + assert.Len(t, sources, 0) + }) + + t.Run("streams recursive paths", func(t *testing.T) { + root := t.TempDir() + mainPath := filepath.Join(root, "main.go") + copyExampleFile(t, filepath.Join(examplePath(t, "basic"), "main.go"), mainPath) + + nestedDir := filepath.Join(root, "nested") + mustMkdir(t, nestedDir) + nestedPath := filepath.Join(nestedDir, "child.go") + copyExampleFile(t, filepath.Join(examplePath(t, "nested", "sub"), "child.go"), nestedPath) + + ch, err := adapter.GetStream(context.Background(), []m.Path{m.Path(root + "/...")}) + require.NoError(t, err) + + sources := collectFromChannel(t, ch) + require.Len(t, sources, 2) + + mainSource := findSourceV2ByOrigin(sources, mainPath) + nestedSource := findSourceV2ByOrigin(sources, nestedPath) + assert.NotNil(t, mainSource) + assert.NotNil(t, nestedSource) + }) + + t.Run("applies ignore patterns", func(t *testing.T) { + root := t.TempDir() + ignoredPath := filepath.Join(root, "ignored_file.go") + keptPath := filepath.Join(root, "kept_file.go") + writeTestFile(t, ignoredPath, "package main\n") + writeTestFile(t, keptPath, "package main\n") + + ch, err := adapter.GetStream(context.Background(), []m.Path{m.Path(root)}, "^ignored_") + require.NoError(t, err) + + sources := collectFromChannel(t, ch) + require.Len(t, sources, 1) + assert.Equal(t, m.Path(keptPath), sources[0].Origin.FullPath) + }) + + t.Run("handles context cancellation", func(t *testing.T) { + root := t.TempDir() + for i := 0; i < 10; i++ { + writeTestFile(t, filepath.Join(root, fmt.Sprintf("file%d.go", i)), "package main\n") + } + + ctx, cancel := context.WithCancel(context.Background()) + ch, err := adapter.GetStream(ctx, []m.Path{m.Path(root)}) + require.NoError(t, err) + + // Cancel immediately + cancel() + + sources := collectFromChannel(t, ch) + // Should get partial results or none, depending on timing + assert.True(t, len(sources) <= 10) + }) + + t.Run("closes channel when done", func(t *testing.T) { + root := t.TempDir() + mainPath := filepath.Join(root, "main.go") + copyExampleFile(t, filepath.Join(examplePath(t, "basic"), "main.go"), mainPath) + + ch, err := adapter.GetStream(context.Background(), []m.Path{m.Path(root)}) + require.NoError(t, err) + + // Drain channel + for range ch { + } + + // Channel should be closed, so receiving should return immediately with zero value + source, ok := <-ch + assert.False(t, ok, "channel should be closed") + assert.Equal(t, m.Source{}, source) + }) + + t.Run("returns error for invalid root", func(t *testing.T) { + _, err := adapter.GetStream(context.Background(), []m.Path{"/path/does/not/exist"}) + assert.Error(t, err) + }) + + t.Run("de-duplicates sources across multiple roots", func(t *testing.T) { + root := t.TempDir() + mainPath := filepath.Join(root, "main.go") + copyExampleFile(t, filepath.Join(examplePath(t, "basic"), "main.go"), mainPath) + + ch, err := adapter.GetStream(context.Background(), []m.Path{m.Path(root), m.Path(root)}) + require.NoError(t, err) + + sources := collectFromChannel(t, ch) + require.Len(t, sources, 1) + }) + + t.Run("skips broken source files", func(t *testing.T) { + root := t.TempDir() + brokenPath := filepath.Join(root, "broken.go") + validPath := filepath.Join(root, "valid.go") + writeTestFile(t, brokenPath, "package main\nfunc {\n") + writeTestFile(t, validPath, "package main\nfunc Valid() {}\n") + + ch, err := adapter.GetStream(context.Background(), []m.Path{m.Path(root)}) + require.NoError(t, err) + + sources := collectFromChannel(t, ch) + require.Len(t, sources, 1) + assert.Equal(t, m.Path(validPath), sources[0].Origin.FullPath) + }) + + t.Run("skips test files as origins", func(t *testing.T) { + root := t.TempDir() + testPath := filepath.Join(root, "main_test.go") + copyExampleFile(t, filepath.Join(examplePath(t, "basic"), "main_test.go"), testPath) + + ch, err := adapter.GetStream(context.Background(), []m.Path{m.Path(root)}) + require.NoError(t, err) + + sources := collectFromChannel(t, ch) + assert.Len(t, sources, 0) + }) + + t.Run("streams single file path", func(t *testing.T) { + root := t.TempDir() + mainPath := filepath.Join(root, "main.go") + testPath := filepath.Join(root, "main_test.go") + copyExampleFile(t, filepath.Join(examplePath(t, "basic"), "main.go"), mainPath) + copyExampleFile(t, filepath.Join(examplePath(t, "basic"), "main_test.go"), testPath) + mainContent := readFileBytes(t, mainPath) + testContent := readFileBytes(t, testPath) + + ch, err := adapter.GetStream(context.Background(), []m.Path{m.Path(mainPath)}) + require.NoError(t, err) + + sources := collectFromChannel(t, ch) + require.Len(t, sources, 1) + + assertSourceV2(t, &sources[0], mainPath, "", mainContent, "main", testPath, "", testContent) + }) + + t.Run("deterministic ordering across multiple runs", func(t *testing.T) { + root := t.TempDir() + file1Path := filepath.Join(root, "aaa.go") + file2Path := filepath.Join(root, "zzz.go") + file3Path := filepath.Join(root, "mmm.go") + writeTestFile(t, file1Path, "package main\nfunc Aaa() {}\n") + writeTestFile(t, file2Path, "package main\nfunc Zzz() {}\n") + writeTestFile(t, file3Path, "package main\nfunc Mmm() {}\n") + + // Run twice and verify same order + ch1, err := adapter.GetStream(context.Background(), []m.Path{m.Path(root)}) + require.NoError(t, err) + sources1 := collectFromChannel(t, ch1) + + ch2, err := adapter.GetStream(context.Background(), []m.Path{m.Path(root)}) + require.NoError(t, err) + sources2 := collectFromChannel(t, ch2) + + require.Len(t, sources1, 3) + require.Len(t, sources2, 3) + + // Verify same order based on hash sorting + for i := range sources1 { + assert.Equal(t, sources1[i].Origin.FullPath, sources2[i].Origin.FullPath, "Order should be deterministic") + assert.Equal(t, sources1[i].Origin.Hash, sources2[i].Origin.Hash, "Hashes should match") + } + }) +} + +func collectFromChannel(t *testing.T, ch <-chan m.Source) []m.Source { + t.Helper() + sources := make([]m.Source, 0) + for source := range ch { + sources = append(sources, source) + } + return sources +} + func writeTestFile(t *testing.T, path, contents string) { t.Helper() writeTestBytes(t, path, []byte(contents)) diff --git a/internal/domain/mocks/mock_Mutagen.go b/internal/domain/mocks/mock_Mutagen.go index ba7ffbd..0f5fd45 100644 --- a/internal/domain/mocks/mock_Mutagen.go +++ b/internal/domain/mocks/mock_Mutagen.go @@ -97,6 +97,83 @@ func (_c *MockMutagen_GenerateMutation_Call) RunAndReturn(run func(context.Conte return _c } +// StreamMutations provides a mock function with given fields: ctx, source, threads, mutationTypes +func (_m *MockMutagen) StreamMutations(ctx context.Context, source <-chan model.Source, threads int, mutationTypes ...model.MutationType) (<-chan model.Mutation, <-chan error) { + _va := make([]interface{}, len(mutationTypes)) + for _i := range mutationTypes { + _va[_i] = mutationTypes[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, source, threads) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for StreamMutations") + } + + var r0 <-chan model.Mutation + var r1 <-chan error + if rf, ok := ret.Get(0).(func(context.Context, <-chan model.Source, int, ...model.MutationType) (<-chan model.Mutation, <-chan error)); ok { + return rf(ctx, source, threads, mutationTypes...) + } + if rf, ok := ret.Get(0).(func(context.Context, <-chan model.Source, int, ...model.MutationType) <-chan model.Mutation); ok { + r0 = rf(ctx, source, threads, mutationTypes...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(<-chan model.Mutation) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, <-chan model.Source, int, ...model.MutationType) <-chan error); ok { + r1 = rf(ctx, source, threads, mutationTypes...) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(<-chan error) + } + } + + return r0, r1 +} + +// MockMutagen_StreamMutations_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StreamMutations' +type MockMutagen_StreamMutations_Call struct { + *mock.Call +} + +// StreamMutations is a helper method to define mock.On call +// - ctx context.Context +// - source <-chan model.Source +// - threads int +// - mutationTypes ...model.MutationType +func (_e *MockMutagen_Expecter) StreamMutations(ctx interface{}, source interface{}, threads interface{}, mutationTypes ...interface{}) *MockMutagen_StreamMutations_Call { + return &MockMutagen_StreamMutations_Call{Call: _e.mock.On("StreamMutations", + append([]interface{}{ctx, source, threads}, mutationTypes...)...)} +} + +func (_c *MockMutagen_StreamMutations_Call) Run(run func(ctx context.Context, source <-chan model.Source, threads int, mutationTypes ...model.MutationType)) *MockMutagen_StreamMutations_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]model.MutationType, len(args)-3) + for i, a := range args[3:] { + if a != nil { + variadicArgs[i] = a.(model.MutationType) + } + } + run(args[0].(context.Context), args[1].(<-chan model.Source), args[2].(int), variadicArgs...) + }) + return _c +} + +func (_c *MockMutagen_StreamMutations_Call) Return(_a0 <-chan model.Mutation, _a1 <-chan error) *MockMutagen_StreamMutations_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockMutagen_StreamMutations_Call) RunAndReturn(run func(context.Context, <-chan model.Source, int, ...model.MutationType) (<-chan model.Mutation, <-chan error)) *MockMutagen_StreamMutations_Call { + _c.Call.Return(run) + return _c +} + // NewMockMutagen creates a new instance of MockMutagen. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockMutagen(t interface { diff --git a/internal/domain/mocks/mock_MutationStreamer.go b/internal/domain/mocks/mock_MutationStreamer.go new file mode 100644 index 0000000..f0a6bf8 --- /dev/null +++ b/internal/domain/mocks/mock_MutationStreamer.go @@ -0,0 +1,141 @@ +// Code generated by mockery. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + model "gooze.dev/pkg/gooze/internal/model" +) + +// MockMutationStreamer is an autogenerated mock type for the MutationStreamer type +type MockMutationStreamer struct { + mock.Mock +} + +type MockMutationStreamer_Expecter struct { + mock *mock.Mock +} + +func (_m *MockMutationStreamer) EXPECT() *MockMutationStreamer_Expecter { + return &MockMutationStreamer_Expecter{mock: &_m.Mock} +} + +// Get provides a mock function with given fields: ctx, paths, exclude, threads +func (_m *MockMutationStreamer) Get(ctx context.Context, paths []model.Path, exclude []string, threads int) <-chan model.Mutation { + ret := _m.Called(ctx, paths, exclude, threads) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 <-chan model.Mutation + if rf, ok := ret.Get(0).(func(context.Context, []model.Path, []string, int) <-chan model.Mutation); ok { + r0 = rf(ctx, paths, exclude, threads) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(<-chan model.Mutation) + } + } + + return r0 +} + +// MockMutationStreamer_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' +type MockMutationStreamer_Get_Call struct { + *mock.Call +} + +// Get is a helper method to define mock.On call +// - ctx context.Context +// - paths []model.Path +// - exclude []string +// - threads int +func (_e *MockMutationStreamer_Expecter) Get(ctx interface{}, paths interface{}, exclude interface{}, threads interface{}) *MockMutationStreamer_Get_Call { + return &MockMutationStreamer_Get_Call{Call: _e.mock.On("Get", ctx, paths, exclude, threads)} +} + +func (_c *MockMutationStreamer_Get_Call) Run(run func(ctx context.Context, paths []model.Path, exclude []string, threads int)) *MockMutationStreamer_Get_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].([]model.Path), args[2].([]string), args[3].(int)) + }) + return _c +} + +func (_c *MockMutationStreamer_Get_Call) Return(_a0 <-chan model.Mutation) *MockMutationStreamer_Get_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockMutationStreamer_Get_Call) RunAndReturn(run func(context.Context, []model.Path, []string, int) <-chan model.Mutation) *MockMutationStreamer_Get_Call { + _c.Call.Return(run) + return _c +} + +// ShardMutations provides a mock function with given fields: ctx, allMutations, threads, shardIndex, totalShardCount +func (_m *MockMutationStreamer) ShardMutations(ctx context.Context, allMutations <-chan model.Mutation, threads int, shardIndex int, totalShardCount int) <-chan model.Mutation { + ret := _m.Called(ctx, allMutations, threads, shardIndex, totalShardCount) + + if len(ret) == 0 { + panic("no return value specified for ShardMutations") + } + + var r0 <-chan model.Mutation + if rf, ok := ret.Get(0).(func(context.Context, <-chan model.Mutation, int, int, int) <-chan model.Mutation); ok { + r0 = rf(ctx, allMutations, threads, shardIndex, totalShardCount) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(<-chan model.Mutation) + } + } + + return r0 +} + +// MockMutationStreamer_ShardMutations_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ShardMutations' +type MockMutationStreamer_ShardMutations_Call struct { + *mock.Call +} + +// ShardMutations is a helper method to define mock.On call +// - ctx context.Context +// - allMutations <-chan model.Mutation +// - threads int +// - shardIndex int +// - totalShardCount int +func (_e *MockMutationStreamer_Expecter) ShardMutations(ctx interface{}, allMutations interface{}, threads interface{}, shardIndex interface{}, totalShardCount interface{}) *MockMutationStreamer_ShardMutations_Call { + return &MockMutationStreamer_ShardMutations_Call{Call: _e.mock.On("ShardMutations", ctx, allMutations, threads, shardIndex, totalShardCount)} +} + +func (_c *MockMutationStreamer_ShardMutations_Call) Run(run func(ctx context.Context, allMutations <-chan model.Mutation, threads int, shardIndex int, totalShardCount int)) *MockMutationStreamer_ShardMutations_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(<-chan model.Mutation), args[2].(int), args[3].(int), args[4].(int)) + }) + return _c +} + +func (_c *MockMutationStreamer_ShardMutations_Call) Return(_a0 <-chan model.Mutation) *MockMutationStreamer_ShardMutations_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockMutationStreamer_ShardMutations_Call) RunAndReturn(run func(context.Context, <-chan model.Mutation, int, int, int) <-chan model.Mutation) *MockMutationStreamer_ShardMutations_Call { + _c.Call.Return(run) + return _c +} + +// NewMockMutationStreamer creates a new instance of MockMutationStreamer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockMutationStreamer(t interface { + mock.TestingT + Cleanup(func()) +}) *MockMutationStreamer { + mock := &MockMutationStreamer{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/domain/mocks/mock_ReportManager.go b/internal/domain/mocks/mock_ReportManager.go new file mode 100644 index 0000000..dc99c3e --- /dev/null +++ b/internal/domain/mocks/mock_ReportManager.go @@ -0,0 +1,242 @@ +// Code generated by mockery. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + model "gooze.dev/pkg/gooze/internal/model" + + pkg "gooze.dev/pkg/gooze/pkg" +) + +// MockReportManager is an autogenerated mock type for the ReportManager type +type MockReportManager struct { + mock.Mock +} + +type MockReportManager_Expecter struct { + mock *mock.Mock +} + +func (_m *MockReportManager) EXPECT() *MockReportManager_Expecter { + return &MockReportManager_Expecter{mock: &_m.Mock} +} + +// Load provides a mock function with given fields: ctx, path +func (_m *MockReportManager) Load(ctx context.Context, path model.Path) ([]model.Report, error) { + ret := _m.Called(ctx, path) + + if len(ret) == 0 { + panic("no return value specified for Load") + } + + var r0 []model.Report + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, model.Path) ([]model.Report, error)); ok { + return rf(ctx, path) + } + if rf, ok := ret.Get(0).(func(context.Context, model.Path) []model.Report); ok { + r0 = rf(ctx, path) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]model.Report) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, model.Path) error); ok { + r1 = rf(ctx, path) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockReportManager_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load' +type MockReportManager_Load_Call struct { + *mock.Call +} + +// Load is a helper method to define mock.On call +// - ctx context.Context +// - path model.Path +func (_e *MockReportManager_Expecter) Load(ctx interface{}, path interface{}) *MockReportManager_Load_Call { + return &MockReportManager_Load_Call{Call: _e.mock.On("Load", ctx, path)} +} + +func (_c *MockReportManager_Load_Call) Run(run func(ctx context.Context, path model.Path)) *MockReportManager_Load_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(model.Path)) + }) + return _c +} + +func (_c *MockReportManager_Load_Call) Return(_a0 []model.Report, _a1 error) *MockReportManager_Load_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockReportManager_Load_Call) RunAndReturn(run func(context.Context, model.Path) ([]model.Report, error)) *MockReportManager_Load_Call { + _c.Call.Return(run) + return _c +} + +// Merge provides a mock function with given fields: ctx, basePath +func (_m *MockReportManager) Merge(ctx context.Context, basePath model.Path) error { + ret := _m.Called(ctx, basePath) + + if len(ret) == 0 { + panic("no return value specified for Merge") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, model.Path) error); ok { + r0 = rf(ctx, basePath) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockReportManager_Merge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Merge' +type MockReportManager_Merge_Call struct { + *mock.Call +} + +// Merge is a helper method to define mock.On call +// - ctx context.Context +// - basePath model.Path +func (_e *MockReportManager_Expecter) Merge(ctx interface{}, basePath interface{}) *MockReportManager_Merge_Call { + return &MockReportManager_Merge_Call{Call: _e.mock.On("Merge", ctx, basePath)} +} + +func (_c *MockReportManager_Merge_Call) Run(run func(ctx context.Context, basePath model.Path)) *MockReportManager_Merge_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(model.Path)) + }) + return _c +} + +func (_c *MockReportManager_Merge_Call) Return(_a0 error) *MockReportManager_Merge_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockReportManager_Merge_Call) RunAndReturn(run func(context.Context, model.Path) error) *MockReportManager_Merge_Call { + _c.Call.Return(run) + return _c +} + +// Save provides a mock function with given fields: ctx, reportsDir, reports +func (_m *MockReportManager) Save(ctx context.Context, reportsDir model.Path, reports pkg.FileSpill[model.Report]) error { + ret := _m.Called(ctx, reportsDir, reports) + + if len(ret) == 0 { + panic("no return value specified for Save") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, model.Path, pkg.FileSpill[model.Report]) error); ok { + r0 = rf(ctx, reportsDir, reports) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockReportManager_Save_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Save' +type MockReportManager_Save_Call struct { + *mock.Call +} + +// Save is a helper method to define mock.On call +// - ctx context.Context +// - reportsDir model.Path +// - reports pkg.FileSpill[model.Report] +func (_e *MockReportManager_Expecter) Save(ctx interface{}, reportsDir interface{}, reports interface{}) *MockReportManager_Save_Call { + return &MockReportManager_Save_Call{Call: _e.mock.On("Save", ctx, reportsDir, reports)} +} + +func (_c *MockReportManager_Save_Call) Run(run func(ctx context.Context, reportsDir model.Path, reports pkg.FileSpill[model.Report])) *MockReportManager_Save_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(model.Path), args[2].(pkg.FileSpill[model.Report])) + }) + return _c +} + +func (_c *MockReportManager_Save_Call) Return(_a0 error) *MockReportManager_Save_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockReportManager_Save_Call) RunAndReturn(run func(context.Context, model.Path, pkg.FileSpill[model.Report]) error) *MockReportManager_Save_Call { + _c.Call.Return(run) + return _c +} + +// SaveStream provides a mock function with given fields: ctx, reportsDir, reports +func (_m *MockReportManager) SaveStream(ctx context.Context, reportsDir model.Path, reports <-chan model.Report) error { + ret := _m.Called(ctx, reportsDir, reports) + + if len(ret) == 0 { + panic("no return value specified for SaveStream") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, model.Path, <-chan model.Report) error); ok { + r0 = rf(ctx, reportsDir, reports) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockReportManager_SaveStream_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveStream' +type MockReportManager_SaveStream_Call struct { + *mock.Call +} + +// SaveStream is a helper method to define mock.On call +// - ctx context.Context +// - reportsDir model.Path +// - reports <-chan model.Report +func (_e *MockReportManager_Expecter) SaveStream(ctx interface{}, reportsDir interface{}, reports interface{}) *MockReportManager_SaveStream_Call { + return &MockReportManager_SaveStream_Call{Call: _e.mock.On("SaveStream", ctx, reportsDir, reports)} +} + +func (_c *MockReportManager_SaveStream_Call) Run(run func(ctx context.Context, reportsDir model.Path, reports <-chan model.Report)) *MockReportManager_SaveStream_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(model.Path), args[2].(<-chan model.Report)) + }) + return _c +} + +func (_c *MockReportManager_SaveStream_Call) Return(_a0 error) *MockReportManager_SaveStream_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockReportManager_SaveStream_Call) RunAndReturn(run func(context.Context, model.Path, <-chan model.Report) error) *MockReportManager_SaveStream_Call { + _c.Call.Return(run) + return _c +} + +// NewMockReportManager creates a new instance of MockReportManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockReportManager(t interface { + mock.TestingT + Cleanup(func()) +}) *MockReportManager { + mock := &MockReportManager{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/domain/mocks/mock_Workflow.go b/internal/domain/mocks/mock_Workflow.go index 7649682..c493a7a 100644 --- a/internal/domain/mocks/mock_Workflow.go +++ b/internal/domain/mocks/mock_Workflow.go @@ -163,6 +163,53 @@ func (_c *MockWorkflow_Test_Call) RunAndReturn(run func(context.Context, domain. return _c } +// TestStream provides a mock function with given fields: ctx, args +func (_m *MockWorkflow) TestStream(ctx context.Context, args domain.TestArgs) error { + ret := _m.Called(ctx, args) + + if len(ret) == 0 { + panic("no return value specified for TestStream") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, domain.TestArgs) error); ok { + r0 = rf(ctx, args) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockWorkflow_TestStream_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TestStream' +type MockWorkflow_TestStream_Call struct { + *mock.Call +} + +// TestStream is a helper method to define mock.On call +// - ctx context.Context +// - args domain.TestArgs +func (_e *MockWorkflow_Expecter) TestStream(ctx interface{}, args interface{}) *MockWorkflow_TestStream_Call { + return &MockWorkflow_TestStream_Call{Call: _e.mock.On("TestStream", ctx, args)} +} + +func (_c *MockWorkflow_TestStream_Call) Run(run func(ctx context.Context, args domain.TestArgs)) *MockWorkflow_TestStream_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(domain.TestArgs)) + }) + return _c +} + +func (_c *MockWorkflow_TestStream_Call) Return(_a0 error) *MockWorkflow_TestStream_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockWorkflow_TestStream_Call) RunAndReturn(run func(context.Context, domain.TestArgs) error) *MockWorkflow_TestStream_Call { + _c.Call.Return(run) + return _c +} + // View provides a mock function with given fields: ctx, args func (_m *MockWorkflow) View(ctx context.Context, args domain.ViewArgs) error { ret := _m.Called(ctx, args) diff --git a/internal/domain/mutagen.go b/internal/domain/mutagen.go index 2bb91d1..00ad94b 100644 --- a/internal/domain/mutagen.go +++ b/internal/domain/mutagen.go @@ -15,6 +15,7 @@ import ( // Mutagen defines the interface for mutation generation. type Mutagen interface { GenerateMutation(ctx context.Context, source m.Source, mutationTypes ...m.MutationType) ([]m.Mutation, error) + StreamMutations(ctx context.Context, source <-chan m.Source, threads int, mutationTypes ...m.MutationType) (<-chan m.Mutation, <-chan error) } // mutagen handles pure mutation generation logic. @@ -167,3 +168,106 @@ func generateMutationsForNode( return gen(n, fset, content, source) } + +// StreamMutations streams mutations for sources received from a channel. +// It returns a channel of mutations and a channel for errors. +func (mg *mutagen) StreamMutations(ctx context.Context, sources <-chan m.Source, threads int, mutationTypes ...m.MutationType) (<-chan m.Mutation, <-chan error) { + bufferSize := threads + if bufferSize <= 0 { + bufferSize = 1 + } + + mutationCh := make(chan m.Mutation, bufferSize) + errCh := make(chan error, 1) + + go func() { + defer close(mutationCh) + defer close(errCh) + + // Stage 1: Validate configuration + resolvedTypes, err := mg.validateConfig(mutationTypes) + if err != nil { + errCh <- err + return + } + + // Stage 2: Process sources and stream mutations + mg.processSourcesStream(ctx, sources, resolvedTypes, mutationCh, errCh) + }() + + return mutationCh, errCh +} + +// validateConfig validates adapters and resolves mutation types. +func (mg *mutagen) validateConfig(mutationTypes []m.MutationType) ([]m.MutationType, error) { + resolvedTypes, err := resolveMutationTypes(mutationTypes) + if err != nil { + return nil, err + } + + if err := validateAdapters(mg); err != nil { + return nil, err + } + + return resolvedTypes, nil +} + +// processSourcesStream processes sources from channel and sends mutations to output channel. +func (mg *mutagen) processSourcesStream(ctx context.Context, sources <-chan m.Source, mutationTypes []m.MutationType, mutationCh chan<- m.Mutation, errCh chan<- error) { + for source := range sources { + if ctx.Err() != nil { + errCh <- ctx.Err() + return + } + + if !mg.processSourceStream(ctx, source, mutationTypes, mutationCh, errCh) { + return + } + } +} + +// processSourceStream processes a single source and sends its mutations to the channel. +// Returns false if processing should stop. +func (mg *mutagen) processSourceStream(ctx context.Context, source m.Source, mutationTypes []m.MutationType, mutationCh chan<- m.Mutation, errCh chan<- error) bool { + if err := validateSource(source); err != nil { + errCh <- err + return false + } + + content, fset, file, err := mg.loadSourceAST(ctx, source) + if err != nil { + errCh <- err + return false + } + + return mg.streamMutationsForSource(ctx, source, content, fset, file, mutationTypes, mutationCh, errCh) +} + +// streamMutationsForSource generates and streams mutations for a parsed source. +// Returns false if context was cancelled. +func (mg *mutagen) streamMutationsForSource(ctx context.Context, source m.Source, content []byte, fset *token.FileSet, file *ast.File, mutationTypes []m.MutationType, mutationCh chan<- m.Mutation, errCh chan<- error) bool { + for _, mutationType := range mutationTypes { + mutations := collectMutations(mutationType, file, fset, content, source) + + if !mg.sendMutations(ctx, mutations, mutationCh, errCh) { + return false + } + } + + return true +} + +// sendMutations sends mutations to the channel, respecting context cancellation. +// Returns false if context was cancelled. +func (mg *mutagen) sendMutations(ctx context.Context, mutations []m.Mutation, mutationCh chan<- m.Mutation, errCh chan<- error) bool { + for _, mutation := range mutations { + select { + case <-ctx.Done(): + errCh <- ctx.Err() + return false + case mutationCh <- mutation: + } + } + + return true +} diff --git a/internal/domain/mutagen_test.go b/internal/domain/mutagen_test.go index ced61ca..d433ac5 100644 --- a/internal/domain/mutagen_test.go +++ b/internal/domain/mutagen_test.go @@ -218,3 +218,250 @@ func readFileBytes(t *testing.T, path m.Path) []byte { func newTestMutagen() Mutagen { return NewMutagen(adapter.NewLocalGoFileAdapter(), adapter.NewLocalSourceFSAdapter()) } + +func TestMutagen_StreamMutations_Success(t *testing.T) { + mg := newTestMutagen() + + sources := make(chan m.Source, 1) + source := makeSourceV2(t, filepath.Join("..", "..", "examples", "basic", "main.go")) + sources <- source + close(sources) + + mutationCh, errCh := mg.StreamMutations(context.Background(), sources, 4, m.MutationArithmetic) + + var mutations []m.Mutation + for mutation := range mutationCh { + mutations = append(mutations, mutation) + } + + // Check for errors + select { + case err := <-errCh: + if err != nil { + t.Fatalf("StreamMutations failed: %v", err) + } + default: + } + + if len(mutations) != 4 { + t.Fatalf("expected 4 arithmetic mutations, got %d", len(mutations)) + } + + for _, mutation := range mutations { + if mutation.Type != m.MutationArithmetic { + t.Fatalf("expected arithmetic mutation, got %v", mutation.Type) + } + } +} + +func TestMutagen_StreamMutations_MultipleSources(t *testing.T) { + mg := newTestMutagen() + + sources := make(chan m.Source, 2) + source1 := makeSourceV2(t, filepath.Join("..", "..", "examples", "basic", "main.go")) + source2 := makeSourceV2(t, filepath.Join("..", "..", "examples", "boolean", "main.go")) + sources <- source1 + sources <- source2 + close(sources) + + mutationCh, errCh := mg.StreamMutations(context.Background(), sources, 4, m.MutationArithmetic, m.MutationBoolean) + + var mutations []m.Mutation + for mutation := range mutationCh { + mutations = append(mutations, mutation) + } + + // Check for errors + select { + case err := <-errCh: + if err != nil { + t.Fatalf("StreamMutations failed: %v", err) + } + default: + } + + if len(mutations) == 0 { + t.Fatal("expected mutations from multiple sources") + } +} + +func TestMutagen_StreamMutations_ContextCancelled(t *testing.T) { + mg := newTestMutagen() + + ctx, cancel := context.WithCancel(context.Background()) + + sources := make(chan m.Source, 1) + source := makeSourceV2(t, filepath.Join("..", "..", "examples", "basic", "main.go")) + sources <- source + close(sources) + + // Cancel context immediately + cancel() + + mutationCh, errCh := mg.StreamMutations(ctx, sources, 4, m.MutationArithmetic) + + var mutations []m.Mutation + for mutation := range mutationCh { + mutations = append(mutations, mutation) + } + + // Context was cancelled, so we might have fewer mutations or an error + select { + case err := <-errCh: + if err != nil && err != context.Canceled { + t.Fatalf("unexpected error: %v", err) + } + default: + } +} + +func TestMutagen_StreamMutations_InvalidSource(t *testing.T) { + mg := newTestMutagen() + + sources := make(chan m.Source, 1) + sources <- m.Source{} // Invalid source - no origin + close(sources) + + mutationCh, errCh := mg.StreamMutations(context.Background(), sources, 4, m.MutationArithmetic) + + var mutations []m.Mutation + for mutation := range mutationCh { + mutations = append(mutations, mutation) + } + + // Should receive an error + var receivedErr error + select { + case err := <-errCh: + receivedErr = err + default: + } + + if receivedErr == nil { + t.Fatal("expected error for invalid source") + } + + if len(mutations) != 0 { + t.Fatalf("expected no mutations for invalid source, got %d", len(mutations)) + } +} + +func TestMutagen_StreamMutations_InvalidMutationType(t *testing.T) { + mg := newTestMutagen() + + sources := make(chan m.Source, 1) + source := makeSourceV2(t, filepath.Join("..", "..", "examples", "basic", "main.go")) + sources <- source + close(sources) + + invalidType := m.MutationType{Name: "invalid", Version: 1} + mutationCh, errCh := mg.StreamMutations(context.Background(), sources, 4, invalidType) + + var mutations []m.Mutation + for mutation := range mutationCh { + mutations = append(mutations, mutation) + } + + // Should receive an error + var receivedErr error + select { + case err := <-errCh: + receivedErr = err + default: + } + + if receivedErr == nil { + t.Fatal("expected error for invalid mutation type") + } + + if len(mutations) != 0 { + t.Fatalf("expected no mutations for invalid type, got %d", len(mutations)) + } +} + +func TestMutagen_StreamMutations_EmptySources(t *testing.T) { + mg := newTestMutagen() + + sources := make(chan m.Source) + close(sources) // Close immediately - no sources + + mutationCh, errCh := mg.StreamMutations(context.Background(), sources, 4, m.MutationArithmetic) + + var mutations []m.Mutation + for mutation := range mutationCh { + mutations = append(mutations, mutation) + } + + // Check for errors + select { + case err := <-errCh: + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + default: + } + + if len(mutations) != 0 { + t.Fatalf("expected no mutations for empty sources, got %d", len(mutations)) + } +} + +func TestMutagen_StreamMutations_DefaultMutationTypes(t *testing.T) { + mg := newTestMutagen() + + sources := make(chan m.Source, 1) + source := makeSourceV2(t, filepath.Join("..", "..", "examples", "basic", "main.go")) + sources <- source + close(sources) + + // No mutation types specified - should use defaults + mutationCh, errCh := mg.StreamMutations(context.Background(), sources, 4) + + var mutations []m.Mutation + for mutation := range mutationCh { + mutations = append(mutations, mutation) + } + + // Check for errors + select { + case err := <-errCh: + if err != nil { + t.Fatalf("StreamMutations failed: %v", err) + } + default: + } + + if len(mutations) == 0 { + t.Fatal("expected mutations with default types") + } +} + +func TestMutagen_StreamMutations_ThreadsZero(t *testing.T) { + mg := newTestMutagen() + + sources := make(chan m.Source, 1) + source := makeSourceV2(t, filepath.Join("..", "..", "examples", "basic", "main.go")) + sources <- source + close(sources) + + // threads=0 should normalize to 1 + mutationCh, errCh := mg.StreamMutations(context.Background(), sources, 0, m.MutationArithmetic) + + var mutations []m.Mutation + for mutation := range mutationCh { + mutations = append(mutations, mutation) + } + + // Check for errors + select { + case err := <-errCh: + if err != nil { + t.Fatalf("StreamMutations failed: %v", err) + } + default: + } + + if len(mutations) != 4 { + t.Fatalf("expected 4 arithmetic mutations, got %d", len(mutations)) + } +} diff --git a/internal/domain/mutation_score.go b/internal/domain/mutation_score.go index a9e8f3e..609462e 100644 --- a/internal/domain/mutation_score.go +++ b/internal/domain/mutation_score.go @@ -32,3 +32,27 @@ func mutationScoreFromReports(reports pkg.FileSpill[m.Report]) (float64, error) return float64(killed) / float64(total), nil } + +func mutationScoreFromReportsStream(reports <-chan m.Report) (float64, error) { + killed := 0 + total := 0 + + for report := range reports { + for _, entries := range report.Result { + for _, entry := range entries { + total++ + + if entry.Status == m.Killed { + killed++ + } + } + } + } + + if total == 0 { + return 100.0, nil + } + + score := float64(killed) / float64(total) + return score, nil +} diff --git a/internal/domain/mutation_streamer.go b/internal/domain/mutation_streamer.go new file mode 100644 index 0000000..57c298d --- /dev/null +++ b/internal/domain/mutation_streamer.go @@ -0,0 +1,208 @@ +package domain + +import ( + "context" + "log/slog" + "sync/atomic" + + "golang.org/x/sync/errgroup" + "gooze.dev/pkg/gooze/internal/adapter" + m "gooze.dev/pkg/gooze/internal/model" +) + +// MutationStreamer defines the interface for streaming mutation generation. +type MutationStreamer interface { + Get(ctx context.Context, paths []m.Path, exclude []string, threads int) (<-chan m.Mutation, error) + FilterUnchanged(ctx context.Context, mutations <-chan m.Mutation, reportsPath m.Path, threads int) <-chan m.Mutation + ShardMutations(ctx context.Context, allMutations <-chan m.Mutation, threads int, shardIndex, totalShardCount int) <-chan m.Mutation +} + +type mutationStreamer struct { + adapter.SourceFSAdapter + adapter.ReportStore + Mutagen +} + +// NewMutationStreamer creates a new MutationStreamer instance with the provided dependencies. +func NewMutationStreamer(fsAdapter adapter.SourceFSAdapter, reportStore adapter.ReportStore, mutagen Mutagen) MutationStreamer { + return &mutationStreamer{ + SourceFSAdapter: fsAdapter, + ReportStore: reportStore, + Mutagen: mutagen, + } +} + +// Get streams mutations for the given paths, excluding specified patterns. +// The channel closes when done or when ctx is cancelled. +// Returns an error if source streaming fails or mutation generation encounters errors. +func (ms *mutationStreamer) Get(ctx context.Context, paths []m.Path, exclude []string, threads int) (<-chan m.Mutation, error) { + slog.Debug("Starting mutation streaming", "paths", len(paths), "threads", threads) + ch := make(chan m.Mutation, ms.normalizeBufferSize(threads)) + + sourceCh, err := ms.SourceFSAdapter.GetStream(ctx, paths, exclude...) + if err != nil { + slog.Error("Failed to start source streaming", "error", err) + close(ch) + return ch, err + } + + g, ctx := errgroup.WithContext(ctx) + + g.Go(func() error { + defer close(ch) + defer slog.Debug("Mutation streaming completed") + + slog.Debug("Source streaming started") + + var globalIndex uint64 + for source := range sourceCh { + if ctx.Err() != nil { + slog.Debug("Mutation streaming cancelled") + return ctx.Err() + } + + if err := ms.processSource(ctx, source, ch, &globalIndex); err != nil { + return err + } + } + + slog.Debug("Finished processing all sources", "totalMutations", atomic.LoadUint64(&globalIndex)) + return nil + }) + + go func() { + if err := g.Wait(); err != nil { + slog.Error("Mutation streaming failed", "error", err) + } + }() + + return ch, nil +} + +func (ms *mutationStreamer) normalizeBufferSize(threads int) int { + if threads <= 0 { + return 1 + } + return threads * 2 // Buffer size is typically set to a multiple of the number of threads for better throughput +} + +// FilterUnchanged filters mutations by checking if their sources have changed since the last report. +// It uses the ReportStore to load cached reports and only passes through mutations for changed sources. +// If reportsPath is empty, all mutations are passed through (no filtering). +func (ms *mutationStreamer) FilterUnchanged(ctx context.Context, mutations <-chan m.Mutation, reportsPath m.Path, threads int) <-chan m.Mutation { + ch := make(chan m.Mutation, ms.normalizeBufferSize(threads)) + + go func() { + defer close(ch) + if ctx.Err() != nil { + slog.Debug("Mutation filtering cancelled before start") + return + } + var newIndex uint64 + for mutation := range mutations { + if ctx.Err() != nil { + slog.Debug("Mutation filtering cancelled") + return + } + source := mutation.Source + changed, err := ms.ReportStore.CheckUpdate(ctx, reportsPath, source) + if err != nil { + slog.Error("Failed to check source update", "source", source.Origin.FullPath, "error", err) + continue + } + if changed { + mutation.Index = atomic.AddUint64(&newIndex, 1) - 1 + + select { + case <-ctx.Done(): + slog.Debug("Mutation filtering cancelled during processing") + return + case ch <- mutation: + } + } else { + slog.Debug("Source unchanged, skipping mutation", "source", source.Origin.FullPath) + } + } + }() + + return ch +} + +// processSource generates mutations for a single source and sends them to the channel. +// Returns an error if mutation generation fails or context is cancelled. +func (ms *mutationStreamer) processSource(ctx context.Context, source m.Source, ch chan<- m.Mutation, globalIndex *uint64) error { + mutations, err := ms.GenerateMutation(ctx, source, DefaultMutations...) + if err != nil { + slog.Error("Failed to generate mutations", "source", source.Origin.FullPath, "error", err) + return err + } + + slog.Debug("Generated mutations for source", "source", source.Origin.FullPath, "count", len(mutations)) + + for i := range mutations { + mutations[i].Index = atomic.AddUint64(globalIndex, 1) - 1 + + select { + case <-ctx.Done(): + return ctx.Err() + case ch <- mutations[i]: + } + } + + return nil +} + +// ShardMutations filters mutations by shard index using hash-based distribution. +// It streams only mutations that belong to the specified shard. +func (ms *mutationStreamer) ShardMutations(ctx context.Context, allMutations <-chan m.Mutation, threads int, shardIndex, totalShardCount int) <-chan m.Mutation { + ch := make(chan m.Mutation, ms.normalizeBufferSize(threads)) + + go func() { + defer close(ch) + + // If sharding is disabled, pass through all mutations + if totalShardCount <= 0 { + slog.Debug("Sharding disabled, passing through all mutations") + ms.passThroughMutations(ctx, allMutations, ch) + + return + } + + slog.Debug("Starting mutation sharding", "shardIndex", shardIndex, "totalShardCount", totalShardCount) + ms.filterMutationsByShard(ctx, allMutations, ch, shardIndex, totalShardCount) + }() + + return ch +} + +// passThroughMutations forwards all mutations from input to output channel. +func (ms *mutationStreamer) passThroughMutations(ctx context.Context, in <-chan m.Mutation, out chan<- m.Mutation) { + for mutation := range in { + select { + case <-ctx.Done(): + slog.Debug("Mutation pass-through cancelled") + return + case out <- mutation: + } + } +} + +// filterMutationsByShard filters mutations using global index-based shard assignment. +func (ms *mutationStreamer) filterMutationsByShard(ctx context.Context, in <-chan m.Mutation, out chan<- m.Mutation, shardIndex, totalShardCount int) { + for mutation := range in { + select { + case <-ctx.Done(): + slog.Debug("Mutation sharding cancelled") + return + default: + } + + if int(mutation.Index)%totalShardCount == shardIndex { + select { + case <-ctx.Done(): + return + case out <- mutation: + } + } + } +} diff --git a/internal/domain/mutation_streamer_test.go b/internal/domain/mutation_streamer_test.go new file mode 100644 index 0000000..3f30eab --- /dev/null +++ b/internal/domain/mutation_streamer_test.go @@ -0,0 +1,767 @@ +package domain_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + adaptermocks "gooze.dev/pkg/gooze/internal/adapter/mocks" + "gooze.dev/pkg/gooze/internal/domain" + domainmocks "gooze.dev/pkg/gooze/internal/domain/mocks" + m "gooze.dev/pkg/gooze/internal/model" +) + +func TestMutationStreamer_Get_Success(t *testing.T) { + // Arrange + ctx := context.Background() + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + sources := []m.Source{ + {Origin: &m.File{FullPath: "test.go", Hash: "hash1"}}, + } + + mutations := []m.Mutation{ + {ID: "hash-1", Source: sources[0], Type: m.MutationArithmetic}, + {ID: "hash-2", Source: sources[0], Type: m.MutationBoolean}, + } + + sourceCh := make(chan m.Source, 1) + sourceCh <- sources[0] + close(sourceCh) + + mockFSAdapter.EXPECT().GetStream(ctx, mock.Anything, mock.Anything).Return(sourceCh, nil) + mockMutagen.EXPECT().GenerateMutation(ctx, sources[0], domain.DefaultMutations[0], domain.DefaultMutations[1], domain.DefaultMutations[2], domain.DefaultMutations[3], domain.DefaultMutations[4], domain.DefaultMutations[5]).Return(mutations, nil) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + // Act + ch, err := streamer.Get(ctx, []m.Path{"test.go"}, nil, 4) + assert.NoError(t, err) + + // Assert + assert.NoError(t, err) + + var result []m.Mutation + for mutation := range ch { + result = append(result, mutation) + } + + assert.Len(t, result, 2) + assert.Equal(t, mutations[0].ID, result[0].ID) + assert.Equal(t, mutations[1].ID, result[1].ID) + assert.Equal(t, uint64(0), result[0].Index) + assert.Equal(t, uint64(1), result[1].Index) + mockFSAdapter.AssertExpectations(t) + mockMutagen.AssertExpectations(t) +} + +func TestMutationStreamer_Get_DiscoverSourcesError(t *testing.T) { + // Arrange + ctx := context.Background() + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + testErr := errors.New("failed to get sources") + mockFSAdapter.EXPECT().GetStream(ctx, mock.Anything, mock.Anything).Return(nil, testErr) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + // Act + ch, err := streamer.Get(ctx, []m.Path{"test.go"}, nil, 4) + assert.NoError(t, err) + + // Assert + assert.Error(t, err) + assert.Equal(t, testErr, err) + + var result []m.Mutation + for mutation := range ch { + result = append(result, mutation) + } + + assert.Empty(t, result) + mockFSAdapter.AssertExpectations(t) +} + +func TestMutationStreamer_Get_GenerateMutationError(t *testing.T) { + // Arrange + ctx := context.Background() + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + sources := []m.Source{ + {Origin: &m.File{FullPath: "test.go", Hash: "hash1"}}, + } + + sourceCh := make(chan m.Source, 1) + sourceCh <- sources[0] + close(sourceCh) + + testErr := errors.New("failed to generate mutations") + mockFSAdapter.EXPECT().GetStream(ctx, mock.Anything, mock.Anything).Return(sourceCh, nil) + mockMutagen.EXPECT().GenerateMutation(ctx, sources[0], domain.DefaultMutations[0], domain.DefaultMutations[1], domain.DefaultMutations[2], domain.DefaultMutations[3], domain.DefaultMutations[4], domain.DefaultMutations[5]).Return(nil, testErr) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + // Act + ch, err := streamer.Get(ctx, []m.Path{"test.go"}, nil, 4) + assert.NoError(t, err) + + // Assert + assert.NoError(t, err) // GetStream succeeds, error is in background goroutine + + var result []m.Mutation + for mutation := range ch { + result = append(result, mutation) + } + + assert.Empty(t, result) // Channel closes on error + mockFSAdapter.AssertExpectations(t) + mockMutagen.AssertExpectations(t) +} + +func TestMutationStreamer_Get_MultipleSources(t *testing.T) { + // Arrange + ctx := context.Background() + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + sources := []m.Source{ + {Origin: &m.File{FullPath: "test1.go", Hash: "hash1"}}, + {Origin: &m.File{FullPath: "test2.go", Hash: "hash2"}}, + } + + mutations1 := []m.Mutation{ + {ID: "hash-1", Source: sources[0], Type: m.MutationArithmetic}, + } + mutations2 := []m.Mutation{ + {ID: "hash-2", Source: sources[1], Type: m.MutationBoolean}, + } + + sourceCh := make(chan m.Source, 2) + sourceCh <- sources[0] + sourceCh <- sources[1] + close(sourceCh) + + mockFSAdapter.EXPECT().GetStream(ctx, mock.Anything, mock.Anything).Return(sourceCh, nil) + mockMutagen.EXPECT().GenerateMutation(ctx, sources[0], domain.DefaultMutations[0], domain.DefaultMutations[1], domain.DefaultMutations[2], domain.DefaultMutations[3], domain.DefaultMutations[4], domain.DefaultMutations[5]).Return(mutations1, nil) + mockMutagen.EXPECT().GenerateMutation(ctx, sources[1], domain.DefaultMutations[0], domain.DefaultMutations[1], domain.DefaultMutations[2], domain.DefaultMutations[3], domain.DefaultMutations[4], domain.DefaultMutations[5]).Return(mutations2, nil) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + // Act + ch, err := streamer.Get(ctx, []m.Path{"."}, nil, 4) + assert.NoError(t, err) + + var result []m.Mutation + for mutation := range ch { + result = append(result, mutation) + } + + // Assert + assert.Len(t, result, 2) + mockFSAdapter.AssertExpectations(t) + mockMutagen.AssertExpectations(t) +} + +func TestMutationStreamer_Get_ContextCancelled(t *testing.T) { + // Arrange + ctx, cancel := context.WithCancel(context.Background()) + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + sources := []m.Source{ + {Origin: &m.File{FullPath: "test.go", Hash: "hash1"}}, + } + + sourceCh := make(chan m.Source, 1) + sourceCh <- sources[0] + close(sourceCh) + + mockFSAdapter.EXPECT().GetStream(ctx, mock.Anything, mock.Anything).Return(sourceCh, nil) + mockMutagen.EXPECT().GenerateMutation(ctx, sources[0], domain.DefaultMutations[0], domain.DefaultMutations[1], domain.DefaultMutations[2], domain.DefaultMutations[3], domain.DefaultMutations[4], domain.DefaultMutations[5]).Run(func(_ context.Context, _ m.Source, _ ...m.MutationType) { + cancel() // Cancel context during mutation generation + }).Return([]m.Mutation{}, nil) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + // Act + ch, err := streamer.Get(ctx, []m.Path{"test.go"}, nil, 4) + assert.NoError(t, err) + + var result []m.Mutation + for mutation := range ch { + result = append(result, mutation) + } + + // Assert + assert.Empty(t, result) + assert.Error(t, ctx.Err()) +} + +func TestMutationStreamer_Get_NoSources(t *testing.T) { + // Arrange + ctx := context.Background() + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + sourceCh := make(chan m.Source) + close(sourceCh) + + mockFSAdapter.EXPECT().GetStream(ctx, mock.Anything, mock.Anything).Return(sourceCh, nil) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + // Act + ch, err := streamer.Get(ctx, []m.Path{"test.go"}, nil, 4) + assert.NoError(t, err) + + var result []m.Mutation + for mutation := range ch { + result = append(result, mutation) + } + + // Assert + assert.Empty(t, result) + mockFSAdapter.AssertExpectations(t) +} + +func TestMutationStreamer_Get_NoMutations(t *testing.T) { + // Arrange + ctx := context.Background() + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + sources := []m.Source{ + {Origin: &m.File{FullPath: "test.go", Hash: "hash1"}}, + } + + sourceCh := make(chan m.Source, 1) + sourceCh <- sources[0] + close(sourceCh) + + mockFSAdapter.EXPECT().GetStream(ctx, mock.Anything, mock.Anything).Return(sourceCh, nil) + mockMutagen.EXPECT().GenerateMutation(ctx, sources[0], domain.DefaultMutations[0], domain.DefaultMutations[1], domain.DefaultMutations[2], domain.DefaultMutations[3], domain.DefaultMutations[4], domain.DefaultMutations[5]).Return([]m.Mutation{}, nil) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + // Act + ch, err := streamer.Get(ctx, []m.Path{"test.go"}, nil, 4) + assert.NoError(t, err) + + var result []m.Mutation + for mutation := range ch { + result = append(result, mutation) + } + + // Assert + assert.Empty(t, result) + mockFSAdapter.AssertExpectations(t) + mockMutagen.AssertExpectations(t) +} + +func TestMutationStreamer_Get_ThreadsZeroNormalizesToOne(t *testing.T) { + // Arrange + ctx := context.Background() + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + sources := []m.Source{ + {Origin: &m.File{FullPath: "test.go", Hash: "hash1"}}, + } + + mutations := []m.Mutation{ + {ID: "hash-1", Source: sources[0], Type: m.MutationArithmetic}, + } + + sourceCh := make(chan m.Source, 1) + sourceCh <- sources[0] + close(sourceCh) + + mockFSAdapter.EXPECT().GetStream(ctx, mock.Anything, mock.Anything).Return(sourceCh, nil) + mockMutagen.EXPECT().GenerateMutation(ctx, sources[0], domain.DefaultMutations[0], domain.DefaultMutations[1], domain.DefaultMutations[2], domain.DefaultMutations[3], domain.DefaultMutations[4], domain.DefaultMutations[5]).Return(mutations, nil) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + // Act - Pass threads=0, should not panic + ch, err := streamer.Get(ctx, []m.Path{"test.go"}, nil, 0) + assert.NoError(t, err) + + var result []m.Mutation + for mutation := range ch { + result = append(result, mutation) + } + + // Assert + assert.Len(t, result, 1) + mockFSAdapter.AssertExpectations(t) + mockMutagen.AssertExpectations(t) +} + +func TestMutationStreamer_Get_WithExcludePatterns(t *testing.T) { + // Arrange + ctx := context.Background() + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + exclude := []string{"*_test.go", "vendor/*"} + + sources := []m.Source{ + {Origin: &m.File{FullPath: "main.go", Hash: "hash1"}}, + } + + mutations := []m.Mutation{ + {ID: "hash-1", Source: sources[0], Type: m.MutationArithmetic}, + } + + sourceCh := make(chan m.Source, 1) + sourceCh <- sources[0] + close(sourceCh) + + mockFSAdapter.EXPECT().GetStream(ctx, []m.Path{"."}, exclude[0], exclude[1]).Return(sourceCh, nil) + mockMutagen.EXPECT().GenerateMutation(ctx, sources[0], domain.DefaultMutations[0], domain.DefaultMutations[1], domain.DefaultMutations[2], domain.DefaultMutations[3], domain.DefaultMutations[4], domain.DefaultMutations[5]).Return(mutations, nil) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + // Act + ch, err := streamer.Get(ctx, []m.Path{"."}, exclude, 4) + assert.NoError(t, err) + + var result []m.Mutation + for mutation := range ch { + result = append(result, mutation) + } + + // Assert + assert.Len(t, result, 1) + mockFSAdapter.AssertExpectations(t) + mockMutagen.AssertExpectations(t) +} + +// Benchmarks for memory allocation + +func BenchmarkMutationStreamer_Get_SmallSet(b *testing.B) { + ctx := context.Background() + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + sources := []m.Source{ + {Origin: &m.File{FullPath: "test.go", Hash: "hash1"}}, + } + + mutations := make([]m.Mutation, 10) + for i := range mutations { + mutations[i] = m.Mutation{ID: "hash-" + string(rune('0'+i)), Source: sources[0], Type: m.MutationArithmetic} + } + + sourceCh := make(chan m.Source, 1) + sourceCh <- sources[0] + close(sourceCh) + + mockFSAdapter.EXPECT().GetStream(ctx, mock.Anything, mock.Anything).Return(sourceCh, nil) + mockMutagen.EXPECT().GenerateMutation(ctx, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(mutations, nil) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + ch, err := streamer.Get(ctx, []m.Path{"test.go"}, nil, 4) + assert.NoError(t, err) + for range ch { + } + } +} + +func BenchmarkMutationStreamer_Get_MediumSet(b *testing.B) { + ctx := context.Background() + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + sources := make([]m.Source, 10) + for i := range sources { + sources[i] = m.Source{Origin: &m.File{FullPath: m.Path("test" + string(rune('0'+i)) + ".go"), Hash: "hash"}} + } + + mutations := make([]m.Mutation, 100) + for i := range mutations { + mutations[i] = m.Mutation{ID: "hash-" + string(rune(i)), Source: sources[0], Type: m.MutationArithmetic} + } + + sourceCh := make(chan m.Source, len(sources)) + for _, src := range sources { + sourceCh <- src + } + close(sourceCh) + + mockFSAdapter.EXPECT().GetStream(ctx, mock.Anything, mock.Anything).Return(sourceCh, nil) + mockMutagen.EXPECT().GenerateMutation(ctx, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(mutations, nil) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + ch, err := streamer.Get(ctx, []m.Path{"."}, nil, 8) + assert.NoError(t, err) + for range ch { + } + } +} + +func BenchmarkMutationStreamer_Get_LargeSet(b *testing.B) { + ctx := context.Background() + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + sources := make([]m.Source, 100) + for i := range sources { + sources[i] = m.Source{Origin: &m.File{FullPath: m.Path("test" + string(rune(i)) + ".go"), Hash: "hash"}} + } + + mutations := make([]m.Mutation, 1000) + for i := range mutations { + mutations[i] = m.Mutation{ID: "hash-" + string(rune(i)), Source: sources[0], Type: m.MutationArithmetic} + } + + sourceCh := make(chan m.Source, len(sources)) + for _, src := range sources { + sourceCh <- src + } + close(sourceCh) + + mockFSAdapter.EXPECT().GetStream(ctx, mock.Anything, mock.Anything).Return(sourceCh, nil) + mockMutagen.EXPECT().GenerateMutation(ctx, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(mutations, nil) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + ch, err := streamer.Get(ctx, []m.Path{"."}, nil, 16) + assert.NoError(t, err) + for range ch { + } + } +} + +func BenchmarkMutationStreamer_Get_BufferSizes(b *testing.B) { + ctx := context.Background() + + sources := []m.Source{ + {Origin: &m.File{FullPath: "test.go", Hash: "hash1"}}, + } + + mutations := make([]m.Mutation, 100) + for i := range mutations { + mutations[i] = m.Mutation{ID: "hash-" + string(rune(i)), Source: sources[0], Type: m.MutationArithmetic} + } + + bufferSizes := []int{1, 4, 8, 16, 32} + + for _, bufSize := range bufferSizes { + b.Run("buffer_"+string(rune('0'+bufSize)), func(b *testing.B) { + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + sourceCh := make(chan m.Source, 1) + sourceCh <- sources[0] + close(sourceCh) + + mockFSAdapter.EXPECT().GetStream(ctx, mock.Anything, mock.Anything).Return(sourceCh, nil) + mockMutagen.EXPECT().GenerateMutation(ctx, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(mutations, nil) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + ch, err := streamer.Get(ctx, []m.Path{"test.go"}, nil, bufSize) + assert.NoError(t, err) + for range ch { + } + } + }) + } +} + +// ShardMutations tests + +func TestMutationStreamer_ShardMutations_EvenDistribution(t *testing.T) { + // Arrange + ctx := context.Background() + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + // Create input channel with 6 mutations + inputCh := make(chan m.Mutation, 6) + for i := 0; i < 6; i++ { + inputCh <- m.Mutation{ID: "mutation-" + string(rune('0'+i))} + } + close(inputCh) + + // Act - shard into 3 shards + shard0Ch := streamer.ShardMutations(ctx, inputCh, 4, 0, 3) + + var shard0 []m.Mutation + for mutation := range shard0Ch { + shard0 = append(shard0, mutation) + } + + // Assert - shard 0 should get mutations 0, 3 (indices 0, 3 mod 3 == 0) + assert.Len(t, shard0, 2) + assert.Equal(t, "mutation-0", shard0[0].ID) + assert.Equal(t, "mutation-3", shard0[1].ID) +} + +func TestMutationStreamer_ShardMutations_AllShardsGetEqualMutations(t *testing.T) { + // Arrange + ctx := context.Background() + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + totalShards := 3 + totalMutations := 9 + + // Test each shard + for shardIndex := 0; shardIndex < totalShards; shardIndex++ { + inputCh := make(chan m.Mutation, totalMutations) + for i := 0; i < totalMutations; i++ { + inputCh <- m.Mutation{ID: "mutation-" + string(rune('0'+i))} + } + close(inputCh) + + shardCh := streamer.ShardMutations(ctx, inputCh, 4, shardIndex, totalShards) + + var shardMutations []m.Mutation + for mutation := range shardCh { + shardMutations = append(shardMutations, mutation) + } + + // Each shard should get exactly 3 mutations (9 / 3) + assert.Len(t, shardMutations, 3, "Shard %d should have 3 mutations", shardIndex) + } +} + +func TestMutationStreamer_ShardMutations_DisabledSharding(t *testing.T) { + // Arrange + ctx := context.Background() + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + inputCh := make(chan m.Mutation, 5) + for i := 0; i < 5; i++ { + inputCh <- m.Mutation{ID: "mutation-" + string(rune('0'+i))} + } + close(inputCh) + + // Act - totalShardCount <= 0 should pass through all mutations + ch := streamer.ShardMutations(ctx, inputCh, 4, 0, 0) + + var result []m.Mutation + for mutation := range ch { + result = append(result, mutation) + } + + // Assert - all mutations should pass through + assert.Len(t, result, 5) +} + +func TestMutationStreamer_ShardMutations_NegativeShardCount(t *testing.T) { + // Arrange + ctx := context.Background() + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + inputCh := make(chan m.Mutation, 3) + for i := 0; i < 3; i++ { + inputCh <- m.Mutation{ID: "mutation-" + string(rune('0'+i))} + } + close(inputCh) + + // Act - negative totalShardCount should pass through all mutations + ch := streamer.ShardMutations(ctx, inputCh, 4, 0, -1) + + var result []m.Mutation + for mutation := range ch { + result = append(result, mutation) + } + + // Assert + assert.Len(t, result, 3) +} + +func TestMutationStreamer_ShardMutations_ContextCancelled(t *testing.T) { + // Arrange + ctx, cancel := context.WithCancel(context.Background()) + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + inputCh := make(chan m.Mutation, 10) + for i := 0; i < 10; i++ { + inputCh <- m.Mutation{ID: "mutation-" + string(rune('0'+i))} + } + close(inputCh) + + // Cancel context immediately + cancel() + + // Act + ch := streamer.ShardMutations(ctx, inputCh, 4, 0, 2) + + var result []m.Mutation + for mutation := range ch { + result = append(result, mutation) + } + + // Assert - should get few or no mutations due to cancellation + assert.True(t, len(result) < 10, "Should have received fewer mutations due to cancellation") +} + +func TestMutationStreamer_ShardMutations_SingleShard(t *testing.T) { + // Arrange + ctx := context.Background() + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + inputCh := make(chan m.Mutation, 5) + for i := 0; i < 5; i++ { + inputCh <- m.Mutation{ID: "mutation-" + string(rune('0'+i))} + } + close(inputCh) + + // Act - single shard should get all mutations + ch := streamer.ShardMutations(ctx, inputCh, 4, 0, 1) + + var result []m.Mutation + for mutation := range ch { + result = append(result, mutation) + } + + // Assert + assert.Len(t, result, 5) +} + +func TestMutationStreamer_ShardMutations_LastShardGetsRemainder(t *testing.T) { + // Arrange + ctx := context.Background() + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + // 7 mutations with 3 shards: shard 0 gets 3, shard 1 gets 2, shard 2 gets 2 + totalMutations := 7 + totalShards := 3 + + shardCounts := make([]int, totalShards) + + for shardIndex := 0; shardIndex < totalShards; shardIndex++ { + inputCh := make(chan m.Mutation, totalMutations) + for i := 0; i < totalMutations; i++ { + inputCh <- m.Mutation{ID: "mutation-" + string(rune('0'+i))} + } + close(inputCh) + + shardCh := streamer.ShardMutations(ctx, inputCh, 4, shardIndex, totalShards) + + for range shardCh { + shardCounts[shardIndex]++ + } + } + + // Assert - total should equal original count + total := 0 + for _, count := range shardCounts { + total += count + } + assert.Equal(t, totalMutations, total) + + // Round-robin: indices 0,3,6 -> shard 0 (3), indices 1,4 -> shard 1 (2), indices 2,5 -> shard 2 (2) + assert.Equal(t, 3, shardCounts[0]) + assert.Equal(t, 2, shardCounts[1]) + assert.Equal(t, 2, shardCounts[2]) +} + +func TestMutationStreamer_ShardMutations_EmptyInput(t *testing.T) { + // Arrange + ctx := context.Background() + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + inputCh := make(chan m.Mutation) + close(inputCh) + + // Act + ch := streamer.ShardMutations(ctx, inputCh, 4, 0, 3) + + var result []m.Mutation + for mutation := range ch { + result = append(result, mutation) + } + + // Assert + assert.Empty(t, result) +} + +func TestMutationStreamer_ShardMutations_DeterministicDistribution(t *testing.T) { + // Arrange - run same sharding twice, should get same results + ctx := context.Background() + mockFSAdapter := new(adaptermocks.MockSourceFSAdapter) + mockMutagen := new(domainmocks.MockMutagen) + + streamer := domain.NewMutationStreamer(mockFSAdapter, mockMutagen) + + // First run + inputCh1 := make(chan m.Mutation, 6) + for i := 0; i < 6; i++ { + inputCh1 <- m.Mutation{ID: "mutation-" + string(rune('0'+i))} + } + close(inputCh1) + + ch1 := streamer.ShardMutations(ctx, inputCh1, 4, 1, 3) + var result1 []m.Mutation + for mutation := range ch1 { + result1 = append(result1, mutation) + } + + // Second run with same input + inputCh2 := make(chan m.Mutation, 6) + for i := 0; i < 6; i++ { + inputCh2 <- m.Mutation{ID: "mutation-" + string(rune('0'+i))} + } + close(inputCh2) + + ch2 := streamer.ShardMutations(ctx, inputCh2, 4, 1, 3) + var result2 []m.Mutation + for mutation := range ch2 { + result2 = append(result2, mutation) + } + + // Assert - both runs should produce identical results + assert.Equal(t, len(result1), len(result2)) + for i := range result1 { + assert.Equal(t, result1[i].ID, result2[i].ID) + } +} diff --git a/internal/domain/orchestrator.go b/internal/domain/orchestrator.go index c68152f..db68c5c 100644 --- a/internal/domain/orchestrator.go +++ b/internal/domain/orchestrator.go @@ -4,7 +4,9 @@ import ( "context" "fmt" "log/slog" + "time" + "golang.org/x/sync/errgroup" "gooze.dev/pkg/gooze/internal/adapter" m "gooze.dev/pkg/gooze/internal/model" ) @@ -14,6 +16,7 @@ import ( // mutation is killed or survives. type Orchestrator interface { TestMutation(ctx context.Context, mutation m.Mutation) (m.Result, error) + TestMutationStream(ctx context.Context, mutations <-chan m.Mutation, timeout time.Duration) <-chan m.Report } type orchestrator struct { @@ -71,6 +74,49 @@ func (to *orchestrator) TestMutation(ctx context.Context, mutation m.Mutation) ( return to.resultForStatus(mutation, status), nil } +func (to *orchestrator) TestMutationStream(ctx context.Context, mutations <-chan m.Mutation, timeout time.Duration) <-chan m.Report { + reportCh := make(chan m.Report) + + g, ctx := errgroup.WithContext(ctx) + + g.Go(func() error { + defer close(reportCh) + for mutation := range mutations { + if ctx.Err() != nil { + slog.Debug("TestMutationStream cancelled") + return ctx.Err() + } + + result, err := to.TestMutation(ctx, mutation) + if err != nil { + slog.Error("Failed to test mutation", "mutation", mutation.ID, "error", err) + return err + } + + report := m.Report{ + Source: mutation.Source, + Result: result, + } + + select { + case <-ctx.Done(): + slog.Debug("TestMutationStream cancelled while sending result") + return ctx.Err() + case reportCh <- report: + } + } + return nil + }) + + go func() { + if err := g.Wait(); err != nil { + slog.Error("TestMutationStream failed", "error", err) + } + }() + + return reportCh +} + func (to *orchestrator) validateMutation(mutation m.Mutation) error { if mutation.Source.Origin == nil { return fmt.Errorf("source origin is nil") diff --git a/internal/domain/report_manager.go b/internal/domain/report_manager.go new file mode 100644 index 0000000..1fd7e5a --- /dev/null +++ b/internal/domain/report_manager.go @@ -0,0 +1,230 @@ +package domain + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "sort" + "strings" + + "gooze.dev/pkg/gooze/internal/adapter" + m "gooze.dev/pkg/gooze/internal/model" + pkg "gooze.dev/pkg/gooze/pkg" +) + +type ReportManager interface { + // Deprecated: Use SaveStream for better performance and lower memory usage on large mutation sets. + Save(ctx context.Context, reportsDir m.Path, reports pkg.FileSpill[m.Report]) error + SaveStream(ctx context.Context, reportsDir m.Path, reports <-chan m.Report) error + Merge(ctx context.Context, basePath m.Path) error + Load(ctx context.Context, path m.Path) ([]m.Report, error) +} + +type reportManager struct { + adapter.ReportStore +} + +func NewReportManager(reportStore adapter.ReportStore) ReportManager { + return &reportManager{ + ReportStore: reportStore, + } +} + +func (rm *reportManager) Merge(ctx context.Context, basePath m.Path) error { + if err := ctx.Err(); err != nil { + return err + } + + shardDirs, err := rm.findShardDirs(basePath) + if err != nil { + slog.Error("Failed to find shard directories", "error", err, "basePath", basePath) + return err + } + + if len(shardDirs) == 0 { + slog.Debug("No shard directories found; regenerating index only", "basePath", basePath) + return rm.regenerateIndex(ctx, basePath) + } + + merged, err := rm.mergeReports(ctx, basePath, shardDirs) + if err != nil { + slog.Error("Failed to merge shard reports", "error", err, "basePath", basePath) + return err + } + + slog.Info("Merged shard reports", "basePath", basePath) + + if err := rm.saveMergedReports(ctx, basePath, merged); err != nil { + return err + } + + slog.Debug("Saved merged reports and regenerated index", "basePath", basePath) + + return rm.removeShardDirs(shardDirs) +} + +func (rm *reportManager) Save(ctx context.Context, reportsDir m.Path, reports pkg.FileSpill[m.Report]) error { + err := rm.SaveSpillReports(ctx, reportsDir, reports) + if err != nil { + slog.Error("Failed to save reports", "error", err, "path", reportsDir) + return fmt.Errorf("save reports: %w", err) + } + + err = reports.Close() + if err != nil { + slog.Error("Failed to close reports spill", "error", err, "path", reportsDir) + return fmt.Errorf("close reports spill: %w", err) + } + + slog.Debug("Saved reports", "path", reportsDir) + + err = rm.regenerateIndex(ctx, reportsDir) + if err != nil { + slog.Error("Failed to regenerate reports index", "error", err, "path", reportsDir) + return fmt.Errorf("regenerate index: %w", err) + } + + slog.Debug("Regenerated reports index", "path", reportsDir) + return nil +} + +func (rm *reportManager) findShardDirs(base m.Path) ([]string, error) { + shardDirs, err := findShardDirs(string(base)) + if err != nil { + slog.Error("Failed to find shard directories", "error", err, "basePath", base) + return nil, fmt.Errorf("find shard directories: %w", err) + } + + return shardDirs, nil +} + +func (rm *reportManager) SaveStream(ctx context.Context, reportsDir m.Path, reports <-chan m.Report) error { + err := rm.SaveReportsStream(ctx, reportsDir, reports) + if err != nil { + slog.Error("Failed to save reports stream", "error", err, "path", reportsDir) + return fmt.Errorf("save reports stream: %w", err) + } + + slog.Debug("Saved reports stream", "path", reportsDir) + + err = rm.regenerateIndex(ctx, reportsDir) + if err != nil { + slog.Error("Failed to regenerate reports index after saving stream", "error", err, "path", reportsDir) + return fmt.Errorf("regenerate index: %w", err) + } + + slog.Debug("Regenerated reports index after saving stream", "path", reportsDir) + return nil +} + +func (rm *reportManager) Load(ctx context.Context, path m.Path) ([]m.Report, error) { + reports, err := rm.LoadReports(ctx, path) + if err != nil { + slog.Error("Failed to load reports", "error", err, "path", path) + return nil, fmt.Errorf("load reports: %w", err) + } + + slog.Debug("Loaded reports", "path", path, "reportCount", len(reports)) + return reports, nil +} + +func (rm *reportManager) mergeReports(ctx context.Context, base m.Path, shardDirs []string) ([]m.Report, error) { + merged := make([]m.Report, 0) + + // First, load existing reports from base directory to preserve cache. + existingReports, err := rm.loadReportsIfExists(ctx, base) + if err != nil { + return nil, fmt.Errorf("load existing reports from base: %w", err) + } + + merged = append(merged, existingReports...) + + // Then load and merge reports from all shards. + for _, shardDir := range shardDirs { + reports, err := rm.LoadReports(ctx, m.Path(shardDir)) + if err != nil { + slog.Error("Failed to load shard reports", "error", err, "shardDir", shardDir) + return nil, fmt.Errorf("load shard reports from %s: %w", shardDir, err) + } + + merged = append(merged, reports...) + } + + slog.Debug("Merged reports from shards", "totalReports", len(merged)) + + return merged, nil +} + +func (rm *reportManager) loadReportsIfExists(ctx context.Context, path m.Path) ([]m.Report, error) { + reports, err := rm.LoadReports(ctx, path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + + return nil, err + } + + return reports, nil +} + +func (rm *reportManager) removeShardDirs(shardDirs []string) error { + for _, shardDir := range shardDirs { + if err := os.RemoveAll(shardDir); err != nil { + return fmt.Errorf("remove shard directory %s: %w", shardDir, err) + } + } + + return nil +} + +func (rm *reportManager) saveMergedReports(ctx context.Context, basePath m.Path, reports []m.Report) error { + if err := rm.SaveReports(ctx, basePath, reports); err != nil { + slog.Error("Failed to save merged reports", "error", err, "basePath", basePath) + return fmt.Errorf("save merged reports: %w", err) + } + + slog.Debug("Saved merged reports and regenerated index", "basePath", basePath) + + return rm.regenerateIndex(ctx, basePath) +} + +func (rm *reportManager) regenerateIndex(ctx context.Context, basePath m.Path) error { + if err := rm.RegenerateIndex(ctx, basePath); err != nil { + return fmt.Errorf("regenerate index: %w", err) + } + + return nil +} + +func findShardDirs(baseDir string) ([]string, error) { + entries, err := os.ReadDir(baseDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, os.ErrNotExist + } + + return nil, err + } + + shardDirs := make([]string, 0) + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + if !strings.HasPrefix(entry.Name(), ShardDirPrefix) { + continue + } + + shardDirs = append(shardDirs, filepath.Join(baseDir, entry.Name())) + } + + sort.Strings(shardDirs) + + return shardDirs, nil +} diff --git a/internal/domain/report_manager_test.go b/internal/domain/report_manager_test.go new file mode 100644 index 0000000..2ecde3a --- /dev/null +++ b/internal/domain/report_manager_test.go @@ -0,0 +1,499 @@ +package domain_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + adaptermocks "gooze.dev/pkg/gooze/internal/adapter/mocks" + domain "gooze.dev/pkg/gooze/internal/domain" + m "gooze.dev/pkg/gooze/internal/model" + pkg "gooze.dev/pkg/gooze/pkg" +) + +func TestReportManager_Save_Success(t *testing.T) { + // Arrange + ctx := context.Background() + mockReportStore := new(adaptermocks.MockReportStore) + + reportsDir := m.Path("reports") + reports := createMockFileSpill(t, []m.Report{ + { + Source: m.Source{Origin: &m.File{FullPath: "test.go", Hash: "hash1"}}, + Result: m.Result{}, + }, + }) + + mockReportStore.EXPECT().SaveSpillReports(ctx, reportsDir, reports).Return(nil) + mockReportStore.EXPECT().RegenerateIndex(ctx, reportsDir).Return(nil) + + rm := domain.NewReportManager() + // Inject the mock using reflection or setter if available + // For this test, we assume NewReportManager returns a testable implementation + + // Act + err := rm.Save(ctx, reportsDir, reports) + + // Assert + assert.NoError(t, err) + mockReportStore.AssertExpectations(t) +} + +func TestReportManager_Save_SaveSpillReportsError(t *testing.T) { + // Arrange + ctx := context.Background() + mockReportStore := new(adaptermocks.MockReportStore) + + reportsDir := m.Path("reports") + reports := createMockFileSpill(t, []m.Report{ + { + Source: m.Source{Origin: &m.File{FullPath: "test.go", Hash: "hash1"}}, + Result: m.Result{}, + }, + }) + + saveErr := errors.New("failed to save reports") + mockReportStore.EXPECT().SaveSpillReports(ctx, reportsDir, reports).Return(saveErr) + + rm := domain.NewReportManager() + + // Act + err := rm.Save(ctx, reportsDir, reports) + + // Assert + assert.Error(t, err) + assert.Contains(t, err.Error(), "save reports") + mockReportStore.AssertExpectations(t) +} + +func TestReportManager_Save_RegenerateIndexError(t *testing.T) { + // Arrange + ctx := context.Background() + mockReportStore := new(adaptermocks.MockReportStore) + + reportsDir := m.Path("reports") + reports := createMockFileSpill(t, []m.Report{ + { + Source: m.Source{Origin: &m.File{FullPath: "test.go", Hash: "hash1"}}, + Result: m.Result{}, + }, + }) + + indexErr := errors.New("failed to regenerate index") + mockReportStore.EXPECT().SaveSpillReports(ctx, reportsDir, reports).Return(nil) + mockReportStore.EXPECT().RegenerateIndex(ctx, reportsDir).Return(indexErr) + + rm := domain.NewReportManager() + + // Act + err := rm.Save(ctx, reportsDir, reports) + + // Assert + assert.Error(t, err) + assert.Contains(t, err.Error(), "regenerate index") + mockReportStore.AssertExpectations(t) +} + +func TestReportManager_Save_ContextCancelled(t *testing.T) { + // Arrange + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + reportsDir := m.Path("reports") + reports := createMockFileSpill(t, []m.Report{}) + + rm := domain.NewReportManager() + + // Act + err := rm.Save(ctx, reportsDir, reports) + + // Assert + // The behavior depends on implementation, but context cancellation should be handled + // This test documents expected behavior + _ = err +} + +func TestReportManager_Merge_Success(t *testing.T) { + // Arrange + ctx := context.Background() + mockReportStore := new(adaptermocks.MockReportStore) + + basePath := m.Path(t.TempDir()) + + // Create shard directories + shard0 := filepath.Join(string(basePath), "shard_0") + shard1 := filepath.Join(string(basePath), "shard_1") + require.NoError(t, os.MkdirAll(shard0, 0755)) + require.NoError(t, os.MkdirAll(shard1, 0755)) + + shard0Reports := []m.Report{ + { + Source: m.Source{Origin: &m.File{FullPath: "test1.go", Hash: "hash1"}}, + Result: m.Result{}, + }, + } + + shard1Reports := []m.Report{ + { + Source: m.Source{Origin: &m.File{FullPath: "test2.go", Hash: "hash2"}}, + Result: m.Result{}, + }, + } + + // Mock loading existing reports from base (returns not found) + mockReportStore.EXPECT().LoadReports(ctx, basePath).Return(nil, os.ErrNotExist).Once() + + // Mock loading shard reports + mockReportStore.EXPECT().LoadReports(ctx, m.Path(shard0)).Return(shard0Reports, nil).Once() + mockReportStore.EXPECT().LoadReports(ctx, m.Path(shard1)).Return(shard1Reports, nil).Once() + + // Mock saving merged reports + mockReportStore.EXPECT().SaveReports(ctx, basePath, mock.MatchedBy(func(reports []m.Report) bool { + return len(reports) == 2 + })).Return(nil) + + mockReportStore.EXPECT().RegenerateIndex(ctx, basePath).Return(nil) + + rm := domain.NewReportManager() + + // Act + err := rm.Merge(ctx, basePath) + + // Assert + assert.NoError(t, err) + mockReportStore.AssertExpectations(t) + + // Verify shard directories are removed + _, err = os.Stat(shard0) + assert.True(t, os.IsNotExist(err), "shard_0 should be removed") + _, err = os.Stat(shard1) + assert.True(t, os.IsNotExist(err), "shard_1 should be removed") +} + +func TestReportManager_Merge_NoShardDirs(t *testing.T) { + // Arrange + ctx := context.Background() + mockReportStore := new(adaptermocks.MockReportStore) + + basePath := m.Path(t.TempDir()) + + // No shard directories exist, should only regenerate index + mockReportStore.EXPECT().RegenerateIndex(ctx, basePath).Return(nil) + + rm := domain.NewReportManager() + + // Act + err := rm.Merge(ctx, basePath) + + // Assert + assert.NoError(t, err) + mockReportStore.AssertExpectations(t) +} + +func TestReportManager_Merge_BasePathNotExists(t *testing.T) { + // Arrange + ctx := context.Background() + + basePath := m.Path("/nonexistent/path") + + rm := domain.NewReportManager() + + // Act + err := rm.Merge(ctx, basePath) + + // Assert + assert.Error(t, err) + assert.Contains(t, err.Error(), "find shard directories") +} + +func TestReportManager_Merge_LoadShardReportsError(t *testing.T) { + // Arrange + ctx := context.Background() + mockReportStore := new(adaptermocks.MockReportStore) + + basePath := m.Path(t.TempDir()) + + // Create shard directory + shard0 := filepath.Join(string(basePath), "shard_0") + require.NoError(t, os.MkdirAll(shard0, 0755)) + + loadErr := errors.New("failed to load shard reports") + + // Mock loading existing reports from base (returns not found) + mockReportStore.EXPECT().LoadReports(ctx, basePath).Return(nil, os.ErrNotExist).Once() + + // Mock loading shard reports with error + mockReportStore.EXPECT().LoadReports(ctx, m.Path(shard0)).Return(nil, loadErr).Once() + + rm := domain.NewReportManager() + + // Act + err := rm.Merge(ctx, basePath) + + // Assert + assert.Error(t, err) + assert.Contains(t, err.Error(), "load shard reports") + mockReportStore.AssertExpectations(t) +} + +func TestReportManager_Merge_SaveMergedReportsError(t *testing.T) { + // Arrange + ctx := context.Background() + mockReportStore := new(adaptermocks.MockReportStore) + + basePath := m.Path(t.TempDir()) + + // Create shard directory + shard0 := filepath.Join(string(basePath), "shard_0") + require.NoError(t, os.MkdirAll(shard0, 0755)) + + shard0Reports := []m.Report{ + { + Source: m.Source{Origin: &m.File{FullPath: "test1.go", Hash: "hash1"}}, + Result: m.Result{}, + }, + } + + saveErr := errors.New("failed to save merged reports") + + // Mock loading existing reports from base (returns not found) + mockReportStore.EXPECT().LoadReports(ctx, basePath).Return(nil, os.ErrNotExist).Once() + + // Mock loading shard reports + mockReportStore.EXPECT().LoadReports(ctx, m.Path(shard0)).Return(shard0Reports, nil).Once() + + // Mock saving merged reports with error + mockReportStore.EXPECT().SaveReports(ctx, basePath, mock.Anything).Return(saveErr) + + rm := domain.NewReportManager() + + // Act + err := rm.Merge(ctx, basePath) + + // Assert + assert.Error(t, err) + assert.Contains(t, err.Error(), "save merged reports") + mockReportStore.AssertExpectations(t) +} + +func TestReportManager_Merge_PreservesExistingReports(t *testing.T) { + // Arrange + ctx := context.Background() + mockReportStore := new(adaptermocks.MockReportStore) + + basePath := m.Path(t.TempDir()) + + // Create shard directory + shard0 := filepath.Join(string(basePath), "shard_0") + require.NoError(t, os.MkdirAll(shard0, 0755)) + + existingReports := []m.Report{ + { + Source: m.Source{Origin: &m.File{FullPath: "existing.go", Hash: "hash_existing"}}, + Result: m.Result{}, + }, + } + + shard0Reports := []m.Report{ + { + Source: m.Source{Origin: &m.File{FullPath: "test1.go", Hash: "hash1"}}, + Result: m.Result{}, + }, + } + + // Mock loading existing reports from base + mockReportStore.EXPECT().LoadReports(ctx, basePath).Return(existingReports, nil).Once() + + // Mock loading shard reports + mockReportStore.EXPECT().LoadReports(ctx, m.Path(shard0)).Return(shard0Reports, nil).Once() + + // Mock saving merged reports - verify both existing and shard reports are included + mockReportStore.EXPECT().SaveReports(ctx, basePath, mock.MatchedBy(func(reports []m.Report) bool { + if len(reports) != 2 { + return false + } + // Verify existing report is first + if reports[0].Source.Origin.Hash != "hash_existing" { + return false + } + // Verify shard report is second + if reports[1].Source.Origin.Hash != "hash1" { + return false + } + return true + })).Return(nil) + + mockReportStore.EXPECT().RegenerateIndex(ctx, basePath).Return(nil) + + rm := domain.NewReportManager() + + // Act + err := rm.Merge(ctx, basePath) + + // Assert + assert.NoError(t, err) + mockReportStore.AssertExpectations(t) +} + +func TestReportManager_Merge_MultipleShards(t *testing.T) { + // Arrange + ctx := context.Background() + mockReportStore := new(adaptermocks.MockReportStore) + + basePath := m.Path(t.TempDir()) + + // Create multiple shard directories + shard0 := filepath.Join(string(basePath), "shard_0") + shard1 := filepath.Join(string(basePath), "shard_1") + shard2 := filepath.Join(string(basePath), "shard_2") + require.NoError(t, os.MkdirAll(shard0, 0755)) + require.NoError(t, os.MkdirAll(shard1, 0755)) + require.NoError(t, os.MkdirAll(shard2, 0755)) + + // Create non-shard directory that should be ignored + otherDir := filepath.Join(string(basePath), "other") + require.NoError(t, os.MkdirAll(otherDir, 0755)) + + shard0Reports := []m.Report{ + {Source: m.Source{Origin: &m.File{FullPath: "test0.go", Hash: "hash0"}}}, + } + shard1Reports := []m.Report{ + {Source: m.Source{Origin: &m.File{FullPath: "test1.go", Hash: "hash1"}}}, + } + shard2Reports := []m.Report{ + {Source: m.Source{Origin: &m.File{FullPath: "test2.go", Hash: "hash2"}}}, + } + + // Mock loading existing reports from base (returns not found) + mockReportStore.EXPECT().LoadReports(ctx, basePath).Return(nil, os.ErrNotExist).Once() + + // Mock loading shard reports + mockReportStore.EXPECT().LoadReports(ctx, m.Path(shard0)).Return(shard0Reports, nil).Once() + mockReportStore.EXPECT().LoadReports(ctx, m.Path(shard1)).Return(shard1Reports, nil).Once() + mockReportStore.EXPECT().LoadReports(ctx, m.Path(shard2)).Return(shard2Reports, nil).Once() + + // Mock saving merged reports + mockReportStore.EXPECT().SaveReports(ctx, basePath, mock.MatchedBy(func(reports []m.Report) bool { + return len(reports) == 3 + })).Return(nil) + + mockReportStore.EXPECT().RegenerateIndex(ctx, basePath).Return(nil) + + rm := domain.NewReportManager() + + // Act + err := rm.Merge(ctx, basePath) + + // Assert + assert.NoError(t, err) + mockReportStore.AssertExpectations(t) + + // Verify only shard directories are removed, not the "other" directory + _, err = os.Stat(otherDir) + assert.NoError(t, err, "non-shard directory should not be removed") +} + +func TestReportManager_Merge_ContextCancelled(t *testing.T) { + // Arrange + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + basePath := m.Path(t.TempDir()) + + rm := domain.NewReportManager() + + // Act + err := rm.Merge(ctx, basePath) + + // Assert + assert.Error(t, err) + assert.Equal(t, context.Canceled, err) +} + +func TestReportManager_Merge_RegenerateIndexError(t *testing.T) { + // Arrange + ctx := context.Background() + mockReportStore := new(adaptermocks.MockReportStore) + + basePath := m.Path(t.TempDir()) + + // No shard directories, so only regenerate index is called + indexErr := errors.New("failed to regenerate index") + mockReportStore.EXPECT().RegenerateIndex(ctx, basePath).Return(indexErr) + + rm := domain.NewReportManager() + + // Act + err := rm.Merge(ctx, basePath) + + // Assert + assert.Error(t, err) + assert.Contains(t, err.Error(), "regenerate index") + mockReportStore.AssertExpectations(t) +} + +func TestReportManager_Merge_EmptyShardReports(t *testing.T) { + // Arrange + ctx := context.Background() + mockReportStore := new(adaptermocks.MockReportStore) + + basePath := m.Path(t.TempDir()) + + // Create shard directory + shard0 := filepath.Join(string(basePath), "shard_0") + require.NoError(t, os.MkdirAll(shard0, 0755)) + + // Empty shard reports + shard0Reports := []m.Report{} + + // Mock loading existing reports from base (returns not found) + mockReportStore.EXPECT().LoadReports(ctx, basePath).Return(nil, os.ErrNotExist).Once() + + // Mock loading shard reports + mockReportStore.EXPECT().LoadReports(ctx, m.Path(shard0)).Return(shard0Reports, nil).Once() + + // Mock saving merged reports (empty) + mockReportStore.EXPECT().SaveReports(ctx, basePath, mock.MatchedBy(func(reports []m.Report) bool { + return len(reports) == 0 + })).Return(nil) + + mockReportStore.EXPECT().RegenerateIndex(ctx, basePath).Return(nil) + + rm := domain.NewReportManager() + + // Act + err := rm.Merge(ctx, basePath) + + // Assert + assert.NoError(t, err) + mockReportStore.AssertExpectations(t) +} + +// Helper function to create a mock FileSpill for testing +func createMockFileSpill(t *testing.T, reports []m.Report) pkg.FileSpill[m.Report] { + t.Helper() + + // Create a file spill + spill, err := pkg.NewFileSpill[m.Report]() + require.NoError(t, err) + + for _, report := range reports { + err := spill.Append(report) + require.NoError(t, err) + } + + return spill +} + +func TestReportManager_NewReportManager(t *testing.T) { + // Arrange & Act + rm := domain.NewReportManager() + + // Assert + assert.NotNil(t, rm) +} diff --git a/internal/domain/workflow copy.go_ b/internal/domain/workflow copy.go_ new file mode 100644 index 0000000..c96f18f --- /dev/null +++ b/internal/domain/workflow copy.go_ @@ -0,0 +1,446 @@ +package domain + +import ( + "context" + "fmt" + "log/slog" + "path/filepath" + "sort" + "sync" + "sync/atomic" + "time" + + "golang.org/x/sync/errgroup" + "gooze.dev/pkg/gooze/internal/adapter" + "gooze.dev/pkg/gooze/internal/controller" + m "gooze.dev/pkg/gooze/internal/model" + pkg "gooze.dev/pkg/gooze/pkg" +) + +// DefaultMutations defines the default set of mutation types to generate. +var DefaultMutations = []m.MutationType{m.MutationArithmetic, m.MutationBoolean, m.MutationNumbers, m.MutationComparison, m.MutationLogical, m.MutationUnary} + +// ShardDirPrefix is the directory name prefix used when storing sharded reports. +const ShardDirPrefix = "shard_" + +// EstimateArgs contains the arguments for estimating mutations. +type EstimateArgs struct { + Paths []m.Path + Exclude []string + UseCache bool + Reports m.Path +} + +// TestArgs contains the arguments for running mutation tests. +type TestArgs struct { + EstimateArgs + Reports m.Path + Threads int + ShardIndex int + TotalShardCount int + MutationTimeout time.Duration +} + +// ViewArgs contains the arguments for viewing mutation test reports. +type ViewArgs struct { + Reports m.Path +} + +// MergeArgs contains the arguments for merging sharded mutation test reports. +type MergeArgs struct { + Reports m.Path +} + +// Workflow defines the interface for the mutation testing workflow. +type Workflow interface { + Estimate(ctx context.Context, args EstimateArgs) error + Test(ctx context.Context, args TestArgs) error + View(ctx context.Context, args ViewArgs) error + Merge(ctx context.Context, args MergeArgs) error +} + +type workflow struct { + adapter.ReportStore + adapter.SourceFSAdapter + controller.UI + Orchestrator + Mutagen + MutationStreamer + ReportManager +} + +// NewWorkflow creates a new WorkflowV2 instance with the provided dependencies. +func NewWorkflow( + fsAdapter adapter.SourceFSAdapter, + reportStore adapter.ReportStore, + ui controller.UI, + orchestrator Orchestrator, + mutagen Mutagen, + mutationStreamer MutationStreamer, +) Workflow { + return &workflow{ + SourceFSAdapter: fsAdapter, + ReportStore: reportStore, + UI: ui, + Orchestrator: orchestrator, + Mutagen: mutagen, + MutationStreamer: mutationStreamer, + } +} + +func (w *workflow) Estimate(ctx context.Context, args EstimateArgs) error { + if err := w.Start(ctx, controller.WithEstimateMode()); err != nil { + slog.Error("Failed to start workflow UI", "error", err) + return err + } + + // Use streaming to get mutations + mutationsCh := w.MutationStreamer.Get(ctx, args.Paths, args.Exclude, 1) + + // Collect mutations for display + allMutations := make([]m.Mutation, 0) + for mutation := range mutationsCh { + allMutations = append(allMutations, mutation) + } + + err := w.DisplayEstimation(ctx, allMutations, nil) + if err != nil { + w.Close(ctx) + slog.Error("Failed to display estimation", "error", err) + + return fmt.Errorf("display: %w", err) + } + + // Wait for UI to be closed by user (press 'q') + w.Wait(ctx) + w.Close(ctx) + + return nil +} + +func shardReportsDir(base m.Path, shardIndex int, totalShardCount int) m.Path { + if totalShardCount <= 1 { + slog.Debug("Using unsharded reports directory", "path", base) + return base + } + + slog.Debug("Using sharded reports directory", "base", base, "shardIndex", shardIndex) + + return m.Path(filepath.Join(string(base), fmt.Sprintf("%s%d", ShardDirPrefix, shardIndex))) +} + +func (w *workflow) Test(ctx context.Context, args TestArgs) error { + return w.withTestUI(ctx, func() error { + slog.Info("Starting mutation testing", "threads", args.Threads, "shardIndex", args.ShardIndex, "totalShardCount", args.TotalShardCount) + w.DisplayConcurrencyInfo(ctx, args.Threads, args.ShardIndex, args.TotalShardCount) + + reportsDir := shardReportsDir(args.Reports, args.ShardIndex, args.TotalShardCount) + slog.Debug("Using reports directory", "path", reportsDir) + + // Stream mutations instead of collecting all upfront + mutationsCh := w.MutationStreamer.Get(ctx, args.Paths, args.Exclude, args.Threads) + shardedCh := w.MutationStreamer.ShardMutations(ctx, mutationsCh, 1, args.ShardIndex, args.TotalShardCount) + + reports, err := w.TestReports(ctx, shardedCh, args.Threads, args.MutationTimeout) + if err != nil { + slog.Error("Failed to run mutation tests", "error", err) + return fmt.Errorf("run mutation tests: %w", err) + } + + slog.Info("Completed mutation tests", "reportsCount", reports.Len()) + + score, err := mutationScoreFromReports(reports) + if err != nil { + return fmt.Errorf("calculate mutation score: %w", err) + } + + slog.Info("Calculated mutation score", "score", score) + w.DisplayMutationScore(ctx, score) + + err = w.SaveSpillReports(ctx, reportsDir, reports) + if err != nil { + slog.Error("Failed to save reports", "error", err, "path", reportsDir) + return fmt.Errorf("save reports: %w", err) + } + + err = reports.Close() + if err != nil { + slog.Error("Failed to close reports spill", "error", err, "path", reportsDir) + return fmt.Errorf("close reports spill: %w", err) + } + + slog.Debug("Saved reports", "path", reportsDir) + + err = w.RegenerateIndex(ctx, reportsDir) + if err != nil { + slog.Error("Failed to regenerate reports index", "error", err, "path", reportsDir) + return fmt.Errorf("regenerate index: %w", err) + } + + slog.Debug("Regenerated reports index", "path", reportsDir) + + return nil + }) +} + +// TestReports processes mutations from a channel and runs tests concurrently. +func (w *workflow) TestReports(ctx context.Context, mutations <-chan m.Mutation, threads int, mutationTimeout time.Duration) (pkg.FileSpill[m.Report], error) { + reports, err := pkg.NewFileSpill[m.Report]() + if err != nil { + slog.Error("failed to create reports filespill", "error", err) + return nil, fmt.Errorf("create reports filespill: %w", err) + } + + errors := []error{} + + effectiveThreads := threads + if effectiveThreads <= 0 { + effectiveThreads = 1 + } + + var ( + reportsMutex sync.Mutex + errorsMutex sync.Mutex + threadIDCounter int32 = -1 + ) + + var group errgroup.Group + group.SetLimit(effectiveThreads) + + for mutation := range mutations { + currentMutation := mutation + + ctxTimeout, cancel := context.WithTimeout(ctx, mutationTimeout) + + group.Go(w.processMutation( + ctx, + ctxTimeout, + currentMutation, &threadIDCounter, + effectiveThreads, &reportsMutex, + &errorsMutex, &reports, + &errors, + cancel, + )) + } + + if err := group.Wait(); err != nil { + return reports, err + } + + if len(errors) == 0 { + return reports, nil + } + + return reports, fmt.Errorf("errors occurred during streaming mutation testing: %v", errors) +} + +func (w *workflow) View(ctx context.Context, args ViewArgs) error { + return w.withTestUI(ctx, func() error { + slog.Info("Loading mutation test reports", "path", args.Reports) + + reports, err := w.LoadSpillReports(ctx, args.Reports) + if err != nil { + slog.Error("Failed to load reports", "error", err, "path", args.Reports) + return fmt.Errorf("load reports: %w", err) + } + + mutations, results, err := viewItemsFromReports(reports) + if err != nil { + return fmt.Errorf("extract view items from reports: %w", err) + } + + slog.Debug("Loaded reports", "reportsCount", reports.Len(), "mutationsCount", len(mutations)) + + score, err := mutationScoreFromReports(reports) + if err != nil { + return fmt.Errorf("calculate mutation score: %w", err) + } + + slog.Info("Calculated mutation score", "score", score) + w.DisplayUpcomingTestsInfo(ctx, len(mutations)) + + for i, mutation := range mutations { + w.DisplayStartingTestInfo(ctx, mutation, 0) + w.DisplayCompletedTestInfo(ctx, mutation, results[i]) + } + + w.DisplayMutationScore(ctx, score) + + return nil + }) +} + +func (w *workflow) Merge(ctx context.Context, args MergeArgs) error { + base := args.Reports + slog.Info("Merging sharded mutation test reports", "basePath", base) + + err := w.ReportManager.Merge(ctx, base) + if err != nil { + slog.Error("Failed to merge sharded mutation test reports", "error", err, "basePath", base) + return fmt.Errorf("merge sharded mutation test reports: %w", err) + } + + slog.Info("Completed merging sharded mutation test reports", "basePath", base) + return nil +} + +func (w *workflow) withTestUI(ctx context.Context, fn func() error) error { + slog.Info("Starting workflow in test mode") + + if err := w.Start(ctx, controller.WithTestMode()); err != nil { + slog.Error("Failed to start workflow in test mode", "error", err) + return err + } + + defer func() { + slog.Info("Closing workflow UI") + w.Close(ctx) + }() + + err := fn() + if err != nil { + return err + } + + // Wait for UI to be closed by user (press 'q') + w.Wait(ctx) + + return nil +} + +func viewItemsFromReports(reports pkg.FileSpill[m.Report]) ([]m.Mutation, []m.Result, error) { + mutations := make([]m.Mutation, 0) + results := make([]m.Result, 0) + + err := reports.Range(func(_ uint64, report m.Report) error { + if len(report.Result) == 0 { + return nil + } + + mutationTypes := make([]m.MutationType, 0, len(report.Result)) + for mutationType := range report.Result { + mutationTypes = append(mutationTypes, mutationType) + } + + sort.Slice(mutationTypes, func(i, j int) bool { + if mutationTypes[i].Name != mutationTypes[j].Name { + return mutationTypes[i].Name < mutationTypes[j].Name + } + + return mutationTypes[i].Version < mutationTypes[j].Version + }) + + for _, mutationType := range mutationTypes { + entries := report.Result[mutationType] + for _, entry := range entries { + mutation := m.Mutation{ + ID: entry.MutationID, + Source: report.Source, + Type: mutationType, + } + if entry.Status == m.Survived && report.Diff != nil { + mutation.DiffCode = *report.Diff + } + + result := m.Result{} + result[mutationType] = []struct { + MutationID string + Status m.TestStatus + Err error + }{ + { + MutationID: entry.MutationID, + Status: entry.Status, + Err: entry.Err, + }, + } + + mutations = append(mutations, mutation) + results = append(results, result) + } + } + + return nil + }) + if err != nil { + slog.Error("Failed to extract view items from reports", "error", err) + return nil, nil, err + } + + return mutations, results, nil +} + +func (w *workflow) processMutation( + ctx context.Context, + ctxTimeout context.Context, + currentMutation m.Mutation, + threadIDCounter *int32, + threads int, + reportsMutex *sync.Mutex, + errorsMutex *sync.Mutex, + reports *pkg.FileSpill[m.Report], + errors *[]error, + cancel context.CancelFunc, +) func() error { + return func() error { + // ensure the timeout context is cancelled when this goroutine finishes + defer cancel() + + // Assign a thread ID to this goroutine + threadID := int(atomic.AddInt32(threadIDCounter, 1)) % threads + + w.DisplayStartingTestInfo(ctx, currentMutation, threadID) + + mutationResult, err := w.TestMutation(ctxTimeout, currentMutation) + if err != nil { + errorsMutex.Lock() + + *errors = append(*errors, err) + + errorsMutex.Unlock() + + return nil + } + + report := m.Report{ + Source: currentMutation.Source, + Result: mutationResult, + } + if getMutationStatus(mutationResult, currentMutation) != m.Killed { + diff := currentMutation.DiffCode + report.Diff = &diff + } + + reportsMutex.Lock() + + err = (*reports).Append(report) + if err != nil { + slog.Error("failed to append report to filespill", "error", err) + return fmt.Errorf("append report to filespill: %w", err) + } + + reportsMutex.Unlock() + + w.DisplayCompletedTestInfo(ctx, currentMutation, mutationResult) + + return nil + } +} + +func getMutationStatus(result m.Result, mutation m.Mutation) m.TestStatus { + entries, ok := result[mutation.Type] + if !ok || len(entries) < 1 { + return m.Error + } + + for _, entry := range entries { + if entry.MutationID == mutation.ID { + return entry.Status + } + } + + // If the orchestrator returned entries for a different mutation ID, do not + // guess: treating it as an error avoids inflating the score and attaching an + // incorrect diff. + return m.Error +} diff --git a/internal/domain/workflow.go b/internal/domain/workflow.go index a23af4b..5ed5b0b 100644 --- a/internal/domain/workflow.go +++ b/internal/domain/workflow.go @@ -2,23 +2,13 @@ package domain import ( "context" - "crypto/sha256" - "errors" "fmt" "log/slog" - "os" - "path/filepath" - "sort" - "strings" - "sync" - "sync/atomic" "time" - "golang.org/x/sync/errgroup" "gooze.dev/pkg/gooze/internal/adapter" "gooze.dev/pkg/gooze/internal/controller" m "gooze.dev/pkg/gooze/internal/model" - pkg "gooze.dev/pkg/gooze/pkg" ) // DefaultMutations defines the default set of mutation types to generate. @@ -29,19 +19,18 @@ const ShardDirPrefix = "shard_" // EstimateArgs contains the arguments for estimating mutations. type EstimateArgs struct { - Paths []m.Path - Exclude []string - UseCache bool - Reports m.Path + Paths []m.Path + Exclude []string + UseCache bool + Reports m.Path + ShardIndex int + TotalShardCount int } // TestArgs contains the arguments for running mutation tests. type TestArgs struct { EstimateArgs - Reports m.Path Threads int - ShardIndex int - TotalShardCount int MutationTimeout time.Duration } @@ -64,11 +53,9 @@ type Workflow interface { } type workflow struct { - adapter.ReportStore - adapter.SourceFSAdapter - controller.UI + MutationStreamer Orchestrator - Mutagen + ReportManager } // NewWorkflow creates a new WorkflowV2 instance with the provided dependencies. @@ -78,619 +65,66 @@ func NewWorkflow( ui controller.UI, orchestrator Orchestrator, mutagen Mutagen, + mutationStreamer MutationStreamer, ) Workflow { return &workflow{ - SourceFSAdapter: fsAdapter, - ReportStore: reportStore, - UI: ui, - Orchestrator: orchestrator, - Mutagen: mutagen, + MutationStreamer: mutationStreamer, + Orchestrator: orchestrator, + ReportManager: NewReportManager(reportStore), } } +// Estimate implements Workflow. func (w *workflow) Estimate(ctx context.Context, args EstimateArgs) error { - if err := w.Start(ctx, controller.WithEstimateMode()); err != nil { - slog.Error("Failed to start workflow UI", "error", err) + threads := 1 // Use a single thread for estimation to ensure consistent mutation indexing + allMutations, err := w.MutationStreamer.Get(ctx, args.Paths, args.Exclude, threads) + if err != nil { return err } - - allMutations, err := w.GetMutations(ctx, args) - if err != nil { - w.Close(ctx) - slog.Error("Failed to generate mutations", "error", err) - - return fmt.Errorf("generate mutations: %w", err) + var filtered <-chan m.Mutation + if args.UseCache { + slog.Debug("Using cache for mutation estimation") + filtered = w.MutationStreamer.FilterUnchanged(ctx, allMutations, args.Reports, threads) + } else { + slog.Debug("Not using cache for mutation estimation") + filtered = allMutations } - - err = w.DisplayEstimation(ctx, allMutations, nil) - if err != nil { - w.Close(ctx) - slog.Error("Failed to display estimation", "error", err) - - return fmt.Errorf("display: %w", err) + shardMutations := w.MutationStreamer.ShardMutations(ctx, filtered, threads, args.ShardIndex, args.TotalShardCount) + for mutation := range shardMutations { + fmt.Printf("Estimated mutation: %s (Index: %d, Source: %s)\n", mutation.Type, mutation.Index, *&mutation.Source.Origin.FullPath) } - - // Wait for UI to be closed by user (press 'q') - w.Wait(ctx) - w.Close(ctx) - return nil } +// Test implements Workflow. func (w *workflow) Test(ctx context.Context, args TestArgs) error { - return w.withTestUI(ctx, func() error { - slog.Info("Starting mutation testing", "threads", args.Threads, "shardIndex", args.ShardIndex, "totalShardCount", args.TotalShardCount) - w.DisplayConcurrencyInfo(ctx, args.Threads, args.ShardIndex, args.TotalShardCount) - - reportsDir := shardReportsDir(args.Reports, args.ShardIndex, args.TotalShardCount) - slog.Debug("Using reports directory", "path", reportsDir) - - allMutations, err := w.GetMutations(ctx, args.EstimateArgs) - if err != nil { - slog.Error("Failed to generate mutations", "error", err) - return fmt.Errorf("generate mutations: %w", err) - } - - slog.Debug("Generated mutations", "count", len(allMutations)) - shardMutations := w.ShardMutations(allMutations, args.ShardIndex, args.TotalShardCount) - slog.Debug("Sharded mutations", "count", len(shardMutations)) - w.DisplayUpcomingTestsInfo(ctx, len(shardMutations)) - - reports, err := w.TestReports(ctx, shardMutations, args.Threads, args.MutationTimeout) - if err != nil { - slog.Error("Failed to run mutation tests", "error", err) - return fmt.Errorf("run mutation tests: %w", err) - } - - slog.Info("Completed mutation tests", "reportsCount", reports.Len()) - - score, err := mutationScoreFromReports(reports) - if err != nil { - return fmt.Errorf("calculate mutation score: %w", err) - } - - slog.Info("Calculated mutation score", "score", score) - w.DisplayMutationScore(ctx, score) - - err = w.SaveSpillReports(ctx, reportsDir, reports) - if err != nil { - slog.Error("Failed to save reports", "error", err, "path", reportsDir) - return fmt.Errorf("save reports: %w", err) - } - - err = reports.Close() - if err != nil { - slog.Error("Failed to close reports spill", "error", err, "path", reportsDir) - return fmt.Errorf("close reports spill: %w", err) - } - - slog.Debug("Saved reports", "path", reportsDir) - - err = w.RegenerateIndex(ctx, reportsDir) - if err != nil { - slog.Error("Failed to regenerate reports index", "error", err, "path", reportsDir) - return fmt.Errorf("regenerate index: %w", err) - } - - slog.Debug("Regenerated reports index", "path", reportsDir) - - return nil - }) -} - -func shardReportsDir(base m.Path, shardIndex int, totalShardCount int) m.Path { - if totalShardCount <= 1 { - slog.Debug("Using unsharded reports directory", "path", base) - return base - } - - slog.Debug("Using sharded reports directory", "base", base, "shardIndex", shardIndex) - - return m.Path(filepath.Join(string(base), fmt.Sprintf("%s%d", ShardDirPrefix, shardIndex))) -} - -func (w *workflow) View(ctx context.Context, args ViewArgs) error { - return w.withTestUI(ctx, func() error { - slog.Info("Loading mutation test reports", "path", args.Reports) - - reports, err := w.LoadSpillReports(ctx, args.Reports) - if err != nil { - slog.Error("Failed to load reports", "error", err, "path", args.Reports) - return fmt.Errorf("load reports: %w", err) - } - - mutations, results, err := viewItemsFromReports(reports) - if err != nil { - return fmt.Errorf("extract view items from reports: %w", err) - } - - slog.Debug("Loaded reports", "reportsCount", reports.Len(), "mutationsCount", len(mutations)) - - score, err := mutationScoreFromReports(reports) - if err != nil { - return fmt.Errorf("calculate mutation score: %w", err) - } - - slog.Info("Calculated mutation score", "score", score) - w.DisplayUpcomingTestsInfo(ctx, len(mutations)) - - for i, mutation := range mutations { - w.DisplayStartingTestInfo(ctx, mutation, 0) - w.DisplayCompletedTestInfo(ctx, mutation, results[i]) - } - - w.DisplayMutationScore(ctx, score) - - return nil - }) -} - -func (w *workflow) Merge(ctx context.Context, args MergeArgs) error { - base := args.Reports - slog.Info("Merging sharded mutation test reports", "basePath", base) - - if string(base) == "" { - slog.Error("Reports directory path is required but not provided") - return fmt.Errorf("reports directory path is required") - } - - shardDirs, err := w.findShardDirs(base) - if err != nil { - slog.Error("Failed to find shard directories", "error", err, "basePath", base) - return err - } - - if len(shardDirs) == 0 { - slog.Debug("No shard directories found; regenerating index only", "basePath", base) - return w.regenerateIndex(ctx, base) - } - - merged, err := w.mergeReports(ctx, base, shardDirs) + threads := 1 // Use a single thread for estimation to ensure consistent mutation indexing + allMutations, err := w.MutationStreamer.Get(ctx, args.Paths, args.Exclude, threads) if err != nil { - slog.Error("Failed to merge shard reports", "error", err, "basePath", base) return err } - - slog.Info("Merged shard reports", "basePath", base) - - if err := w.saveMergedReports(ctx, base, merged); err != nil { - return err - } - - slog.Debug("Saved merged reports and regenerated index", "basePath", base) - - return w.removeShardDirs(shardDirs) -} - -func (w *workflow) findShardDirs(base m.Path) ([]string, error) { - shardDirs, err := findShardDirs(string(base)) - if err != nil { - slog.Error("Failed to find shard directories", "error", err, "basePath", base) - return nil, fmt.Errorf("find shard directories: %w", err) - } - - return shardDirs, nil -} - -func (w *workflow) regenerateIndex(ctx context.Context, base m.Path) error { - if err := w.RegenerateIndex(ctx, base); err != nil { - return fmt.Errorf("regenerate index: %w", err) - } - - return nil -} - -func (w *workflow) mergeReports(ctx context.Context, base m.Path, shardDirs []string) ([]m.Report, error) { - merged := make([]m.Report, 0) - - // First, load existing reports from base directory to preserve cache. - existingReports, err := w.loadReportsIfExists(ctx, base) - if err != nil { - return nil, fmt.Errorf("load existing reports from base: %w", err) - } - - merged = append(merged, existingReports...) - - // Then load and merge reports from all shards. - for _, shardDir := range shardDirs { - reports, err := w.LoadReports(ctx, m.Path(shardDir)) - if err != nil { - slog.Error("Failed to load shard reports", "error", err, "shardDir", shardDir) - return nil, fmt.Errorf("load shard reports from %s: %w", shardDir, err) - } - - merged = append(merged, reports...) - } - - slog.Debug("Merged reports from shards", "totalReports", len(merged)) - - return merged, nil -} - -func (w *workflow) loadReportsIfExists(ctx context.Context, path m.Path) ([]m.Report, error) { - reports, err := w.LoadReports(ctx, path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil, nil - } - - return nil, err - } - - return reports, nil -} - -func (w *workflow) saveMergedReports(ctx context.Context, base m.Path, reports []m.Report) error { - if err := w.SaveReports(ctx, base, reports); err != nil { - slog.Error("Failed to save merged reports", "error", err, "basePath", base) - return fmt.Errorf("save merged reports: %w", err) - } - - slog.Debug("Saved merged reports and regenerated index", "basePath", base) - - return w.regenerateIndex(ctx, base) -} - -func (w *workflow) removeShardDirs(shardDirs []string) error { - for _, shardDir := range shardDirs { - if err := os.RemoveAll(shardDir); err != nil { - return fmt.Errorf("remove shard directory %s: %w", shardDir, err) - } - } - - return nil -} - -func findShardDirs(baseDir string) ([]string, error) { - entries, err := os.ReadDir(baseDir) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil, os.ErrNotExist - } - - return nil, err - } - - shardDirs := make([]string, 0) - - for _, entry := range entries { - if !entry.IsDir() { - continue - } - - if !strings.HasPrefix(entry.Name(), ShardDirPrefix) { - continue - } - - shardDirs = append(shardDirs, filepath.Join(baseDir, entry.Name())) - } - - sort.Strings(shardDirs) - - return shardDirs, nil -} - -func (w *workflow) withTestUI(ctx context.Context, fn func() error) error { - slog.Info("Starting workflow in test mode") - - if err := w.Start(ctx, controller.WithTestMode()); err != nil { - slog.Error("Failed to start workflow in test mode", "error", err) - return err + var filtered <-chan m.Mutation + if args.UseCache { + slog.Debug("Using cache for mutation estimation") + filtered = w.MutationStreamer.FilterUnchanged(ctx, allMutations, args.Reports, threads) + } else { + slog.Debug("Not using cache for mutation estimation") + filtered = allMutations } - - defer func() { - slog.Info("Closing workflow UI") - w.Close(ctx) - }() - - err := fn() - if err != nil { - return err + shardMutations := w.MutationStreamer.ShardMutations(ctx, filtered, threads, args.ShardIndex, args.TotalShardCount) + reportCh := w.Orchestrator.TestMutationStream(ctx, shardMutations, args.MutationTimeout) + if err := w.ReportManager.SaveStream(ctx, args.Reports, reportCh); err != nil { + return fmt.Errorf("failed to save reports: %w", err) } - - // Wait for UI to be closed by user (press 'q') - w.Wait(ctx) - return nil } -func viewItemsFromReports(reports pkg.FileSpill[m.Report]) ([]m.Mutation, []m.Result, error) { - mutations := make([]m.Mutation, 0) - results := make([]m.Result, 0) - - err := reports.Range(func(_ uint64, report m.Report) error { - if len(report.Result) == 0 { - return nil - } - - mutationTypes := make([]m.MutationType, 0, len(report.Result)) - for mutationType := range report.Result { - mutationTypes = append(mutationTypes, mutationType) - } - - sort.Slice(mutationTypes, func(i, j int) bool { - if mutationTypes[i].Name != mutationTypes[j].Name { - return mutationTypes[i].Name < mutationTypes[j].Name - } - - return mutationTypes[i].Version < mutationTypes[j].Version - }) - - for _, mutationType := range mutationTypes { - entries := report.Result[mutationType] - for _, entry := range entries { - mutation := m.Mutation{ - ID: entry.MutationID, - Source: report.Source, - Type: mutationType, - } - if entry.Status == m.Survived && report.Diff != nil { - mutation.DiffCode = *report.Diff - } - - result := m.Result{} - result[mutationType] = []struct { - MutationID string - Status m.TestStatus - Err error - }{ - { - MutationID: entry.MutationID, - Status: entry.Status, - Err: entry.Err, - }, - } - - mutations = append(mutations, mutation) - results = append(results, result) - } - } - - return nil - }) - if err != nil { - slog.Error("Failed to extract view items from reports", "error", err) - return nil, nil, err - } - - return mutations, results, nil -} - -func (w *workflow) GetMutations(ctx context.Context, args EstimateArgs) ([]m.Mutation, error) { - sources, err := w.Get(ctx, args.Paths, args.Exclude...) - if err != nil { - return nil, fmt.Errorf("get sources: %w", err) - } - - changedSSources, err := w.GetChangedSources(ctx, args, sources) - if err != nil { - return nil, fmt.Errorf("get changed sources: %w", err) - } - - allMutations, err := w.GenerateAllMutations(ctx, changedSSources) - if err != nil { - return nil, fmt.Errorf("generate mutations: %w", err) - } - - return allMutations, nil -} - -func (w *workflow) GetChangedSources(ctx context.Context, args EstimateArgs, sources []m.Source) ([]m.Source, error) { - if !args.UseCache { - return sources, nil - } - - if args.Reports == "" { - return sources, nil - } - - changed, err := w.CheckUpdates(ctx, args.Reports, sources) - if err != nil { - return nil, err - } - - currentByPath := w.buildSourcePathMap(sources) - deleted, changedExisting := w.separateDeletedAndChanged(changed, currentByPath) - - if len(deleted) > 0 { - if err := w.CleanReports(ctx, args.Reports, deleted); err != nil { - return nil, err - } - } - - return changedExisting, nil -} - -func (w *workflow) buildSourcePathMap(sources []m.Source) map[string]m.Source { - currentByPath := map[string]m.Source{} - - for _, src := range sources { - if src.Origin != nil && src.Origin.FullPath != "" { - currentByPath[string(src.Origin.FullPath)] = src - } - } - - return currentByPath -} - -func (w *workflow) separateDeletedAndChanged(changed []m.Source, currentByPath map[string]m.Source) ([]m.Source, []m.Source) { - deleted := make([]m.Source, 0) - changedExisting := make([]m.Source, 0) - - for _, src := range changed { - if src.Origin == nil || src.Origin.FullPath == "" { - continue - } - - if current, ok := currentByPath[string(src.Origin.FullPath)]; ok { - changedExisting = append(changedExisting, current) - } else { - deleted = append(deleted, src) - } - } - - return deleted, changedExisting -} - -func (w *workflow) GenerateAllMutations(ctx context.Context, sources []m.Source) ([]m.Mutation, error) { - mutationsIndex := 0 - - var allMutations []m.Mutation - - for _, source := range sources { - mutations, err := w.GenerateMutation(ctx, source, DefaultMutations...) - if err != nil { - return nil, err - } - - mutationsIndex += len(mutations) - allMutations = append(allMutations, mutations...) - } - - return allMutations, nil -} - -func (w *workflow) ShardMutations(allMutations []m.Mutation, shardIndex int, totalShardCount int) []m.Mutation { - if totalShardCount <= 0 { - return allMutations - } - - var shardMutations []m.Mutation - - for _, mutation := range allMutations { - // Use hash of the mutation ID to determine shard - h := sha256.Sum256([]byte(mutation.ID)) - - hashValue := int(h[0])<<24 + int(h[1])<<16 + int(h[2])<<8 + int(h[3]) - if hashValue < 0 { - hashValue = -hashValue - } - - if hashValue%totalShardCount == shardIndex { - shardMutations = append(shardMutations, mutation) - } - } - - return shardMutations -} - -func (w *workflow) TestReports(ctx context.Context, allMutations []m.Mutation, threads int, mutationTimeout time.Duration) (pkg.FileSpill[m.Report], error) { - reports, err := pkg.NewFileSpill[m.Report]() - if err != nil { - slog.Error("failed to create reports filespill", "error", err) - return nil, fmt.Errorf("create reports filespill: %w", err) - } - - errors := []error{} - - effectiveThreads := threads - if effectiveThreads <= 0 { - effectiveThreads = 1 - } - - var ( - reportsMutex sync.Mutex - errorsMutex sync.Mutex - threadIDCounter int32 = -1 - ) - - var group errgroup.Group - group.SetLimit(effectiveThreads) - - for _, mutation := range allMutations { - currentMutation := mutation - - ctxTimeout, cancel := context.WithTimeout(ctx, mutationTimeout) - defer cancel() - - group.Go(w.processMutation( - ctx, - ctxTimeout, - currentMutation, &threadIDCounter, - effectiveThreads, &reportsMutex, - &errorsMutex, &reports, - &errors, - )) - } - - if err := group.Wait(); err != nil { - return reports, err - } - - if len(errors) == 0 { - return reports, nil - } - - return reports, fmt.Errorf("errors occurred during mutation testing: %v", errors) -} - -func (w *workflow) processMutation( - ctx context.Context, - ctxTimeout context.Context, - currentMutation m.Mutation, - threadIDCounter *int32, - threads int, - reportsMutex *sync.Mutex, - errorsMutex *sync.Mutex, - reports *pkg.FileSpill[m.Report], - errors *[]error, -) func() error { - return func() error { - // Assign a thread ID to this goroutine - threadID := int(atomic.AddInt32(threadIDCounter, 1)) % threads - - w.DisplayStartingTestInfo(ctx, currentMutation, threadID) - - mutationResult, err := w.TestMutation(ctxTimeout, currentMutation) - if err != nil { - errorsMutex.Lock() - - *errors = append(*errors, err) - - errorsMutex.Unlock() - - return nil - } - - report := m.Report{ - Source: currentMutation.Source, - Result: mutationResult, - } - if getMutationStatus(mutationResult, currentMutation) != m.Killed { - diff := currentMutation.DiffCode - report.Diff = &diff - } - - reportsMutex.Lock() - - err = (*reports).Append(report) - if err != nil { - slog.Error("failed to append report to filespill", "error", err) - return fmt.Errorf("append report to filespill: %w", err) - } - - reportsMutex.Unlock() - - w.DisplayCompletedTestInfo(ctx, currentMutation, mutationResult) - - return nil - } +// Merge implements Workflow. +func (w *workflow) Merge(ctx context.Context, args MergeArgs) error { + return fmt.Errorf("unimplemented") } -func getMutationStatus(result m.Result, mutation m.Mutation) m.TestStatus { - entries, ok := result[mutation.Type] - if !ok || len(entries) < 1 { - return m.Error - } - - for _, entry := range entries { - if entry.MutationID == mutation.ID { - return entry.Status - } - } - - // If the orchestrator returned entries for a different mutation ID, do not - // guess: treating it as an error avoids inflating the score and attaching an - // incorrect diff. - return m.Error +// View implements Workflow. +func (w *workflow) View(ctx context.Context, args ViewArgs) error { + return fmt.Errorf("unimplemented") } diff --git a/internal/domain/workflow_test.go b/internal/domain/workflow_test.go index 1e14c1c..cea6e14 100644 --- a/internal/domain/workflow_test.go +++ b/internal/domain/workflow_test.go @@ -32,6 +32,15 @@ func collectSpillReports(t *testing.T, reports pkg.FileSpill[m.Report]) []m.Repo return collected } +func sendMutationsToChannel(mutations []m.Mutation) <-chan m.Mutation { + ch := make(chan m.Mutation, len(mutations)) + for _, mutation := range mutations { + ch <- mutation + } + close(ch) + return ch +} + func TestWorkflow_Test_Success(t *testing.T) { // Arrange ctx := context.Background() @@ -42,6 +51,7 @@ func TestWorkflow_Test_Success(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) sources := []m.Source{ { @@ -58,16 +68,20 @@ func TestWorkflow_Test_Success(t *testing.T) { mockUI.EXPECT().Wait(ctx).Return().Once() mockUI.EXPECT().Close(ctx).Return().Once() mockUI.EXPECT().DisplayConcurrencyInfo(ctx, mock.Anything, mock.Anything, mock.Anything).Return() - mockUI.EXPECT().DisplayUpcomingTestsInfo(ctx, mock.Anything).Return() mockUI.EXPECT().DisplayStartingTestInfo(ctx, mock.Anything, mock.Anything).Return().Once() mockUI.EXPECT().DisplayCompletedTestInfo(ctx, mock.Anything, mock.Anything).Return().Once() - mockFSAdapter.EXPECT().Get(ctx, mock.Anything).Return(sources, nil) - mockMutagen.EXPECT().GenerateMutation(ctx, mock.Anything, domain.DefaultMutations[0], domain.DefaultMutations[1], domain.DefaultMutations[2], domain.DefaultMutations[3], domain.DefaultMutations[4], domain.DefaultMutations[5]).Return(mutations, nil) + + // Mock streaming methods + mutationsCh := sendMutationsToChannel(mutations) + shardedCh := sendMutationsToChannel(mutations) + mockMutationStreamer.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(mutationsCh).Once() + mockMutationStreamer.EXPECT().ShardMutations(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(shardedCh).Once() + mockOrchestrator.EXPECT().TestMutation(mock.Anything, mock.Anything).Return(m.Result{}, nil) mockReportStore.EXPECT().SaveSpillReports(ctx, mock.Anything, mock.Anything).Return(nil) mockReportStore.EXPECT().RegenerateIndex(ctx, mock.Anything).Return(nil) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act args := domain.TestArgs{ @@ -83,8 +97,7 @@ func TestWorkflow_Test_Success(t *testing.T) { // Assert assert.NoError(t, err) - mockFSAdapter.AssertExpectations(t) - mockMutagen.AssertExpectations(t) + mockMutationStreamer.AssertExpectations(t) mockReportStore.AssertExpectations(t) mockOrchestrator.AssertExpectations(t) } @@ -99,6 +112,7 @@ func TestWorkflow_Test_GetSourcesError(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) testErr := errors.New("failed to get sources") mockUI.EXPECT().Start(ctx, mock.Anything).Return(nil).Once() @@ -106,7 +120,7 @@ func TestWorkflow_Test_GetSourcesError(t *testing.T) { mockUI.EXPECT().DisplayConcurrencyInfo(ctx, mock.Anything, mock.Anything, mock.Anything).Return() mockFSAdapter.EXPECT().Get(ctx, mock.Anything).Return(nil, testErr) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act args := domain.TestArgs{ @@ -132,6 +146,7 @@ func TestWorkflow_Test_GenerateMutationsError(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) sources := []m.Source{ {Origin: &m.File{FullPath: "test.go", Hash: "hash1"}}, @@ -144,7 +159,7 @@ func TestWorkflow_Test_GenerateMutationsError(t *testing.T) { mockFSAdapter.EXPECT().Get(ctx, mock.Anything).Return(sources, nil) mockMutagen.EXPECT().GenerateMutation(ctx, mock.Anything, domain.DefaultMutations[0], domain.DefaultMutations[1], domain.DefaultMutations[2], domain.DefaultMutations[3], domain.DefaultMutations[4], domain.DefaultMutations[5]).Return(nil, testErr) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act args := domain.TestArgs{ @@ -170,6 +185,7 @@ func TestWorkflow_Test_TestMutationError(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) sources := []m.Source{ {Origin: &m.File{FullPath: "test.go", Hash: "hash1"}}, @@ -189,7 +205,7 @@ func TestWorkflow_Test_TestMutationError(t *testing.T) { mockMutagen.EXPECT().GenerateMutation(ctx, mock.Anything, domain.DefaultMutations[0], domain.DefaultMutations[1], domain.DefaultMutations[2], domain.DefaultMutations[3], domain.DefaultMutations[4], domain.DefaultMutations[5]).Return(mutations, nil) mockOrchestrator.EXPECT().TestMutation(mock.Anything, mock.Anything).Return(nil, testErr) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act args := domain.TestArgs{ @@ -218,6 +234,7 @@ func TestWorkflow_Test_SaveReportsError(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) sources := []m.Source{ {Origin: &m.File{FullPath: "test.go", Hash: "hash1"}}, @@ -240,7 +257,7 @@ func TestWorkflow_Test_SaveReportsError(t *testing.T) { saveErr := errors.New("failed to save reports") mockReportStore.EXPECT().SaveSpillReports(ctx, mock.Anything, mock.Anything).Return(saveErr) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act args := domain.TestArgs{ @@ -269,6 +286,7 @@ func TestWorkflow_Test_NoMutations(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) sources := []m.Source{ {Origin: &m.File{FullPath: "test.go", Hash: "hash1"}}, @@ -287,7 +305,7 @@ func TestWorkflow_Test_NoMutations(t *testing.T) { })).Return(nil) mockReportStore.EXPECT().RegenerateIndex(ctx, mock.Anything).Return(nil) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act args := domain.TestArgs{ @@ -318,6 +336,7 @@ func TestWorkflow_Test_MultipleThreads(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) source := m.Source{ Origin: &m.File{FullPath: "test.go", Hash: "hash1"}, @@ -345,7 +364,7 @@ func TestWorkflow_Test_MultipleThreads(t *testing.T) { })).Return(nil) mockReportStore.EXPECT().RegenerateIndex(ctx, mock.Anything).Return(nil) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act args := domain.TestArgs{ @@ -373,6 +392,7 @@ func TestWorkflow_Test_WithSharding(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) baseReportsDir := m.Path("reports") expectedShardDir := m.Path(filepath.Join(string(baseReportsDir), domain.ShardDirPrefix+"0")) @@ -408,7 +428,7 @@ func TestWorkflow_Test_WithSharding(t *testing.T) { })).Return(nil) mockReportStore.EXPECT().RegenerateIndex(ctx, expectedShardDir).Return(nil) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act args := domain.TestArgs{ @@ -435,7 +455,7 @@ func TestWorkflow_ShardMutations_InvalidShardReturnsEmpty(t *testing.T) { {ID: "hash-2"}, } - wf := domain.NewWorkflow(nil, nil, nil, nil, nil) + wf := domain.NewWorkflow(nil, nil, nil, nil, nil, nil) // Act result := wf.(interface { @@ -454,7 +474,7 @@ func TestWorkflow_ShardMutations_ShardIndexGreaterThanTotalReturnsEmpty(t *testi {ID: "hash-2"}, } - wf := domain.NewWorkflow(nil, nil, nil, nil, nil) + wf := domain.NewWorkflow(nil, nil, nil, nil, nil, nil) // Act result := wf.(interface { @@ -473,7 +493,7 @@ func TestWorkflow_ShardMutations_NonPositiveTotalReturnsAll(t *testing.T) { {ID: "hash-2"}, } - wf := domain.NewWorkflow(nil, nil, nil, nil, nil) + wf := domain.NewWorkflow(nil, nil, nil, nil, nil, nil) // Act resultZero := wf.(interface { @@ -500,7 +520,7 @@ func TestWorkflow_ShardMutations_MiddleShardSelectsExactMatches(t *testing.T) { {ID: "hash-5"}, } - wf := domain.NewWorkflow(nil, nil, nil, nil, nil) + wf := domain.NewWorkflow(nil, nil, nil, nil, nil, nil) // Act result := wf.(interface { @@ -534,6 +554,7 @@ func TestWorkflow_TestThreadsZeroDoesNotPanic(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) source := m.Source{ Origin: &m.File{FullPath: "test.go", Hash: "hash1"}, @@ -555,7 +576,7 @@ func TestWorkflow_TestThreadsZeroDoesNotPanic(t *testing.T) { mockOrchestrator.EXPECT().TestMutation(mock.Anything, mutations[0]).Return(m.Result{}, nil) mockReportStore.EXPECT().SaveSpillReports(ctx, mock.Anything, mock.Anything).Return(nil) mockReportStore.EXPECT().RegenerateIndex(ctx, mock.Anything).Return(nil) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act args := domain.TestArgs{ @@ -580,6 +601,7 @@ func TestWorkflow_TestThreadIDWithinBounds(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) source := m.Source{ Origin: &m.File{FullPath: "test.go", Hash: "hash1"}, @@ -607,7 +629,7 @@ func TestWorkflow_TestThreadIDWithinBounds(t *testing.T) { })).Return(nil) mockReportStore.EXPECT().RegenerateIndex(ctx, mock.Anything).Return(nil) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act args := domain.TestArgs{ @@ -632,6 +654,7 @@ func TestWorkflow_TestThreadIDIsUniqueForThreadsTwo(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) source := m.Source{ Origin: &m.File{FullPath: "test.go", Hash: "hash1"}, @@ -661,7 +684,7 @@ func TestWorkflow_TestThreadIDIsUniqueForThreadsTwo(t *testing.T) { })).Return(nil) mockReportStore.EXPECT().RegenerateIndex(ctx, mock.Anything).Return(nil) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act args := domain.TestArgs{ @@ -689,6 +712,7 @@ func TestWorkflow_TestWithSkippedMutation(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) diffCode := []byte("--- original\n+++ mutated\n@@ -1,1 +1,1 @@\n-\treturn 3 + 5\n+\treturn 3 - 5\n") @@ -737,7 +761,7 @@ func TestWorkflow_TestWithSkippedMutation(t *testing.T) { return report.Diff != nil })).Return(nil) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act args := domain.TestArgs{ @@ -762,6 +786,7 @@ func TestWorkflow_TestMutationIDExactMatchDoesNotUseHigherID(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) diffCode := []byte("--- original\n+++ mutated\n@@ -1,1 +1,1 @@\n-\treturn 3 + 5\n+\treturn 3 - 5\n") @@ -813,7 +838,7 @@ func TestWorkflow_TestMutationIDExactMatchDoesNotUseHigherID(t *testing.T) { return report.Diff != nil })).Return(nil) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act args := domain.TestArgs{ @@ -838,6 +863,7 @@ func TestWorkflow_TestEmptyResultEntriesReturnsError(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) diffCode := []byte("--- original\n+++ mutated\n@@ -1,1 +1,1 @@\n-\treturn 3 + 5\n+\treturn 3 - 5\n") @@ -886,7 +912,7 @@ func TestWorkflow_TestEmptyResultEntriesReturnsError(t *testing.T) { return report.Diff != nil })).Return(nil) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act args := domain.TestArgs{ @@ -911,6 +937,7 @@ func TestWorkflow_Test_MultipleSources(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) source1 := m.Source{ Origin: &m.File{FullPath: "file1.go", Hash: "hash1"}, @@ -942,7 +969,7 @@ func TestWorkflow_Test_MultipleSources(t *testing.T) { return reports.Len() == 3 })).Return(nil) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act args := domain.TestArgs{ @@ -972,9 +999,10 @@ func TestWorkflow_NewWorkflowV2(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) // Act - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Assert require.NotNil(t, wf) @@ -991,6 +1019,7 @@ func TestWorkflow_TestWithSurvivedMutation(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) diffCode := []byte("--- original\n+++ mutated\n@@ -1,1 +1,1 @@\n-\treturn 3 + 5\n+\treturn 3 - 5\n") @@ -1042,7 +1071,7 @@ func TestWorkflow_TestWithSurvivedMutation(t *testing.T) { return report.Diff != nil && string(*report.Diff) == string(diffCode) })).Return(nil) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act args := domain.TestArgs{ @@ -1072,6 +1101,7 @@ func TestWorkflow_TestWithKilledMutation(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) diffCode := []byte("--- original\n+++ mutated\n@@ -1,1 +1,1 @@\n-\treturn 3 + 5\n+\treturn 3 - 5\n") @@ -1123,7 +1153,7 @@ func TestWorkflow_TestWithKilledMutation(t *testing.T) { return report.Diff == nil })).Return(nil) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act args := domain.TestArgs{ @@ -1153,6 +1183,7 @@ func TestWorkflow_Estimate_Success(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) sources := []m.Source{ {Origin: &m.File{FullPath: "test.go", Hash: "hash1"}}, @@ -1172,7 +1203,7 @@ func TestWorkflow_Estimate_Success(t *testing.T) { mockFSAdapter.EXPECT().Get(ctx, mock.Anything).Return(sources, nil) mockMutagen.EXPECT().GenerateMutation(ctx, mock.Anything, domain.DefaultMutations[0], domain.DefaultMutations[1], domain.DefaultMutations[2], domain.DefaultMutations[3], domain.DefaultMutations[4], domain.DefaultMutations[5]).Return(mutations, nil) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act err := wf.Estimate(context.Background(), domain.EstimateArgs{Paths: []m.Path{"test.go"}}) @@ -1191,11 +1222,12 @@ func TestWorkflow_Estimate_StartError(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) startErr := errors.New("start failed") mockUI.EXPECT().Start(ctx, mock.Anything).Return(startErr).Once() - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act err := wf.Estimate(ctx, domain.EstimateArgs{Paths: []m.Path{"test.go"}}) @@ -1215,6 +1247,7 @@ func TestWorkflow_Estimate_GetMutationsError(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) getErr := errors.New("get mutations failed") @@ -1222,7 +1255,7 @@ func TestWorkflow_Estimate_GetMutationsError(t *testing.T) { mockUI.EXPECT().Close(ctx).Return().Once() mockFSAdapter.EXPECT().Get(ctx, mock.Anything).Return(nil, getErr) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act err := wf.Estimate(ctx, domain.EstimateArgs{Paths: []m.Path{"test.go"}}) @@ -1242,6 +1275,7 @@ func TestWorkflow_Estimate_DisplayError(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) sources := []m.Source{ {Origin: &m.File{FullPath: "test.go", Hash: "hash1"}}, @@ -1260,7 +1294,7 @@ func TestWorkflow_Estimate_DisplayError(t *testing.T) { mockFSAdapter.EXPECT().Get(ctx, mock.Anything).Return(sources, nil) mockMutagen.EXPECT().GenerateMutation(ctx, mock.Anything, domain.DefaultMutations[0], domain.DefaultMutations[1], domain.DefaultMutations[2], domain.DefaultMutations[3], domain.DefaultMutations[4], domain.DefaultMutations[5]).Return(mutations, nil) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act err := wf.Estimate(ctx, domain.EstimateArgs{Paths: []m.Path{"test.go"}}) @@ -1321,6 +1355,7 @@ func TestWorkflow_TestThreadIDStartsAtZero(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) source := m.Source{ Origin: &m.File{FullPath: "test.go", Hash: "hash1"}, @@ -1343,7 +1378,7 @@ func TestWorkflow_TestThreadIDStartsAtZero(t *testing.T) { mockReportStore.EXPECT().SaveSpillReports(ctx, mock.Anything, mock.Anything).Return(nil) mockReportStore.EXPECT().RegenerateIndex(ctx, mock.Anything).Return(nil) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act args := domain.TestArgs{ @@ -1368,6 +1403,7 @@ func TestWorkflow_TestExactMutationIDMatch(t *testing.T) { mockUI.EXPECT().DisplayMutationScore(ctx, mock.Anything).Return().Maybe() mockOrchestrator := new(domainmocks.MockOrchestrator) mockMutagen := new(domainmocks.MockMutagen) + mockMutationStreamer := new(domainmocks.MockMutationStreamer) diffCode := []byte("--- original\n+++ mutated\n@@ -1,1 +1,1 @@\n-\treturn 3 + 5\n+\treturn 3 - 5\n") @@ -1419,7 +1455,7 @@ func TestWorkflow_TestExactMutationIDMatch(t *testing.T) { return report.Diff != nil && string(*report.Diff) == string(diffCode) })).Return(nil) - wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen) + wf := domain.NewWorkflow(mockFSAdapter, mockReportStore, mockUI, mockOrchestrator, mockMutagen, mockMutationStreamer) // Act args := domain.TestArgs{ diff --git a/internal/model/mutation.go b/internal/model/mutation.go index 7e9b512..8c30a98 100644 --- a/internal/model/mutation.go +++ b/internal/model/mutation.go @@ -38,6 +38,7 @@ var ( type Mutation struct { // ID is the unique identifier for a mutation within a test run. ID string + Index uint64 Source Source Type MutationType MutatedCode []byte