diff --git a/docs/architecture/architecture-overview.md b/docs/architecture/architecture-overview.md index 4b20c3b..d8e0c33 100644 --- a/docs/architecture/architecture-overview.md +++ b/docs/architecture/architecture-overview.md @@ -208,6 +208,14 @@ Plan actions: - run - warn +## Planning inspections + +Planning resources share Homebrew inventories and asdf version/plugin inventory +checks within one resource build. Each new plan builds a fresh resource list; +there is no persistent cache. Per-plugin asdf version checks remain live. +Apply and upgrade use uncached resources, and Homebrew update rechecks target +the selected package rather than scanning the entire installed inventory. + ## Error philosophy Errors should include: diff --git a/internal/cli/upgrade_test.go b/internal/cli/upgrade_test.go index d50e7b8..307ddf3 100644 --- a/internal/cli/upgrade_test.go +++ b/internal/cli/upgrade_test.go @@ -128,10 +128,10 @@ func TestUpgradeAppliesManagedHomebrewUpgrades(t *testing.T) { }} upgradeRunner := &fakeApplyRunner{responses: []fakeApplyResponse{ {result: applyCommandResult("brew", []string{"list", "--formula", "git"}, 0)}, - {result: applyResultWithStdout("brew", []string{"outdated", "--formula", "--quiet"}, "git\n")}, + {result: applyResultWithStdout("brew", []string{"outdated", "--formula", "--quiet", "git"}, "git\n")}, {result: applyCommandResult("brew", []string{"upgrade", "git"}, 0)}, {result: applyCommandResult("brew", []string{"list", "--cask", "ghostty"}, 0)}, - {result: applyResultWithStdout("brew", []string{"outdated", "--cask", "--quiet"}, "ghostty\n")}, + {result: applyResultWithStdout("brew", []string{"outdated", "--cask", "--quiet", "ghostty"}, "ghostty\n")}, {result: applyCommandResult("brew", []string{"upgrade", "--cask", "ghostty"}, 0)}, }} configPath := writeCLIConfigFile(t, `version: 1 @@ -181,14 +181,61 @@ brew: }) expectApplyRunnerCalls(t, upgradeRunner.calls, []applyCommandCall{ {name: "brew", args: []string{"list", "--formula", "git"}}, - {name: "brew", args: []string{"outdated", "--formula", "--quiet"}}, + {name: "brew", args: []string{"outdated", "--formula", "--quiet", "git"}}, {name: "brew", args: []string{"upgrade", "git"}}, {name: "brew", args: []string{"list", "--cask", "ghostty"}}, - {name: "brew", args: []string{"outdated", "--cask", "--quiet"}}, + {name: "brew", args: []string{"outdated", "--cask", "--quiet", "ghostty"}}, {name: "brew", args: []string{"upgrade", "--cask", "ghostty"}}, }) } +func TestUpgradeRechecksEachPlannedTargetBeforeMutation(t *testing.T) { + for _, typ := range []string{"formula", "cask"} { + for _, fail := range []bool{false, true} { + name := typ + "/current" + if fail { + name = typ + "/inspection-failure" + } + t.Run(name, func(t *testing.T) { + section := "packages" + if typ == "cask" { + section = "casks" + } + configPath := writeCLIConfigFile(t, "version: 1\nbrew:\n "+section+": [example]\n") + planRunner := &fakeApplyRunner{responses: []fakeApplyResponse{ + {result: applyResultWithStdout("brew", nil, "example\n")}, + {result: applyResultWithStdout("brew", nil, "example\n")}, + }} + fresh := fakeApplyResponse{result: applyResultWithStdout("brew", nil, "")} + if fail { + fresh.err = applyCommandError("brew", []string{"outdated", "--" + typ, "--quiet", "example"}, 1) + } + upgradeRunner := &fakeApplyRunner{responses: []fakeApplyResponse{ + {result: applyCommandResult("brew", nil, 0)}, + fresh, + }} + var stdout, stderr bytes.Buffer + code := runWithCLIExecRunners(t, []string{"upgrade", "--config", configPath, "--json"}, nil, &stdout, &stderr, planRunner, upgradeRunner) + wantCode, wantAction := exitOK, "noop" + if fail { + wantCode, wantAction = exitApplyFailure, "fail" + } + if code != wantCode { + t.Fatalf("exit = %d, want %d; stdout: %s; stderr: %s", code, wantCode, stdout.String(), stderr.String()) + } + report := decodeStatusJSON(t, stdout.String()).Upgrade + if report == nil || len(report.Items) != 1 || report.Items[0].Action != wantAction || report.Summary.Changed != 0 { + t.Fatalf("report = %+v, want %s without mutation", report, wantAction) + } + expectApplyRunnerCalls(t, upgradeRunner.calls, []applyCommandCall{ + {name: "brew", args: []string{"list", "--" + typ, "example"}}, + {name: "brew", args: []string{"outdated", "--" + typ, "--quiet", "example"}}, + }) + }) + } + } +} + func TestUpgradeOnlyBrewSkipsCaskChecks(t *testing.T) { runner := &fakeApplyRunner{responses: []fakeApplyResponse{ {result: applyResultWithStdout("brew", []string{"list", "--formula", "--quiet"}, "git\n")}, @@ -323,7 +370,7 @@ func TestUpgradeJSONReportsUpgradeResults(t *testing.T) { }} upgradeRunner := &fakeApplyRunner{responses: []fakeApplyResponse{ {result: applyCommandResult("brew", []string{"list", "--formula", "git"}, 0)}, - {result: applyResultWithStdout("brew", []string{"outdated", "--formula", "--quiet"}, "git\n")}, + {result: applyResultWithStdout("brew", []string{"outdated", "--formula", "--quiet", "git"}, "git\n")}, {result: applyCommandResult("brew", []string{"upgrade", "git"}, 0)}, }} configPath := writeCLIConfigFile(t, `version: 1 diff --git a/internal/resources/asdf.go b/internal/resources/asdf.go index a848635..be1963f 100644 --- a/internal/resources/asdf.go +++ b/internal/resources/asdf.go @@ -32,6 +32,7 @@ type ASDFPluginResource struct { updateBeforeInstall bool versions []string runner platform.Runner + planningRunner platform.Runner } var _ engine.Resource = ASDFPluginResource{} @@ -69,6 +70,9 @@ func (resource ASDFPluginResource) Status(ctx context.Context) (engine.StatusRes if err := resource.validate(); err != nil { return resource.status(engine.StateFailed, err.Error()), err } + if resource.planningRunner != nil { + resource.runner = resource.planningRunner + } if err := resource.checkASDF(ctx); err != nil { return resource.status(engine.StateFailed, "asdf is required before checking plugins"), err } diff --git a/internal/resources/asdf_inventory.go b/internal/resources/asdf_inventory.go new file mode 100644 index 0000000..f004030 --- /dev/null +++ b/internal/resources/asdf_inventory.go @@ -0,0 +1,58 @@ +package resources + +import ( + "context" + "errors" + "sync" + + "github.com/vwall/kitout/internal/platform" +) + +// asdfPlanningInventory shares read-only prerequisites across one planning build. +// Per-plugin version queries and all apply commands use the underlying live runner. +type asdfPlanningInventory struct { + runner platform.Runner + mu sync.Mutex + version asdfInventoryResult + plugins asdfInventoryResult +} + +type asdfInventoryResult struct { + loaded bool + result platform.CommandResult + err error +} + +func newASDFPlanningInventory(runner platform.Runner) *asdfPlanningInventory { + return &asdfPlanningInventory{runner: runner} +} + +func (inventory *asdfPlanningInventory) Run(ctx context.Context, name string, args ...string) (platform.CommandResult, error) { + if err := ctx.Err(); err != nil { + return platform.CommandResult{}, err + } + var cached *asdfInventoryResult + if name == "asdf" { + if len(args) == 1 && args[0] == "--version" { + cached = &inventory.version + } else if len(args) == 3 && args[0] == "plugin" && args[1] == "list" && args[2] == "--urls" { + cached = &inventory.plugins + } + } + if cached == nil { + return inventory.runner.Run(ctx, name, args...) + } + inventory.mu.Lock() + defer inventory.mu.Unlock() + if err := ctx.Err(); err != nil { + return platform.CommandResult{}, err + } + if cached.loaded { + return cached.result, cached.err + } + result, err := inventory.runner.Run(ctx, name, args...) + if ctx.Err() == nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + *cached = asdfInventoryResult{loaded: true, result: result, err: err} + } + return result, err +} diff --git a/internal/resources/asdf_test.go b/internal/resources/asdf_test.go index a9d7183..31995b2 100644 --- a/internal/resources/asdf_test.go +++ b/internal/resources/asdf_test.go @@ -2,6 +2,7 @@ package resources import ( "context" + "errors" "os" "path/filepath" "testing" @@ -423,3 +424,89 @@ func writeToolVersions(t *testing.T, contents string) string { } return path } + +func TestASDFPlanningInventoryApplyReadsFreshState(t *testing.T) { + runner := &fakeRunner{responses: []fakeResponse{ + {}, {result: resultWithStdout("asdf", nil, "ruby https://example.com/ruby.git\n")}, {result: resultWithStdout("asdf", nil, "1.0\n")}, + {}, {result: resultWithStdout("asdf", nil, "ruby https://example.com/changed.git\n")}, + }} + resource := NewASDFPlugin("ruby", "https://example.com/ruby.git", []string{"1.0"}, runner) + resource.planningRunner = newASDFPlanningInventory(runner) + status, err := resource.Status(context.Background()) + if err != nil || status.State != engine.StateSatisfied { + t.Fatalf("Status = %+v, %v", status, err) + } + result, err := resource.Apply(context.Background()) + if !containsError(err, "configured URL does not match installed URL") || result.Changed { + t.Fatalf("Apply = %+v, %v; want fresh URL rejection", result, err) + } + if len(runner.calls) != 5 { + t.Fatalf("commands = %d, want 5", len(runner.calls)) + } +} + +func TestASDFPlanningInventorySharesErrorsAndHonorsCancellation(t *testing.T) { + for _, stage := range []string{"version", "plugins"} { + t.Run(stage, func(t *testing.T) { + wantErr := errors.New("inventory failed") + responses := []fakeResponse{{err: wantErr}} + if stage == "plugins" { + responses = append([]fakeResponse{{}}, responses...) + } + runner := &fakeRunner{responses: responses} + resource := NewASDFPlugin("ruby", "https://example.com/ruby.git", nil, runner) + resource.planningRunner = newASDFPlanningInventory(runner) + for i := 0; i < 2; i++ { + status, err := resource.Status(context.Background()) + if !errors.Is(err, wantErr) || status.State != engine.StateFailed { + t.Fatalf("Status = %+v, %v", status, err) + } + } + if len(runner.calls) != len(responses) { + t.Fatalf("commands = %d, want %d", len(runner.calls), len(responses)) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := resource.Status(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled Status error = %v", err) + } + if len(runner.calls) != len(responses) { + t.Fatal("canceled status executed commands") + } + }) + } +} + +func TestASDFPlanningInventoryDoesNotCacheCancellation(t *testing.T) { + runner := &fakeRunner{responses: []fakeResponse{{err: context.Canceled}, {}, {}}} + inventory := newASDFPlanningInventory(runner) + if _, err := inventory.Run(context.Background(), "asdf", "--version"); !errors.Is(err, context.Canceled) { + t.Fatalf("first call error = %v", err) + } + if _, err := inventory.Run(context.Background(), "asdf", "--version"); err != nil { + t.Fatal(err) + } + fresh := newASDFPlanningInventory(runner) + if _, err := fresh.Run(context.Background(), "asdf", "--version"); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 3 { + t.Fatalf("commands = %d, want retry and fresh inventory query", len(runner.calls)) + } +} + +func TestASDFPublicConstructorStatusRemainsLive(t *testing.T) { + runner := &fakeRunner{responses: []fakeResponse{{}, {}, {}, {result: resultWithStdout("asdf", nil, "ruby https://example.com/ruby.git\n")}, {result: resultWithStdout("asdf", nil, "1.0\n")}}} + resource := NewASDFPlugin("ruby", "https://example.com/ruby.git", []string{"1.0"}, runner) + first, err := resource.Status(context.Background()) + if err != nil || first.State != engine.StateMissing { + t.Fatalf("first Status = %+v, %v", first, err) + } + second, err := resource.Status(context.Background()) + if err != nil || second.State != engine.StateSatisfied { + t.Fatalf("second Status = %+v, %v", second, err) + } + if len(runner.calls) != 5 { + t.Fatalf("commands = %d, want 5", len(runner.calls)) + } +} diff --git a/internal/resources/brew.go b/internal/resources/brew.go index f2b43d8..e88e7b2 100644 --- a/internal/resources/brew.go +++ b/internal/resources/brew.go @@ -294,11 +294,10 @@ func newDirectBrewOutdatedChecker(runner platform.Runner) directBrewOutdatedChec func (checker directBrewOutdatedChecker) Contains(ctx context.Context, name string) (bool, error) { result, err := checker.runner.Run(ctx, "brew", "outdated", "--formula", "--quiet", name) - if strings.TrimSpace(result.Stdout) != "" { - return true, nil - } - if err == nil || isExitCode(err, 1) { - return false, nil + outdated := strings.TrimSpace(result.Stdout) != "" + // A targeted query exits 1 for outdated packages, but failures also exit 1. + if err != nil && !(isExitCode(err, 1) && outdated) { + return false, err } - return false, err + return outdated, nil } diff --git a/internal/resources/builder.go b/internal/resources/builder.go index b496d64..06fc3ee 100644 --- a/internal/resources/builder.go +++ b/internal/resources/builder.go @@ -10,6 +10,7 @@ import ( ) // Build converts a validated config into executable resources in stable order. +// Call it for each new plan: shared inventory snapshots belong to this build. func Build(cfg config.Config, runner platform.Runner) []engine.Resource { return build(cfg, runner, true) } @@ -20,14 +21,15 @@ func BuildUncached(cfg config.Config, runner platform.Runner) []engine.Resource return build(cfg, runner, false) } -func build(cfg config.Config, runner platform.Runner, batchHomebrew bool) []engine.Resource { +func build(cfg config.Config, runner platform.Runner, batchPlanning bool) []engine.Resource { resources := make([]engine.Resource, 0, resourceCount(cfg)) brewOutdated := newBrewOutdatedCache(runner) caskOutdated := newCaskOutdatedCache(runner) + asdfInventory := newASDFPlanningInventory(runner) var brewTapInstalled brewTapInstalledChecker = newDirectBrewTapInstalledChecker(runner) var brewInstalled brewInstalledChecker = newDirectBrewInstalledChecker(runner) var caskInstalled caskInstalledChecker = newDirectCaskInstalledChecker(runner) - if batchHomebrew { + if batchPlanning { brewTapInstalled = newBrewTapInstalledCache(runner) brewInstalled = newBrewInstalledCache(runner) caskInstalled = newCaskInstalledCache(runner) @@ -48,8 +50,8 @@ func build(cfg config.Config, runner platform.Runner, batchHomebrew bool) []engi for _, name := range cfg.Brew.Packages { installed := brewInstalled var outdated brewOutdatedChecker = brewOutdated - if !batchHomebrew { - outdated = newBrewOutdatedCache(runner) + if !batchPlanning { + outdated = newDirectBrewOutdatedChecker(runner) } if isFullyQualifiedBrewFormula(name) { installed = newDirectBrewInstalledChecker(runner) @@ -58,21 +60,25 @@ func build(cfg config.Config, runner platform.Runner, batchHomebrew bool) []engi resources = append(resources, newBrewPackage(name, runner, installed, outdated)) } for _, plugin := range cfg.ASDF.Plugins { - resources = append(resources, NewASDFPluginWithOptions( + resource := NewASDFPluginWithOptions( plugin.Name, plugin.URL, plugin.Versions, ASDFPluginOptions{UpdateBeforeInstall: plugin.UpdateBeforeInstall}, runner, - )) + ) + if batchPlanning { + resource.planningRunner = asdfInventory + } + resources = append(resources, resource) } for _, item := range cfg.ASDF.ToolVersions { resources = append(resources, NewASDFToolVersions(item.Path, item.Tools)) } for _, name := range cfg.Brew.Casks { - outdated := caskOutdated - if !batchHomebrew { - outdated = newCaskOutdatedCache(runner) + var outdated caskOutdatedChecker = caskOutdated + if !batchPlanning { + outdated = newDirectCaskOutdatedChecker(runner) } resources = append(resources, newCask(name, runner, caskInstalled, outdated)) } diff --git a/internal/resources/cask.go b/internal/resources/cask.go index bf82817..42e5b2e 100644 --- a/internal/resources/cask.go +++ b/internal/resources/cask.go @@ -266,3 +266,21 @@ func (cache *caskOutdatedCache) load(ctx context.Context) { cache.loadErr = err } } + +type directCaskOutdatedChecker struct { + runner platform.Runner +} + +func newDirectCaskOutdatedChecker(runner platform.Runner) directCaskOutdatedChecker { + return directCaskOutdatedChecker{runner: runner} +} + +func (checker directCaskOutdatedChecker) Contains(ctx context.Context, name string) (bool, error) { + result, err := checker.runner.Run(ctx, "brew", "outdated", "--cask", "--quiet", name) + outdated := strings.TrimSpace(result.Stdout) != "" + // A targeted query exits 1 for outdated packages, but failures also exit 1. + if err != nil && !(isExitCode(err, 1) && outdated) { + return false, err + } + return outdated, nil +} diff --git a/internal/resources/command_resource_test.go b/internal/resources/command_resource_test.go index 4b7c1a6..f29f9d5 100644 --- a/internal/resources/command_resource_test.go +++ b/internal/resources/command_resource_test.go @@ -581,7 +581,7 @@ func TestBrewPackageDryRunChecksFullyQualifiedBuiltFormulaOutdatedDirectly(t *te func TestBrewPackageUncachedBuildUsesDirectInstalledChecks(t *testing.T) { runner := &fakeRunner{responses: []fakeResponse{ {result: commandResult("brew", []string{"list", "--formula", "git"}, 0)}, - {result: commandResult("brew", []string{"outdated", "--formula", "--quiet"}, 0)}, + {result: commandResult("brew", []string{"outdated", "--formula", "--quiet", "git"}, 0)}, {err: commandError("brew", []string{"list", "--formula", "go"}, 1)}, }} resources := BuildUncached(config.Config{ @@ -601,7 +601,7 @@ func TestBrewPackageUncachedBuildUsesDirectInstalledChecks(t *testing.T) { } expectCalls(t, runner.calls, []commandCall{ {name: "brew", args: []string{"list", "--formula", "git"}}, - {name: "brew", args: []string{"outdated", "--formula", "--quiet"}}, + {name: "brew", args: []string{"outdated", "--formula", "--quiet", "git"}}, {name: "brew", args: []string{"list", "--formula", "go"}}, }) } @@ -609,9 +609,9 @@ func TestBrewPackageUncachedBuildUsesDirectInstalledChecks(t *testing.T) { func TestBrewPackageUncachedBuildDoesNotShareOutdatedChecks(t *testing.T) { runner := &fakeRunner{responses: []fakeResponse{ {result: commandResult("brew", []string{"list", "--formula", "git"}, 0)}, - {result: commandResult("brew", []string{"outdated", "--formula", "--quiet"}, 0)}, + {result: commandResult("brew", []string{"outdated", "--formula", "--quiet", "git"}, 0)}, {result: commandResult("brew", []string{"list", "--formula", "go"}, 0)}, - {result: resultWithStdout("brew", []string{"outdated", "--formula", "--quiet"}, "go\n")}, + {result: resultWithStdout("brew", []string{"outdated", "--formula", "--quiet", "go"}, "go\n")}, }} resources := BuildUncached(config.Config{ Version: config.CurrentVersion, @@ -631,9 +631,9 @@ func TestBrewPackageUncachedBuildDoesNotShareOutdatedChecks(t *testing.T) { expectAdvisory(t, plan.Items[1].Advisories, "homebrew_formula_outdated", "formula update available for go", "brew upgrade go") expectCalls(t, runner.calls, []commandCall{ {name: "brew", args: []string{"list", "--formula", "git"}}, - {name: "brew", args: []string{"outdated", "--formula", "--quiet"}}, + {name: "brew", args: []string{"outdated", "--formula", "--quiet", "git"}}, {name: "brew", args: []string{"list", "--formula", "go"}}, - {name: "brew", args: []string{"outdated", "--formula", "--quiet"}}, + {name: "brew", args: []string{"outdated", "--formula", "--quiet", "go"}}, }) } @@ -843,7 +843,7 @@ func TestCaskDryRunBatchesInstalledCheckForBuiltResources(t *testing.T) { func TestCaskUncachedBuildUsesDirectInstalledChecks(t *testing.T) { runner := &fakeRunner{responses: []fakeResponse{ {result: commandResult("brew", []string{"list", "--cask", "ghostty"}, 0)}, - {result: commandResult("brew", []string{"outdated", "--cask", "--quiet"}, 0)}, + {result: commandResult("brew", []string{"outdated", "--cask", "--quiet", "ghostty"}, 0)}, {err: commandError("brew", []string{"list", "--cask", "rectangle"}, 1)}, }} resources := BuildUncached(config.Config{ @@ -861,7 +861,7 @@ func TestCaskUncachedBuildUsesDirectInstalledChecks(t *testing.T) { } expectCalls(t, runner.calls, []commandCall{ {name: "brew", args: []string{"list", "--cask", "ghostty"}}, - {name: "brew", args: []string{"outdated", "--cask", "--quiet"}}, + {name: "brew", args: []string{"outdated", "--cask", "--quiet", "ghostty"}}, {name: "brew", args: []string{"list", "--cask", "rectangle"}}, }) } diff --git a/internal/resources/homebrew_targeted_test.go b/internal/resources/homebrew_targeted_test.go new file mode 100644 index 0000000..5118fb6 --- /dev/null +++ b/internal/resources/homebrew_targeted_test.go @@ -0,0 +1,70 @@ +package resources + +import ( + "context" + "testing" + + "github.com/vwall/kitout/internal/platform" +) + +func TestDirectHomebrewOutdatedChecksAreTargetedAndFresh(t *testing.T) { + for _, kind := range []string{"--formula", "--cask"} { + t.Run(kind, func(t *testing.T) { + runner := &fakeRunner{responses: []fakeResponse{ + {result: platform.CommandResult{Name: "brew", Stdout: "first\n"}}, + {}, + {result: platform.CommandResult{Name: "brew", Stdout: "second\n"}}, + }} + checker := targetedOutdatedChecker(kind, runner) + for i, test := range []struct { + name string + outdated bool + }{{"first", true}, {"first", false}, {"second", true}} { + got, err := checker.Contains(context.Background(), test.name) + if err != nil || got != test.outdated { + t.Fatalf("check %d = %v, %v; want %v", i, got, err, test.outdated) + } + } + expectCalls(t, runner.calls, []commandCall{ + {name: "brew", args: []string{"outdated", kind, "--quiet", "first"}}, + {name: "brew", args: []string{"outdated", kind, "--quiet", "first"}}, + {name: "brew", args: []string{"outdated", kind, "--quiet", "second"}}, + }) + }) + } +} + +func TestDirectHomebrewOutdatedPreservesErrorsWithOutput(t *testing.T) { + for _, kind := range []string{"--formula", "--cask"} { + for _, test := range []struct { + name, output string + err error + outdated, wantError bool + }{ + {name: "current"}, + {name: "outdated", output: "pkg\n", outdated: true}, + {name: "outdated exit one", output: "pkg\n", err: commandError("brew", nil, 1), outdated: true}, + {name: "empty exit one", err: commandError("brew", nil, 1), wantError: true}, + {name: "failure with partial output", output: "pkg\n", err: commandError("brew", nil, 2), wantError: true}, + {name: "canceled with partial output", output: "pkg\n", err: context.Canceled, wantError: true}, + } { + t.Run(kind+"/"+test.name, func(t *testing.T) { + runner := &fakeRunner{responses: []fakeResponse{{result: platform.CommandResult{Name: "brew", Stdout: test.output}, err: test.err}}} + got, err := targetedOutdatedChecker(kind, runner).Contains(context.Background(), "pkg") + if got != test.outdated || (err != nil) != test.wantError { + t.Fatalf("Contains = %v, %v; want %v, error=%v", got, err, test.outdated, test.wantError) + } + if test.wantError && err.Error() != test.err.Error() { + t.Fatalf("error = %v, want original %v", err, test.err) + } + }) + } + } +} + +func targetedOutdatedChecker(kind string, runner platform.Runner) brewOutdatedChecker { + if kind == "--cask" { + return newDirectCaskOutdatedChecker(runner) + } + return newDirectBrewOutdatedChecker(runner) +} diff --git a/internal/resources/inspection_planning_test.go b/internal/resources/inspection_planning_test.go new file mode 100644 index 0000000..6f3e496 --- /dev/null +++ b/internal/resources/inspection_planning_test.go @@ -0,0 +1,92 @@ +package resources + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/vwall/kitout/internal/config" + "github.com/vwall/kitout/internal/engine" + "github.com/vwall/kitout/internal/platform" +) + +type inspectionCountRunner struct { + calls int + commands []commandCall + inventory string +} + +func (runner *inspectionCountRunner) Run(ctx context.Context, name string, args ...string) (platform.CommandResult, error) { + runner.calls++ + runner.commands = append(runner.commands, commandCall{name: name, args: append([]string(nil), args...)}) + if err := ctx.Err(); err != nil { + return platform.CommandResult{}, err + } + result := platform.CommandResult{} + if name == "asdf" && strings.Join(args, " ") == "plugin list --urls" { + result.Stdout = runner.inventory + } + if name == "asdf" && len(args) == 2 && args[0] == "list" { + result.Stdout = "1.0\n" + } + return result, nil +} + +func TestBuildScopesASDFInventoryToPlanningOperation(t *testing.T) { + cfg := config.Config{Version: 1} + runner := &inspectionCountRunner{} + for i := 0; i < 30; i++ { + name := fmt.Sprintf("tool%d", i) + url := "https://example.com/" + name + cfg.ASDF.Plugins = append(cfg.ASDF.Plugins, config.ASDFPlugin{Name: name, URL: url, Versions: []string{"1.0"}}) + runner.inventory += name + " " + url + "\n" + } + for pass := 1; pass <= 2; pass++ { + plan := engine.NewPlanner().Build(context.Background(), Build(cfg, runner)) + if plan.Summary.Satisfied != 30 || runner.calls != pass*32 { + t.Fatalf("pass %d: summary=%+v calls=%d; want 30 satisfied and %d calls", pass, plan.Summary, runner.calls, pass*32) + } + } + runner.calls = 0 + plan := engine.NewPlanner().Build(context.Background(), BuildUncached(cfg, runner)) + if plan.Summary.Satisfied != 30 || runner.calls != 90 { + t.Fatalf("uncached: summary=%+v calls=%d; want 30 satisfied and 90 fresh calls", plan.Summary, runner.calls) + } +} + +func TestBuildUncachedHomebrewUpgradeUsesOnlyTargetedInspections(t *testing.T) { + for _, kind := range []string{"--formula", "--cask"} { + t.Run(kind, func(t *testing.T) { + cfg := config.Config{Version: 1} + var names []string + for i := 0; i < 30; i++ { + names = append(names, fmt.Sprintf("package%d", i)) + } + if kind == "--formula" { + cfg.Brew.Packages = names + } else { + cfg.Brew.Casks = names + } + runner := &inspectionCountRunner{} + for _, resource := range BuildUncached(cfg, runner) { + upgrader := resource.(interface { + Upgrade(context.Context) (engine.ApplyResult, error) + }) + result, err := upgrader.Upgrade(context.Background()) + if err != nil || result.Changed || result.Action != "noop" { + t.Fatalf("Upgrade = %+v, %v; want unchanged current package", result, err) + } + } + if runner.calls != 60 { + t.Fatalf("commands = %d, want 60 fresh targeted inspections", runner.calls) + } + for i, name := range names { + expectCalls(t, runner.commands[i*2:i*2+2], []commandCall{ + {name: "brew", args: []string{"list", kind, name}}, + {name: "brew", args: []string{"outdated", kind, "--quiet", name}}, + }) + } + }) + } +}