diff --git a/internal/modules/neovim/assets/arduino.lua b/internal/modules/neovim/assets/arduino.lua new file mode 100644 index 0000000..616b62a --- /dev/null +++ b/internal/modules/neovim/assets/arduino.lua @@ -0,0 +1,10 @@ +return { + { + "stevearc/vim-arduino", + ft = { "arduino", "cpp", "c" }, + config = function() + vim.g.arduino_use_cli = 1 + vim.g.arduino_cli_path = "arduino-cli" + end, + }, +} diff --git a/internal/modules/neovim/assets/chadrc.lua.tmpl b/internal/modules/neovim/assets/chadrc.lua.tmpl new file mode 100644 index 0000000..12a3505 --- /dev/null +++ b/internal/modules/neovim/assets/chadrc.lua.tmpl @@ -0,0 +1,8 @@ +---@type ChadrcConfig +local M = {} + +M.base46 = { + theme = "{{.NvChadTheme}}", +} + +return M diff --git a/internal/modules/neovim/assets/clangd.lua b/internal/modules/neovim/assets/clangd.lua new file mode 100644 index 0000000..bb31868 --- /dev/null +++ b/internal/modules/neovim/assets/clangd.lua @@ -0,0 +1,10 @@ +return { + cmd = { + "clangd", + "--background-index", + "--clang-tidy", + "--header-insertion=iwyu", + "--completion-style=detailed", + }, + filetypes = { "c", "cpp", "objc", "objcpp", "cuda", "arduino" }, +} diff --git a/internal/modules/neovim/assets/dap.lua b/internal/modules/neovim/assets/dap.lua new file mode 100644 index 0000000..0bc9198 --- /dev/null +++ b/internal/modules/neovim/assets/dap.lua @@ -0,0 +1,57 @@ +return { + { + "mfussenegger/nvim-dap", + keys = { "d" }, + config = function() + local dap = require("dap") + + -- GDB remote adapter (connects to Renode's GDB server) + dap.adapters.gdb_remote = { + type = "executable", + command = vim.fn.executable("arm-none-eabi-gdb") == 1 + and "arm-none-eabi-gdb" + or "gdb-multiarch", + args = { "-i", "dap" }, + } + + -- Default debug configurations for Arduino/embedded + dap.configurations.c = { + { + name = "Attach to Renode (GDB :3333)", + type = "gdb_remote", + request = "attach", + target = "localhost:3333", + cwd = "${workspaceFolder}", + program = function() + return vim.fn.input("ELF path: ", vim.fn.getcwd() .. "/build/", "file") + end, + }, + } + dap.configurations.cpp = dap.configurations.c + dap.configurations.arduino = dap.configurations.c + + -- Breakpoint signs + vim.fn.sign_define("DapBreakpoint", { text = "●", texthl = "DiagnosticError" }) + vim.fn.sign_define("DapStopped", { text = "▶", texthl = "DiagnosticOk", linehl = "DiffAdd" }) + end, + }, + { + "rcarriga/nvim-dap-ui", + dependencies = { "mfussenegger/nvim-dap", "nvim-neotest/nvim-nio" }, + keys = { "du" }, + config = function() + local dapui = require("dapui") + dapui.setup() + + -- Auto open/close UI on debug sessions + local dap = require("dap") + dap.listeners.after.event_initialized["dapui_config"] = function() dapui.open() end + dap.listeners.before.event_terminated["dapui_config"] = function() dapui.close() end + dap.listeners.before.event_exited["dapui_config"] = function() dapui.close() end + end, + }, + { + "nvim-neotest/nvim-nio", + lazy = true, + }, +} diff --git a/internal/modules/neovim/assets/lspconfig.lua b/internal/modules/neovim/assets/lspconfig.lua new file mode 100644 index 0000000..cc9ace7 --- /dev/null +++ b/internal/modules/neovim/assets/lspconfig.lua @@ -0,0 +1,2 @@ +vim.lsp.enable("clangd") +return {} diff --git a/internal/modules/neovim/assets/mappings.lua b/internal/modules/neovim/assets/mappings.lua new file mode 100644 index 0000000..8df26d9 --- /dev/null +++ b/internal/modules/neovim/assets/mappings.lua @@ -0,0 +1,20 @@ +require "nvchad.mappings" + +local map = vim.keymap.set + +map("n", "ac", "ArduinoChooseBoard", { desc = "Arduino Choose Board" }) +map("n", "ap", "ArduinoChoosePort", { desc = "Arduino Choose Port" }) +map("n", "av", "ArduinoVerify", { desc = "Arduino Verify (compile)" }) +map("n", "au", "ArduinoUpload", { desc = "Arduino Upload" }) +map("n", "as", "ArduinoSerial", { desc = "Arduino Serial Monitor" }) +map("n", "ai", "ArduinoInfo", { desc = "Arduino Board Info" }) +map("n", "aw", "!wokwi-cli .", { desc = "Arduino Wokwi Simulate" }) + +-- Debug (DAP) +map("n", "ds", function() require("dap").continue() end, { desc = "Debug Start/Continue" }) +map("n", "db", function() require("dap").toggle_breakpoint() end, { desc = "Debug Toggle Breakpoint" }) +map("n", "dn", function() require("dap").step_over() end, { desc = "Debug Step Over" }) +map("n", "di", function() require("dap").step_into() end, { desc = "Debug Step Into" }) +map("n", "do", function() require("dap").step_out() end, { desc = "Debug Step Out" }) +map("n", "dx", function() require("dap").terminate() end, { desc = "Debug Terminate" }) +map("n", "du", function() require("dapui").toggle() end, { desc = "Debug UI Toggle" }) diff --git a/internal/modules/neovim/neovim.go b/internal/modules/neovim/neovim.go new file mode 100644 index 0000000..b2c7e27 --- /dev/null +++ b/internal/modules/neovim/neovim.go @@ -0,0 +1,422 @@ +// Package neovim installs Neovim via pacman, clones the NvChad starter, +// backs up any non-NvChad config, strips the starter's nvim-lspconfig +// block, and writes Archduino's Lua asset files. +package neovim + +import ( + "bytes" + "context" + "embed" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" + "time" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" + "github.com/MDietermann/ArduinoArchSetup/internal/tui" +) + +//go:embed assets/* +var assets embed.FS + +// assetMap lists each asset and its destination under ~/.config/nvim. +// Order matters: Detect iterates in this order and Apply writes in this order. +var assetMap = []struct { + SrcPath string // under assets/ + DstRel string // under ~/.config/nvim/ +}{ + {"assets/arduino.lua", "lua/plugins/arduino.lua"}, + {"assets/dap.lua", "lua/plugins/dap.lua"}, + {"assets/lspconfig.lua", "lua/plugins/lspconfig.lua"}, + {"assets/mappings.lua", "lua/plugins/mappings.lua"}, + {"assets/clangd.lua", "lsp/clangd.lua"}, +} + +// Module installs and configures Neovim + NvChad for Archduino. +type Module struct { + cfg config.NeovimConfig + themeID string + themes []tui.Theme + pm platform.PackageManager + git platform.GitClient +} + +// New constructs a Neovim Module. +func New(cfg config.NeovimConfig, themeID string, themes []tui.Theme, pm platform.PackageManager, git platform.GitClient) Module { + return Module{cfg: cfg, themeID: themeID, themes: themes, pm: pm, git: git} +} + +type state struct { + NvimInstalled bool + ConfigDirExists bool + ChadrcPresent bool + BackupNeeded bool + BackupSource string + StarterCloned bool + MissingAssets []string + ChadrcDrift bool + LspconfigBlock bool + StarterLspFile bool + ThemeName string + RenderedChadrc []byte +} + +// Meta returns the module metadata. +func (Module) Meta() module.Meta { + return module.Meta{ID: "neovim", Name: "Neovim + NvChad", Description: "Install Neovim and configure NvChad with Archduino plugins."} +} + +func nvimCfg() (string, error) { + h, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(h, ".config", "nvim"), nil +} + +func (m Module) resolvedTheme() string { + if t, ok := tui.ThemeByID(m.themes, m.themeID); ok && t.NvChadTheme != "" { + return t.NvChadTheme + } + if len(m.themes) > 0 { + return m.themes[0].NvChadTheme + } + return "gruvbox" +} + +func (m Module) renderChadrc() ([]byte, error) { + tmplBytes, err := assets.ReadFile("assets/chadrc.lua.tmpl") + if err != nil { + return nil, err + } + tmpl, err := template.New("chadrc").Parse(string(tmplBytes)) + if err != nil { + return nil, err + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, struct{ NvChadTheme string }{NvChadTheme: m.resolvedTheme()}); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// Detect inspects the filesystem and returns the current state. +func (m Module) Detect(ctx context.Context) (module.State, error) { + cfg, err := nvimCfg() + if err != nil { + return nil, err + } + var s state + s.ThemeName = m.resolvedTheme() + if m.pm != nil { + ok, err := m.pm.IsInstalled(ctx, "neovim") + if err != nil { + return nil, err + } + s.NvimInstalled = ok + } + + rendered, err := m.renderChadrc() + if err != nil { + return nil, err + } + s.RenderedChadrc = rendered + + if fi, err := os.Stat(cfg); err == nil && fi.IsDir() { + s.ConfigDirExists = true + if _, err := os.Stat(filepath.Join(cfg, "lua", "chadrc.lua")); err == nil { + s.ChadrcPresent = true + } + if _, err := os.Stat(filepath.Join(cfg, "lua")); err == nil && !s.ChadrcPresent { + s.BackupNeeded = true + s.BackupSource = cfg + } + } + if _, err := os.Stat(filepath.Join(cfg, "lua", "plugins", "init.lua")); err == nil { + s.StarterCloned = true + data, _ := os.ReadFile(filepath.Join(cfg, "lua", "plugins", "init.lua")) + if bytes.Contains(data, []byte("nvim-lspconfig")) { + s.LspconfigBlock = true + } + } + if _, err := os.Stat(filepath.Join(cfg, "lua", "configs", "lspconfig.lua")); err == nil { + s.StarterLspFile = true + } + + if cur, err := os.ReadFile(filepath.Join(cfg, "lua", "chadrc.lua")); err == nil { + if !bytes.Equal(cur, rendered) { + s.ChadrcDrift = true + } + } + + for _, a := range assetMap { + want, _ := assets.ReadFile(a.SrcPath) + got, err := os.ReadFile(filepath.Join(cfg, a.DstRel)) + if err != nil || !bytes.Equal(got, want) { + s.MissingAssets = append(s.MissingAssets, a.DstRel) + } + } + return s, nil +} + +// stripLspconfigBlock removes the plugin-table entry that references +// "neovim/nvim-lspconfig" from the NvChad starter's init.lua. It walks the +// source line-by-line: find the marker line, scan backward for the opening +// `{` of that entry, then scan forward counting braces until the matching +// `}` (plus trailing `,` if present). Robust against nested tables inside +// the entry (e.g. `opts = {}`) that defeat single-level regexes. +func stripLspconfigBlock(src []byte) []byte { + lines := strings.Split(string(src), "\n") + markerIdx := -1 + for i, l := range lines { + if strings.Contains(l, "neovim/nvim-lspconfig") { + markerIdx = i + break + } + } + if markerIdx == -1 { + return src + } + + // Scan backward for the opening `{` that begins the plugin entry. + // We track braces relative to the marker line so nested tables on + // earlier lines don't confuse the search: we want the first `{` + // whose pair would enclose the marker. + startLine := -1 + depth := 0 + for i := markerIdx; i >= 0; i-- { + // Count braces on this line from right to left. + for j := len(lines[i]) - 1; j >= 0; j-- { + c := lines[i][j] + if c == '}' { + depth++ + } else if c == '{' { + if depth == 0 { + startLine = i + // break out of both loops + i = -1 + break + } + depth-- + } + } + } + if startLine == -1 { + return src + } + + // Scan forward from markerIdx for the matching `}`. + endLine := -1 + depth = 1 // we're already inside the `{` at startLine + // Start just after the opening `{` on startLine. + for i := startLine; i < len(lines); i++ { + start := 0 + if i == startLine { + // skip past the first `{` on this line + idx := strings.Index(lines[i], "{") + if idx >= 0 { + start = idx + 1 + } + } + for j := start; j < len(lines[i]); j++ { + c := lines[i][j] + if c == '{' { + depth++ + } else if c == '}' { + depth-- + if depth == 0 { + endLine = i + break + } + } + } + if endLine != -1 { + break + } + } + if endLine == -1 { + return src + } + + // Include an optional trailing comma and whitespace-only lines after endLine. + cutEnd := endLine + // If the `}` line also has a trailing `,`, that's fine; keep the line as-is + // but drop it. If the endLine text after the closing `}` is just `,` or + // `,` + whitespace, keep cutEnd = endLine. Otherwise also keep cutEnd. + // Skip a single blank line after the block to avoid leaving a stray gap. + if cutEnd+1 < len(lines) && strings.TrimSpace(lines[cutEnd+1]) == "" { + cutEnd++ + } + + out := make([]string, 0, len(lines)-(cutEnd-startLine+1)) + out = append(out, lines[:startLine]...) + out = append(out, lines[cutEnd+1:]...) + return []byte(strings.Join(out, "\n")) +} + +// Plan computes the idempotent set of actions to converge to desired state. +func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) { + s, _ := cur.(state) + cfg, err := nvimCfg() + if err != nil { + return module.Plan{}, err + } + var actions []module.Action + + if !s.NvimInstalled && m.pm != nil { + actions = append(actions, module.Action{ + Description: "pacman install neovim", + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"neovim"}) }, + }) + } + + if s.BackupNeeded { + src := s.BackupSource + actions = append(actions, module.Action{ + Description: "back up existing non-NvChad config", + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + ts := time.Now().UTC().Format("20060102T150405Z") + return os.Rename(src, src+".bak."+ts) + }, + }) + } + + if !s.StarterCloned || s.BackupNeeded { + repo := m.cfg.NvChadStarterRepo + actions = append(actions, module.Action{ + Description: "clone NvChad starter", + Kind: module.ActionGitClone, + Exec: func(ctx context.Context) error { + 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")) + }, + }) + } + + if s.LspconfigBlock || !s.StarterCloned || s.BackupNeeded { + actions = append(actions, module.Action{ + Description: "strip nvim-lspconfig block from starter init.lua", + 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 { + return err + } + return platform.WriteUserFile(p, stripLspconfigBlock(data), 0o644) + }, + }) + } + + if s.StarterLspFile || !s.StarterCloned || s.BackupNeeded { + actions = append(actions, module.Action{ + Description: "remove starter lspconfig.lua", + 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 + }, + }) + } + + if s.ChadrcDrift || !s.ChadrcPresent || s.BackupNeeded { + rendered := append([]byte{}, s.RenderedChadrc...) + actions = append(actions, module.Action{ + Description: "write chadrc.lua with theme " + s.ThemeName, + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + return platform.WriteUserFile(filepath.Join(cfg, "lua", "chadrc.lua"), rendered, 0o644) + }, + }) + } + + for _, dst := range s.MissingAssets { + dst := dst + actions = append(actions, module.Action{ + Description: "write asset " + dst, + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + var src string + for _, a := range assetMap { + if a.DstRel == dst { + src = a.SrcPath + break + } + } + data, err := assets.ReadFile(src) + if err != nil { + return err + } + return platform.WriteUserFile(filepath.Join(cfg, dst), data, 0o644) + }, + }) + } + + if len(actions) > 0 { + actions = append(actions, module.Action{ + Description: "clear NvChad base46 theme cache", + 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 +} + +// Apply executes the planned actions in order. +func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error { + for i, a := range p.Actions { + r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description) + if err := a.Exec(ctx); err != nil { + return err + } + } + return nil +} + +// Verify re-Detects and returns an error if state is not converged. +func (m Module) Verify(ctx context.Context) error { + st, err := m.Detect(ctx) + if err != nil { + return err + } + s := st.(state) + if !s.NvimInstalled { + return fmt.Errorf("neovim not installed") + } + if !s.ChadrcPresent { + return fmt.Errorf("chadrc.lua missing") + } + if s.ChadrcDrift { + return fmt.Errorf("chadrc.lua content drifted") + } + if len(s.MissingAssets) > 0 { + return fmt.Errorf("missing assets: %v", s.MissingAssets) + } + if s.LspconfigBlock { + return fmt.Errorf("nvim-lspconfig block not stripped") + } + if s.StarterLspFile { + return fmt.Errorf("starter lspconfig.lua still present") + } + return nil +} diff --git a/internal/modules/neovim/neovim_test.go b/internal/modules/neovim/neovim_test.go new file mode 100644 index 0000000..964ebff --- /dev/null +++ b/internal/modules/neovim/neovim_test.go @@ -0,0 +1,177 @@ +package neovim + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" + "github.com/MDietermann/ArduinoArchSetup/internal/tui" +) + +type fakeRunner struct { + runCalls, outCalls [][]string + installed map[string]bool +} + +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { + f.runCalls = append(f.runCalls, append([]string{name}, args...)) + if name == "git" && len(args) >= 1 && args[0] == "clone" { + dest := args[len(args)-1] + os.MkdirAll(filepath.Join(dest, "lua", "plugins"), 0o755) + os.WriteFile(filepath.Join(dest, "lua", "plugins", "init.lua"), + []byte("return {\n \"neovim/nvim-lspconfig\",\n opts = {},\n},\n{\n \"other\",\n},\n"), + 0o644) + os.MkdirAll(filepath.Join(dest, "lua", "configs"), 0o755) + os.WriteFile(filepath.Join(dest, "lua", "configs", "lspconfig.lua"), []byte("stub"), 0o644) + } + return nil +} + +func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.outCalls = append(f.outCalls, append([]string{name}, args...)) + if name == "pacman" && len(args) == 2 && args[0] == "-Q" { + if f.installed[args[1]] { + return []byte("ok"), nil + } + return nil, errExit1 + } + return nil, nil +} + +type exitOne struct{} + +func (exitOne) Error() string { return "exit 1" } + +var errExit1 = exitOne{} + +type nopReporter struct{} + +func (nopReporter) Progress(string, int, int, string) {} +func (nopReporter) Log(string, string) {} + +func TestDetectFreshSystem(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + r := &fakeRunner{installed: map[string]bool{}} + m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r}) + st, err := m.Detect(context.Background()) + if err != nil { + t.Fatal(err) + } + s := st.(state) + if s.NvimInstalled { + t.Error("nvim should be absent") + } + if s.ChadrcPresent { + t.Error("chadrc should be absent") + } + if s.BackupNeeded { + t.Error("no backup on empty system") + } +} + +func TestDetectNonNvChadTriggersBackup(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + os.MkdirAll(filepath.Join(home, ".config", "nvim", "lua"), 0o755) + os.WriteFile(filepath.Join(home, ".config", "nvim", "lua", "plugins.lua"), []byte("-- lazyvim"), 0o644) + r := &fakeRunner{installed: map[string]bool{}} + m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r}) + st, _ := m.Detect(context.Background()) + if !st.(state).BackupNeeded { + t.Fatal("expected BackupNeeded=true") + } +} + +func TestPlanApplyInstallsEverything(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + r := &fakeRunner{installed: map[string]bool{}} + m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "dracula", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r}) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if len(p.Actions) == 0 { + t.Fatal("empty plan") + } + if err := m.Apply(context.Background(), p, nopReporter{}); err != nil { + t.Fatal(err) + } + + sawPacman, sawClone := false, false + for _, c := range r.runCalls { + if len(c) >= 3 && c[0] == "sudo" && c[1] == "pacman" { + sawPacman = true + } + if c[0] == "git" && len(c) >= 2 && c[1] == "clone" { + sawClone = true + } + } + if !sawPacman || !sawClone { + t.Fatalf("missing steps; calls=%v", r.runCalls) + } + + nvimCfg := filepath.Join(home, ".config", "nvim") + chadrc, err := os.ReadFile(filepath.Join(nvimCfg, "lua", "chadrc.lua")) + if err != nil { + t.Fatalf("chadrc missing: %v", err) + } + if !strings.Contains(string(chadrc), `theme = "dracula"`) { + t.Fatalf("chadrc theme not applied: %s", chadrc) + } + + initLua, _ := os.ReadFile(filepath.Join(nvimCfg, "lua", "plugins", "init.lua")) + if strings.Contains(string(initLua), "nvim-lspconfig") { + t.Fatalf("nvim-lspconfig not stripped: %s", initLua) + } + + if _, err := os.Stat(filepath.Join(nvimCfg, "lua", "configs", "lspconfig.lua")); !os.IsNotExist(err) { + t.Fatal("starter lspconfig.lua should be removed") + } + + for _, name := range []string{"arduino.lua", "dap.lua", "mappings.lua"} { + if _, err := os.Stat(filepath.Join(nvimCfg, "lua", "plugins", name)); err != nil { + t.Errorf("asset %s missing: %v", name, err) + } + } + if _, err := os.Stat(filepath.Join(nvimCfg, "lsp", "clangd.lua")); err != nil { + t.Errorf("clangd.lua missing: %v", err) + } +} + +func TestPlanAfterApplyIsEmpty(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", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r}) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + _ = m.Apply(context.Background(), p, nopReporter{}) + st2, _ := m.Detect(context.Background()) + p2, _ := m.Plan(context.Background(), st2) + if !p2.IsEmpty() { + t.Fatalf("second plan not empty: %+v", p2.Actions) + } +} + +func TestUnknownThemeFallsBackToFirst(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"}, "does-not-exist", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r}) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if err := m.Apply(context.Background(), p, nopReporter{}); err != nil { + t.Fatal(err) + } + chadrc, _ := os.ReadFile(filepath.Join(home, ".config", "nvim", "lua", "chadrc.lua")) + if !strings.Contains(string(chadrc), `theme = "gruvbox"`) { + t.Fatalf("fallback theme not applied: %s", chadrc) + } +} + +var _ module.Module = Module{}