diff --git a/internal/modules/neovim/neovim.go b/internal/modules/neovim/neovim.go index 64c26d1..c6721eb 100644 --- a/internal/modules/neovim/neovim.go +++ b/internal/modules/neovim/neovim.go @@ -6,7 +6,6 @@ package neovim import ( "bytes" "context" - "errors" "embed" "fmt" "os" @@ -463,6 +462,114 @@ func (m Module) Verify(ctx context.Context) error { return nil } +// PlanForce rewrites chadrc.lua and reinstalls packages but does NOT +// delete an existing NvChad clone, to preserve any user modifications. +// If NvChad is missing, it's cloned fresh. func (m Module) PlanForce(ctx context.Context) (module.Plan, error) { - return module.Plan{}, errors.New("PlanForce not implemented") + cfg, err := nvimCfg() + if err != nil { + return module.Plan{}, err + } + rendered, err := m.renderChadrc() + if err != nil { + return module.Plan{}, err + } + + var actions []module.Action + + if m.pm != nil { + actions = append(actions, module.Action{ + Description: "pacman -S (force) neovim", + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.pm.Reinstall(ctx, []string{"neovim"}) }, + }) + } + + // Clone NvChad only if absent; preserve any user modifications otherwise. + repo := m.cfg.NvChadStarterRepo + actions = append(actions, module.Action{ + Description: "ensure NvChad starter cloned (skip if present)", + Kind: module.ActionGitClone, + Exec: func(ctx context.Context) error { + if _, err := os.Stat(filepath.Join(cfg, "lua", "plugins", "init.lua")); err == nil { + return nil + } + if err := os.MkdirAll(filepath.Dir(cfg), 0o755); err != nil { + return err + } + if err := m.git.Clone(ctx, repo, cfg); err != nil { + return err + } + return os.RemoveAll(filepath.Join(cfg, ".git")) + }, + }) + + actions = append(actions, module.Action{ + Description: "strip nvim-lspconfig block from starter init.lua (force)", + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + p := filepath.Join(cfg, "lua", "plugins", "init.lua") + data, err := os.ReadFile(p) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + return platform.WriteUserFile(p, stripLspconfigBlock(data), 0o644) + }, + }) + + actions = append(actions, module.Action{ + Description: "remove starter lspconfig.lua (force)", + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + err := os.Remove(filepath.Join(cfg, "lua", "configs", "lspconfig.lua")) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil + }, + }) + + renderedCopy := append([]byte{}, rendered...) + themeName := m.resolvedTheme() + actions = append(actions, module.Action{ + Description: "rewrite chadrc.lua with theme " + themeName + " (force)", + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + if err := os.MkdirAll(filepath.Join(cfg, "lua"), 0o755); err != nil { + return err + } + return platform.WriteUserFile(filepath.Join(cfg, "lua", "chadrc.lua"), renderedCopy, 0o644) + }, + }) + + for _, a := range assetMap { + a := a + actions = append(actions, module.Action{ + Description: "rewrite asset " + a.DstRel + " (force)", + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + data, err := assets.ReadFile(a.SrcPath) + if err != nil { + return err + } + return platform.WriteUserFile(filepath.Join(cfg, a.DstRel), data, 0o644) + }, + }) + } + + actions = append(actions, module.Action{ + Description: "clear NvChad base46 theme cache (force)", + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + home, _ := os.UserHomeDir() + _ = os.RemoveAll(filepath.Join(home, ".local", "share", "nvim", "lazy", "base46", "lua", "base46", "themes")) + _ = os.RemoveAll(filepath.Join(home, ".cache", "nvim", "base46")) + return nil + }, + }) + + return module.Plan{Actions: actions}, nil } diff --git a/internal/modules/neovim/neovim_test.go b/internal/modules/neovim/neovim_test.go index f1268a4..801c268 100644 --- a/internal/modules/neovim/neovim_test.go +++ b/internal/modules/neovim/neovim_test.go @@ -207,4 +207,93 @@ func TestWriteChadrcNoopWithoutConfigDir(t *testing.T) { } } +func TestPlanForceReinstallsAndRewrites(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + // Pre-populate an "installed" NvChad clone with a local user tweak so we + // can verify PlanForce does NOT wipe the clone. + cfg := filepath.Join(home, ".config", "nvim") + os.MkdirAll(filepath.Join(cfg, "lua", "plugins"), 0o755) + os.WriteFile(filepath.Join(cfg, "lua", "plugins", "init.lua"), + []byte("return {\n \"other\",\n},\n"), 0o644) + userTweak := filepath.Join(cfg, "lua", "user_tweak.lua") + os.WriteFile(userTweak, []byte("-- my custom stuff"), 0o644) + + r := &fakeRunner{installed: map[string]bool{"neovim": true}} + m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "dracula", platform.NewPacman(r), platform.GitClient{Runner: r}) + + plan, err := m.PlanForce(context.Background()) + if err != nil { + t.Fatalf("PlanForce: %v", err) + } + if plan.IsEmpty() { + t.Fatal("force plan must not be empty") + } + for _, a := range plan.Actions { + if err := a.Exec(context.Background()); err != nil { + t.Fatalf("Exec %q: %v", a.Description, err) + } + } + + // User tweak must survive. + if _, err := os.Stat(userTweak); err != nil { + t.Fatalf("PlanForce wiped user NvChad modification: %v", err) + } + // No `git clone` call because starter init.lua was already present. + for _, c := range r.runCalls { + if c[0] == "git" && len(c) >= 2 && c[1] == "clone" { + t.Fatalf("PlanForce re-cloned existing NvChad: %v", c) + } + } + // pacman reinstall must have fired. + sawReinstall := false + for _, c := range r.runCalls { + if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" && c[3] == "--noconfirm" { + sawReinstall = true + } + } + if !sawReinstall { + t.Fatalf("no pacman reinstall call; calls=%v", r.runCalls) + } + // chadrc.lua was rewritten with the requested theme. + chadrc, err := os.ReadFile(filepath.Join(cfg, "lua", "chadrc.lua")) + if err != nil { + t.Fatalf("chadrc missing after force: %v", err) + } + if !strings.Contains(string(chadrc), `theme = "dracula"`) { + t.Fatalf("chadrc theme not applied: %s", chadrc) + } + // assets rewritten + for _, name := range []string{"arduino.lua", "dap.lua"} { + if _, err := os.Stat(filepath.Join(cfg, "lua", "plugins", name)); err != nil { + t.Errorf("asset %s missing after force: %v", name, err) + } + } +} + +func TestPlanForceClonesIfMissing(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + r := &fakeRunner{installed: map[string]bool{"neovim": true}} + m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", platform.NewPacman(r), platform.GitClient{Runner: r}) + plan, err := m.PlanForce(context.Background()) + if err != nil { + t.Fatalf("PlanForce: %v", err) + } + for _, a := range plan.Actions { + if err := a.Exec(context.Background()); err != nil { + t.Fatalf("Exec %q: %v", a.Description, err) + } + } + sawClone := false + for _, c := range r.runCalls { + if c[0] == "git" && len(c) >= 2 && c[1] == "clone" { + sawClone = true + } + } + if !sawClone { + t.Fatalf("expected git clone when NvChad absent; calls=%v", r.runCalls) + } +} + var _ module.Module = Module{}