diff --git a/generic_manager.go b/generic_manager.go index 5303d9e..0ef61b2 100644 --- a/generic_manager.go +++ b/generic_manager.go @@ -32,43 +32,51 @@ func (m *GenericManager) Warnings() []string { return m.warnings } -func (m *GenericManager) Init(ctx context.Context) (*Result, error) { - input := CommandInput{ - Args: map[string]string{}, - Flags: map[string]any{}, - } - - cmd, err := m.translator.BuildCommand(m.def.Name, "init", input) +// run builds the command chain for an operation (base command plus any +// then: entries) and executes them in order. It returns the first +// command's result with subsequent results attached in Then. Execution +// stops after the first non-zero exit; Success() on the returned result +// reflects the whole chain. +func (m *GenericManager) run(ctx context.Context, operation string, input CommandInput) (*Result, error) { + cmds, err := m.translator.BuildCommands(m.def.Name, operation, input) if err != nil { return nil, err } + var first *Result + for i, cmd := range cmds { + res, err := m.runner.Run(ctx, m.dir, cmd...) + if i == 0 { + first = res + } else if first != nil && res != nil { + first.Then = append(first.Then, res) + } + if err != nil { + return first, err + } + if res == nil || res.ExitCode != 0 { + return first, nil + } + } + return first, nil +} - return m.runner.Run(ctx, m.dir, cmd...) +func (m *GenericManager) Init(ctx context.Context) (*Result, error) { + return m.run(ctx, "init", CommandInput{}) } func (m *GenericManager) Install(ctx context.Context, opts InstallOptions) (*Result, error) { - input := CommandInput{ - Args: map[string]string{}, + return m.run(ctx, "install", CommandInput{ Flags: map[string]any{ "frozen": opts.Frozen, "clean": opts.Clean, "production": opts.Production, }, - } - - cmd, err := m.translator.BuildCommand(m.def.Name, "install", input) - if err != nil { - return nil, err - } - - return m.runner.Run(ctx, m.dir, cmd...) + }) } func (m *GenericManager) Add(ctx context.Context, pkg string, opts AddOptions) (*Result, error) { input := CommandInput{ - Args: map[string]string{ - argPackage: pkg, - }, + Args: map[string]string{argPackage: pkg}, Flags: map[string]any{ "dev": opts.Dev, "optional": opts.Optional, @@ -76,79 +84,30 @@ func (m *GenericManager) Add(ctx context.Context, pkg string, opts AddOptions) ( "workspace": opts.Workspace, }, } - if opts.Version != "" { input.Args["version"] = opts.Version } - - cmd, err := m.translator.BuildCommand(m.def.Name, "add", input) - if err != nil { - return nil, err - } - - return m.runner.Run(ctx, m.dir, cmd...) + return m.run(ctx, "add", input) } func (m *GenericManager) Remove(ctx context.Context, pkg string) (*Result, error) { - input := CommandInput{ - Args: map[string]string{ - argPackage: pkg, - }, - Flags: map[string]any{}, - } - - cmd, err := m.translator.BuildCommand(m.def.Name, "remove", input) - if err != nil { - return nil, err - } - - return m.runner.Run(ctx, m.dir, cmd...) + return m.run(ctx, "remove", CommandInput{Args: map[string]string{argPackage: pkg}}) } func (m *GenericManager) List(ctx context.Context) (*Result, error) { - input := CommandInput{ - Args: map[string]string{}, - Flags: map[string]any{}, - } - - cmd, err := m.translator.BuildCommand(m.def.Name, "list", input) - if err != nil { - return nil, err - } - - return m.runner.Run(ctx, m.dir, cmd...) + return m.run(ctx, "list", CommandInput{}) } func (m *GenericManager) Outdated(ctx context.Context) (*Result, error) { - input := CommandInput{ - Args: map[string]string{}, - Flags: map[string]any{}, - } - - cmd, err := m.translator.BuildCommand(m.def.Name, "outdated", input) - if err != nil { - return nil, err - } - - return m.runner.Run(ctx, m.dir, cmd...) + return m.run(ctx, "outdated", CommandInput{}) } func (m *GenericManager) Update(ctx context.Context, pkg string) (*Result, error) { - input := CommandInput{ - Args: map[string]string{}, - Flags: map[string]any{}, - } - + input := CommandInput{} if pkg != "" { - input.Args[argPackage] = pkg + input.Args = map[string]string{argPackage: pkg} } - - cmd, err := m.translator.BuildCommand(m.def.Name, "update", input) - if err != nil { - return nil, err - } - - return m.runner.Run(ctx, m.dir, cmd...) + return m.run(ctx, "update", input) } func (m *GenericManager) Supports(cap Capability) bool { @@ -172,31 +131,11 @@ func (m *GenericManager) Capabilities() []Capability { } func (m *GenericManager) Vendor(ctx context.Context) (*Result, error) { - input := CommandInput{ - Args: map[string]string{}, - Flags: map[string]any{}, - } - - cmd, err := m.translator.BuildCommand(m.def.Name, "vendor", input) - if err != nil { - return nil, err - } - - return m.runner.Run(ctx, m.dir, cmd...) + return m.run(ctx, "vendor", CommandInput{}) } func (m *GenericManager) Resolve(ctx context.Context) (*Result, error) { - input := CommandInput{ - Args: map[string]string{}, - Flags: map[string]any{}, - } - - cmd, err := m.translator.BuildCommand(m.def.Name, "resolve", input) - if err != nil { - return nil, err - } - - return m.runner.Run(ctx, m.dir, cmd...) + return m.run(ctx, "resolve", CommandInput{}) } func (m *GenericManager) Path(ctx context.Context, pkg string) (*PathResult, error) { diff --git a/generic_manager_test.go b/generic_manager_test.go index e5b5241..1639697 100644 --- a/generic_manager_test.go +++ b/generic_manager_test.go @@ -61,6 +61,151 @@ func TestGenericManager_Add_CargoVersion(t *testing.T) { } } +func TestGenericManager_Add_RunsThenChain(t *testing.T) { + runner := NewMockRunner() + mgr := newTestManager(embeddedDef(t, "gomod"), runner) + res, err := mgr.Add(context.Background(), "github.com/pkg/errors", AddOptions{}) + if err != nil { + t.Fatalf("Add: %v", err) + } + if len(runner.Captured) != 2 { + t.Fatalf("captured %d commands, want 2 (go get + go mod tidy)", len(runner.Captured)) + } + if !slicesEqual(runner.Captured[0], []string{"go", "get", "github.com/pkg/errors"}) { + t.Errorf("cmd[0] = %v", runner.Captured[0]) + } + if !slicesEqual(runner.Captured[1], []string{"go", "mod", "tidy"}) { + t.Errorf("cmd[1] = %v", runner.Captured[1]) + } + if !slicesEqual(res.Command, runner.Captured[0]) { + t.Errorf("res.Command = %v, want the primary go get", res.Command) + } + if len(res.Then) != 1 || !slicesEqual(res.Then[0].Command, runner.Captured[1]) { + t.Errorf("res.Then = %+v, want the go mod tidy result", res.Then) + } + if !res.Success() { + t.Error("Success() should be true when all chain commands exit zero") + } +} + +func TestGenericManager_Add_ChainNilFollowupError(t *testing.T) { + // A Runner that returns (nil, err) for the second call, as + // PolicyRunner does on a policy violation. + runner := NewMockRunner() + runner.Results = []*Result{{Command: []string{"go", "get", "x"}, ExitCode: 0}} + runner.Errors = []error{nil, errors.New("policy denied")} + mgr := newTestManager(embeddedDef(t, "gomod"), runner) + res, err := mgr.Add(context.Background(), "example.com/x", AddOptions{}) + if err == nil { + t.Fatal("expected error from follow-up") + } + if res == nil { + t.Fatal("expected first command's result") + } + for _, th := range res.Then { + if th == nil { + t.Fatal("Then must not contain nil entries") + } + } + // Success() must not panic; its value is not asserted since the + // failure is signalled via the returned error, not the Result. + _ = res.Success() +} + +func TestGenericManager_Add_ChainStopsOnFailure(t *testing.T) { + runner := NewMockRunner() + runner.Results = []*Result{ + {Command: []string{"go", "get", "x"}, ExitCode: 1, Stderr: "boom"}, + } + mgr := newTestManager(embeddedDef(t, "gomod"), runner) + res, err := mgr.Add(context.Background(), "example.com/x", AddOptions{}) + if err != nil { + t.Fatalf("Add: %v", err) + } + if res.Success() { + t.Error("Success() should be false when first command exits non-zero") + } + if len(runner.Captured) != 1 { + t.Errorf("captured %d commands, want 1 (chain should stop)", len(runner.Captured)) + } +} + +func TestGenericManager_Add_ChainThenFails(t *testing.T) { + runner := NewMockRunner() + runner.Results = []*Result{ + {Command: []string{"go", "get", "x"}, ExitCode: 0}, + {Command: []string{"go", "mod", "tidy"}, ExitCode: 1, Stderr: "tidy failed"}, + } + mgr := newTestManager(embeddedDef(t, "gomod"), runner) + res, err := mgr.Add(context.Background(), "example.com/x", AddOptions{}) + if err != nil { + t.Fatalf("Add: %v", err) + } + if res.Success() { + t.Error("Success() should be false when a then: command exits non-zero") + } + if !slicesEqual(res.Command, []string{"go", "get", "x"}) { + t.Errorf("res.Command = %v, want the primary command", res.Command) + } + if len(res.Then) != 1 { + t.Fatalf("res.Then = %d, want 1", len(res.Then)) + } + if res.Then[0].ExitCode != 1 || res.Then[0].Stderr != "tidy failed" { + t.Errorf("res.Then[0] = %+v", res.Then[0]) + } +} + +func TestGenericManager_Replace_RunsChain(t *testing.T) { + runner := NewMockRunner() + mgr := newTestManager(embeddedDef(t, "gomod"), runner) + res, err := mgr.Replace(context.Background(), "example.com/x", ReplaceOptions{Path: "../x"}) + if err != nil { + t.Fatalf("Replace: %v", err) + } + // gomod Replace runs `go mod edit -replace ...` then `go mod tidy` + // as two separate operations; both must be reachable via Then. + if len(runner.Captured) != 2 { + t.Fatalf("captured %d commands, want 2", len(runner.Captured)) + } + if !slicesEqual(runner.Captured[1], []string{"go", "mod", "tidy"}) { + t.Errorf("cmd[1] = %v, want go mod tidy", runner.Captured[1]) + } + if len(res.Then) != 1 || !slicesEqual(res.Then[0].Command, runner.Captured[1]) { + t.Errorf("res.Then = %+v, want the tidy result", res.Then) + } +} + +func TestGenericManager_Replace_RunsOperationThen(t *testing.T) { + // A synthetic definition where the replace operation itself has a + // then: entry, to prove Replace routes through the chain executor. + def := &definitions.Definition{ + Name: "gomod", + Binary: "go", + Commands: map[string]definitions.Command{ + "replace": { + Base: []string{"mod", "edit"}, + Args: map[string]definitions.Arg{"spec": {Flag: "-replace"}}, + Then: []definitions.Command{{Base: []string{"post"}}}, + }, + "tidy": {Base: []string{"mod", "tidy"}}, + }, + Capabilities: []string{"replace_path"}, + } + runner := NewMockRunner() + mgr := newTestManager(def, runner) + _, err := mgr.Replace(context.Background(), "example.com/x", ReplaceOptions{Path: "../x"}) + if err != nil { + t.Fatalf("Replace: %v", err) + } + // replace + its then: (post) + tidy = 3 + if len(runner.Captured) != 3 { + t.Fatalf("captured %d commands, want 3: %v", len(runner.Captured), runner.Captured) + } + if !slicesEqual(runner.Captured[1], []string{"go", "post"}) { + t.Errorf("then: command not run: %v", runner.Captured) + } +} + func TestGenericManager_Path_Raw(t *testing.T) { def := &definitions.Definition{ Name: "testpkg", diff --git a/manager.go b/manager.go index 6bf78e6..b46dc61 100644 --- a/manager.go +++ b/manager.go @@ -58,10 +58,24 @@ type Result struct { Duration time.Duration Cwd string Context ExecContext + // Then holds the results of any follow-up commands the operation + // ran after this one, in order. It is empty for single-command + // operations and when this command failed. + Then []*Result } +// Success reports whether this command and every follow-up in Then +// exited zero. func (r *Result) Success() bool { - return r.ExitCode == 0 + if r.ExitCode != 0 { + return false + } + for _, t := range r.Then { + if !t.Success() { + return false + } + } + return true } type PathResult struct { diff --git a/replace.go b/replace.go index 2356fc6..c199974 100644 --- a/replace.go +++ b/replace.go @@ -95,21 +95,22 @@ func (m *GenericManager) Replace(ctx context.Context, pkg string, opts ReplaceOp return nil, err } - var result *Result + var first *Result for _, in := range inputs { - cmd, err := m.translator.BuildCommand(m.def.Name, in.operation, in.input) - if err != nil { - return nil, err + res, err := m.run(ctx, in.operation, in.input) + if first == nil { + first = res + } else if res != nil { + first.Then = append(first.Then, res) } - result, err = m.runner.Run(ctx, m.dir, cmd...) if err != nil { - return result, err + return first, err } - if result.ExitCode != 0 { - return result, nil + if res != nil && !res.Success() { + return first, nil } } - return result, nil + return first, nil } type replaceInput struct {