diff --git a/README.md b/README.md index 41c3f1e..1b7ef0e 100644 --- a/README.md +++ b/README.md @@ -143,19 +143,16 @@ require("code-preview").setup() 1. Install the plugin and call `setup()` 2. Open a project in Neovim 3. Run `:CodePreviewInstallCodexCliHooks` — writes `.codex/hooks.json` -4. Codex requires a feature flag to enable hooks, and the diff-preview workflow only makes sense when Codex asks before applying edits. Create or edit `.codex/config.toml` (project-local) or `~/.codex/config.toml` (global) and add: +4. The diff-preview workflow only makes sense when Codex asks before applying edits. Create or edit `.codex/config.toml` (project-local) or `~/.codex/config.toml` (global) and add: ```toml approval_policy = "on-request" sandbox_mode = "read-only" - - [features] - codex_hooks = true ``` `approval_policy = "on-request"` and `sandbox_mode = "read-only"` ensure Codex prompts you before every edit, so the diff preview has time to open and you have time to review. Without them, Codex may apply changes without prompting and the preview window will never block on your decision. - The installer warns you if `codex_hooks` is missing. You can re-check at any time with `:CodePreviewStatus` or `:checkhealth code-preview`, which both report whether the feature flag is detected. + > **Note on the hooks feature flag:** Modern Codex enables hooks by default — no `[features]` entry is required. If you previously disabled them with `[features] hooks = false` (or the legacy `codex_hooks = false`), remove that line. `:CodePreviewStatus` and `:checkhealth code-preview` will warn if hooks are explicitly disabled. 5. Start Codex CLI in the project directory 6. Ask Codex to edit a file — a diff opens automatically in Neovim 7. Accept/reject in the CLI; the diff closes automatically on accept @@ -187,7 +184,7 @@ AI Agent (terminal) Neovim **GitHub Copilot CLI** uses shell-based hooks (`preToolUse`/`postToolUse`) configured in `.github/hooks/code-preview.json`. The adapter translates Copilot's tool vocabulary (`apply_patch`, `edit`, `create`, `bash`) into the same normalized format used by the other backends. -**OpenAI Codex CLI** uses shell-based hooks (`PreToolUse`/`PostToolUse`) configured in `.codex/hooks.json`, gated by `codex_hooks = true` under `[features]` in `.codex/config.toml`. The adapter passes `Bash` through and rewrites `apply_patch` (whose patch text lives in `tool_input.command`) into the canonical `ApplyPatch` shape with `tool_input.patch_text`. +**OpenAI Codex CLI** uses shell-based hooks (`PreToolUse`/`PostToolUse`) configured in `.codex/hooks.json`. Hooks are enabled by default in modern Codex; the only way to silence them is `[features] hooks = false` (or the legacy `codex_hooks = false`) in `.codex/config.toml`. The adapter passes `Bash` through and rewrites `apply_patch` (whose patch text lives in `tool_input.command`) into the canonical `ApplyPatch` shape with `tool_input.patch_text`. All backends communicate with Neovim via RPC (`nvim --server --remote-send`). @@ -250,8 +247,6 @@ require("code-preview").setup({ | `:CodePreviewToggleVisibleOnly` | Toggle visible_only — show diffs only for open buffers | | `:checkhealth code-preview` | Full health check (all backends) | -> **Migrating?** The old `:ClaudePreview*` commands still work but show a deprecation warning. They will be removed in a future release. - ## Keymaps | Key | Scope | Description | @@ -450,8 +445,8 @@ vim.api.nvim_create_autocmd({ "FocusGained", "BufEnter", "CursorHold" }, { **Codex CLI hooks not firing** - Run `:CodePreviewInstallCodexCliHooks` in the project root -- Confirm `.codex/config.toml` contains `[features]` with `codex_hooks = true` (without it, Codex ignores `hooks.json` silently) -- Update Codex if needed — older versions only fired hooks for `Bash`, not `apply_patch` +- Confirm `.codex/config.toml` does **not** contain `[features] hooks = false` (or the legacy `codex_hooks = false`) — hooks are enabled by default in modern Codex; an explicit `false` is the only way to silence them +- Update Codex if needed — older versions required `codex_hooks = true` explicitly and only fired hooks for `Bash`, not `apply_patch` - Run `:CodePreviewStatus` and `:checkhealth code-preview` to verify install state and the feature flag **Copilot CLI hooks not firing** @@ -466,7 +461,7 @@ vim.api.nvim_create_autocmd({ "FocusGained", "BufEnter", "CursorHold" }, { **Migrating from older versions** - Update `require("claude-preview")` to `require("code-preview")` in your Neovim config - Re-run `:CodePreviewInstallClaudeCodeHooks` to update hook paths -- The old `:ClaudePreview*` commands still work but show deprecation warnings +- The old `:ClaudePreview*` commands have been removed — use the `:CodePreview*` equivalents --- diff --git a/lua/code-preview/backends/claudecode.lua b/lua/code-preview/backends/claudecode.lua index a814810..743bee9 100644 --- a/lua/code-preview/backends/claudecode.lua +++ b/lua/code-preview/backends/claudecode.lua @@ -99,6 +99,20 @@ function M.install() vim.notify("[code-preview] Hooks installed → " .. path, vim.log.levels.INFO) end +--- Report whether the Claude Code hooks are wired up in this project. +--- @return { state: "installed"|"missing", warnings: string[]? } +function M.install_state() + local path = settings_path() + local f = io.open(path, "r") + if not f then return { state = "missing" } end + local content = f:read("*a") or "" + f:close() + local installed = content:find(HOOK_MARKER, 1, true) ~= nil + or content:find(LEGACY_HOOK_MARKER, 1, true) ~= nil + if installed then return { state = "installed" } end + return { state = "missing" } +end + function M.uninstall() local path = settings_path() local data = read_settings(path) diff --git a/lua/code-preview/backends/codex.lua b/lua/code-preview/backends/codex.lua index a89278c..1b1952d 100644 --- a/lua/code-preview/backends/codex.lua +++ b/lua/code-preview/backends/codex.lua @@ -83,25 +83,28 @@ local function remove_ours(list) return filtered end --- Check both the project-local and global config.toml for the codex_hooks --- feature flag. Returns "enabled" | "disabled" | "missing". --- enabled — at least one location has `codex_hooks = true` --- disabled — at least one location exists, but none enable the flag --- missing — neither location exists --- The global path mirrors what Codex itself reads, so a user who set the --- flag in ~/.codex/config.toml shouldn't see a false warning here. +-- Per-file probe for the Codex hooks feature flag. Returns one of: +-- "enabled" — file explicitly sets `hooks` (or legacy `codex_hooks`) to true +-- "disabled" — file explicitly sets the same to false +-- nil — file is missing or expresses no opinion (use default) +-- Modern Codex enables hooks by default and accepts either `hooks` (canonical) +-- or `codex_hooks` (deprecated alias) under [features]. We match either. local function file_flag_state(path) - if vim.fn.filereadable(path) == 0 then return "missing" end + if vim.fn.filereadable(path) == 0 then return nil end local f = io.open(path, "r") - if not f then return "missing" end + if not f then return nil end local content = f:read("*a") or "" f:close() - -- Look for `codex_hooks = true` (loose match — handles whitespace & quotes - -- but not deeply parsed; users with exotic TOML are responsible for it). - if content:match("codex_hooks%s*=%s*true") then + -- Loose match — handles whitespace & quotes but not deeply parsed; users + -- with exotic TOML are responsible for it. Order matters only for the + -- explicit-false detection: we surface disabled iff no enabling line exists. + if content:match("hooks%s*=%s*true") or content:match("codex_hooks%s*=%s*true") then return "enabled" end - return "disabled" + if content:match("hooks%s*=%s*false") or content:match("codex_hooks%s*=%s*false") then + return "disabled" + end + return nil end local function global_config_path() @@ -112,20 +115,16 @@ local function global_config_path() return vim.fn.expand("~/.codex/config.toml") end +--- Resolve the effective state of Codex's `hooks` feature flag. +--- Project-local config wins over the global config; in the absence of any +--- explicit setting, Codex defaults to enabled, so we do too. +--- @return "enabled"|"disabled" local function feature_flag_state() - local local_state = file_flag_state(config_path()) + local local_state = file_flag_state(config_path()) + if local_state ~= nil then return local_state end local global_state = file_flag_state(global_config_path()) - -- Enabled wins if either location turns it on. - if local_state == "enabled" or global_state == "enabled" then - return "enabled" - end - -- If at least one file exists but neither enables the flag, surface as - -- disabled (so we tell the user what to fix). Only report missing when - -- both files are absent. - if local_state == "missing" and global_state == "missing" then - return "missing" - end - return "disabled" + if global_state ~= nil then return global_state end + return "enabled" end local function ensure_executable(path) @@ -173,20 +172,16 @@ function M.install() write_json(hooks_path(), data) vim.notify("[code-preview] Codex hooks installed → " .. hooks_path(), vim.log.levels.INFO) - -- Codex ignores hooks.json unless `codex_hooks = true` lives under - -- `[features]` in config.toml. We don't edit config.toml automatically - -- (TOML editing without a parser is risky); surface a clear nudge instead. - local state = feature_flag_state() - if state ~= "enabled" then - local msg - if state == "missing" then - msg = "[code-preview] Codex requires a feature flag to enable hooks. Create " - .. config_path() .. " with:\n\n [features]\n codex_hooks = true\n" - else - msg = "[code-preview] Codex requires `codex_hooks = true` under `[features]` in " - .. config_path() .. ". Add it manually before running Codex." - end - vim.notify(msg, vim.log.levels.WARN) + -- Modern Codex enables hooks by default — no config.toml entry needed. We + -- only nudge the user if they've *explicitly* opted out via `hooks = false` + -- (or the legacy `codex_hooks = false`) under `[features]`. + if feature_flag_state() == "disabled" then + vim.notify( + "[code-preview] Codex hooks are disabled in your config: `[features] hooks = false` (or `codex_hooks = false`) is set in " + .. config_path() .. " or " .. global_config_path() + .. ". Remove the line, or set `hooks = true`, before running Codex.", + vim.log.levels.WARN + ) end end @@ -221,6 +216,22 @@ end -- without duplicating the parser. function M.feature_flag_state() return feature_flag_state() end +--- Report Codex install state. Hooks-wired-up is the primary signal. Modern +--- Codex enables hooks by default, so we only warn when the user has +--- explicitly disabled them via `[features] hooks = false` (or the legacy +--- `codex_hooks = false`) in config.toml. +--- @return { state: "installed"|"missing", warnings: string[]? } +function M.install_state() + if not M.is_installed() then return { state = "missing" } end + if feature_flag_state() == "disabled" then + return { + state = "installed", + warnings = { "hooks explicitly disabled in .codex/config.toml (`[features] hooks = false`)" }, + } + end + return { state = "installed" } +end + -- True iff `path`'s hooks.json contains an entry referencing our adapter -- script. Used by status display to detect installation without relying on -- file existence alone. diff --git a/lua/code-preview/backends/copilot.lua b/lua/code-preview/backends/copilot.lua index 89c0a75..9aa38a5 100644 --- a/lua/code-preview/backends/copilot.lua +++ b/lua/code-preview/backends/copilot.lua @@ -66,6 +66,15 @@ function M.install() vim.notify("[code-preview] Copilot CLI hooks installed → " .. path, vim.log.levels.INFO) end +--- Report whether the Copilot CLI hooks config was produced by our installer. +--- @return { state: "installed"|"missing", warnings: string[]? } +function M.install_state() + if M.is_our_config(config_path()) then + return { state = "installed" } + end + return { state = "missing" } +end + function M.uninstall() local path = config_path() if vim.fn.filereadable(path) == 0 then diff --git a/lua/code-preview/backends/opencode.lua b/lua/code-preview/backends/opencode.lua index 95c2bba..95a4ae9 100644 --- a/lua/code-preview/backends/opencode.lua +++ b/lua/code-preview/backends/opencode.lua @@ -67,6 +67,15 @@ function M.install() vim.notify("[code-preview] OpenCode plugin installed → " .. target, vim.log.levels.INFO) end +--- Report whether the OpenCode plugin has been copied into the project. +--- @return { state: "installed"|"missing", warnings: string[]? } +function M.install_state() + if vim.fn.filereadable(opencode_target_dir() .. "/index.ts") == 1 then + return { state = "installed" } + end + return { state = "missing" } +end + function M.uninstall() local target = opencode_target_dir() diff --git a/lua/code-preview/health.lua b/lua/code-preview/health.lua index fbad548..1d93961 100644 --- a/lua/code-preview/health.lua +++ b/lua/code-preview/health.lua @@ -215,13 +215,13 @@ function M.check() local codex_backend = require("code-preview.backends.codex") if codex_backend.is_installed() then ok("Codex CLI hooks are installed (.codex/hooks.json)") - local flag = codex_backend.feature_flag_state() - if flag == "enabled" then - ok(".codex/config.toml has codex_hooks = true") - elseif flag == "disabled" then - warn(".codex/config.toml is missing `codex_hooks = true` under [features] — hooks will not fire") + -- Modern Codex enables hooks by default under [features]; the canonical + -- key is `hooks` (with `codex_hooks` accepted as a deprecated alias). + -- The only failure mode here is the user having explicitly opted out. + if codex_backend.feature_flag_state() == "disabled" then + warn("Codex hooks are explicitly disabled in config.toml (`[features] hooks = false`) — remove it or set `hooks = true`") else - warn(".codex/config.toml not found — create it with `[features]\\ncodex_hooks = true`") + ok("Codex hooks feature is enabled (default; no flag required)") end else warn("Codex CLI hooks not installed — run :CodePreviewInstallCodexCliHooks") diff --git a/lua/code-preview/init.lua b/lua/code-preview/init.lua index 867c05d..fdbcc6f 100644 --- a/lua/code-preview/init.lua +++ b/lua/code-preview/init.lua @@ -74,17 +74,6 @@ local function deep_merge(base, override) return result end --- Helper: create a deprecated alias that warns and delegates -local function deprecated_alias(old_name, new_name) - vim.api.nvim_create_user_command(old_name, function() - vim.notify( - "[code-preview] :" .. old_name .. " is deprecated, use :" .. new_name, - vim.log.levels.WARN - ) - vim.cmd(new_name) - end, { desc = "(deprecated) Use :" .. new_name .. " instead" }) -end - function M.setup(user_config) M.config = deep_merge(default_config, user_config or {}) @@ -150,14 +139,6 @@ function M.setup(user_config) ) end, { desc = "Toggle visible_only — show diffs only for open buffers vs all files" }) - -- ── Deprecated aliases (remove after one release cycle) ─────── - - deprecated_alias("ClaudePreviewInstallHooks", "CodePreviewInstallClaudeCodeHooks") - deprecated_alias("ClaudePreviewUninstallHooks", "CodePreviewUninstallClaudeCodeHooks") - deprecated_alias("ClaudePreviewCloseDiff", "CodePreviewCloseDiff") - deprecated_alias("ClaudePreviewStatus", "CodePreviewStatus") - deprecated_alias("ClaudePreviewToggleVisibleOnly", "CodePreviewToggleVisibleOnly") - -- Neo-tree integration (soft dependency) if M.config.neo_tree.enabled then require("code-preview.neo_tree").setup(M.config) @@ -250,60 +231,30 @@ function M.status() local diff = require("code-preview.diff") table.insert(lines, "Diff tab : " .. (diff.is_open() and "open" or "closed")) - -- Backends + -- Backends — each module exposes install_state() returning + -- { state = "installed"|"missing", warnings = {...}? }. Rendering lives + -- here; per-backend detection logic lives in the backend module. table.insert(lines, "") table.insert(lines, "Backends:") - -- Claude Code - local claude_ok = false - local settings_path = vim.fn.getcwd() .. "/.claude/settings.local.json" - local f = io.open(settings_path, "r") - if f then - local content = f:read("*a") - f:close() - claude_ok = content:find("code-preview", 1, true) ~= nil - or content:find("claude-preview", 1, true) ~= nil - end - if claude_ok then - table.insert(lines, " Claude Code : installed") - else - table.insert(lines, " Claude Code : not installed -> :CodePreviewInstallClaudeCodeHooks") - end - - -- OpenCode - local opencode_ok = vim.fn.filereadable(vim.fn.getcwd() .. "/.opencode/plugins/index.ts") == 1 - if opencode_ok then - table.insert(lines, " OpenCode : installed") - else - table.insert(lines, " OpenCode : not installed -> :CodePreviewInstallOpenCodeHooks") - end - - -- Copilot CLI — check file contents, not just existence, so a user-authored - -- hook file that happens to share the name isn't reported as "installed". - local copilot_ok = require("code-preview.backends.copilot").is_our_config( - vim.fn.getcwd() .. "/.github/hooks/code-preview.json" - ) - if copilot_ok then - table.insert(lines, " Copilot CLI : installed") - else - table.insert(lines, " Copilot CLI : not installed -> :CodePreviewInstallCopilotCliHooks") - end - - -- Codex CLI — installation requires both our hooks.json entries AND the - -- `codex_hooks = true` feature flag in config.toml; report both so users - -- can debug a "hooks aren't firing" state without guessing. - local codex = require("code-preview.backends.codex") - if codex.is_installed() then - local flag = codex.feature_flag_state() - if flag == "enabled" then - table.insert(lines, " Codex CLI : installed (codex_hooks=true)") - elseif flag == "disabled" then - table.insert(lines, " Codex CLI : installed BUT codex_hooks flag missing in .codex/config.toml") + local BACKENDS = { + { name = "claudecode", label = "Claude Code", install_cmd = ":CodePreviewInstallClaudeCodeHooks" }, + { name = "opencode", label = "OpenCode ", install_cmd = ":CodePreviewInstallOpenCodeHooks" }, + { name = "copilot", label = "Copilot CLI", install_cmd = ":CodePreviewInstallCopilotCliHooks" }, + { name = "codex", label = "Codex CLI ", install_cmd = ":CodePreviewInstallCodexCliHooks" }, + } + + for _, b in ipairs(BACKENDS) do + local s = require("code-preview.backends." .. b.name).install_state() + if s.state == "installed" then + if s.warnings and #s.warnings > 0 then + table.insert(lines, " " .. b.label .. " : installed BUT " .. table.concat(s.warnings, "; ")) + else + table.insert(lines, " " .. b.label .. " : installed") + end else - table.insert(lines, " Codex CLI : installed BUT .codex/config.toml not found (need codex_hooks=true)") + table.insert(lines, " " .. b.label .. " : not installed -> " .. b.install_cmd) end - else - table.insert(lines, " Codex CLI : not installed -> :CodePreviewInstallCodexCliHooks") end vim.notify(table.concat(lines, "\n"), vim.log.levels.INFO, { title = "code-preview" }) diff --git a/tests/backends/codex/test_install.sh b/tests/backends/codex/test_install.sh index 3acd56f..3b777f5 100755 --- a/tests/backends/codex/test_install.sh +++ b/tests/backends/codex/test_install.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash # test_install.sh — OpenAI Codex CLI hook install/uninstall tests # -# Codex reads hooks from .codex/hooks.json and requires `codex_hooks = true` -# under [features] in .codex/config.toml. Our installer writes hooks.json -# (merging with any existing entries) and warns if the feature flag is -# missing — it does NOT edit config.toml. These tests pin that contract. +# Codex reads hooks from .codex/hooks.json. Modern Codex enables hooks by +# default; only an explicit `[features] hooks = false` (or the legacy +# `codex_hooks = false`) silences them. Our installer writes hooks.json +# (merging with any existing entries) and warns only on explicit opt-out — +# it does NOT edit config.toml. These tests pin that contract. # ── Setup ──────────────────────────────────────────────────────── @@ -136,76 +137,108 @@ EOF assert_not_contains "$content" "code-close-diff.sh" "our post-hook must be removed" || return 1 } -# ── Test: feature_flag_state reports the three modes ──────────── +# ── Test: feature_flag_state reflects default-enabled semantics ── # Drives the helper that :CodePreviewStatus and :checkhealth use to surface -# the codex_hooks feature flag. The flag is the silent failure mode for -# Codex hooks, so the detector must not produce false positives or negatives. +# the Codex hooks feature flag. Modern Codex enables hooks by default — the +# only "off" state is an explicit 'hooks = false' (or legacy +# `codex_hooks = false`) under [features]. test_feature_flag_state() { rm -rf "$TEST_PROJECT_DIR/.codex" rm -f "$GLOBAL_CONFIG_FILE" - # Both project-local and global absent. - local missing - missing="$(nvim_eval "require('code-preview.backends.codex').feature_flag_state()")" - assert_eq "missing" "$missing" "no config files should report 'missing'" || return 1 + # Both project-local and global absent → default (enabled). + local default_state + default_state="$(nvim_eval "require('code-preview.backends.codex').feature_flag_state()")" + assert_eq "enabled" "$default_state" "no config files should default to 'enabled'" || return 1 - # Project-local exists without the flag, global still absent → disabled. + # Project-local exists without any hooks setting → still default (enabled). mkdir -p "$TEST_PROJECT_DIR/.codex" cat > "$CONFIG_FILE" <<'EOF' approval_policy = "on-request" EOF - local disabled - disabled="$(nvim_eval "require('code-preview.backends.codex').feature_flag_state()")" - assert_eq "disabled" "$disabled" "config.toml without flag should report 'disabled'" || return 1 + local no_opinion + no_opinion="$(nvim_eval "require('code-preview.backends.codex').feature_flag_state()")" + assert_eq "enabled" "$no_opinion" "config.toml without a hooks line should stay 'enabled' (default)" || return 1 - # Project-local has the flag → enabled. + # Project-local explicitly enables via the canonical `hooks` key. cat > "$CONFIG_FILE" <<'EOF' -approval_policy = "on-request" +[features] +hooks = true +EOF + local explicit_on + explicit_on="$(nvim_eval "require('code-preview.backends.codex').feature_flag_state()")" + assert_eq "enabled" "$explicit_on" "explicit 'hooks = true' should be 'enabled'" || return 1 + # Legacy alias `codex_hooks = true` must still parse as enabled. + cat > "$CONFIG_FILE" <<'EOF' [features] codex_hooks = true EOF - local enabled - enabled="$(nvim_eval "require('code-preview.backends.codex').feature_flag_state()")" - assert_eq "enabled" "$enabled" "config.toml with flag should report 'enabled'" || return 1 + local legacy_on + legacy_on="$(nvim_eval "require('code-preview.backends.codex').feature_flag_state()")" + assert_eq "enabled" "$legacy_on" "legacy 'codex_hooks = true' should be 'enabled'" || return 1 + + # Explicit opt-out via canonical key → disabled. + cat > "$CONFIG_FILE" <<'EOF' +[features] +hooks = false +EOF + local explicit_off + explicit_off="$(nvim_eval "require('code-preview.backends.codex').feature_flag_state()")" + assert_eq "disabled" "$explicit_off" "explicit 'hooks = false' should be 'disabled'" || return 1 + + # Explicit opt-out via legacy alias. + cat > "$CONFIG_FILE" <<'EOF' +[features] +codex_hooks = false +EOF + local legacy_off + legacy_off="$(nvim_eval "require('code-preview.backends.codex').feature_flag_state()")" + assert_eq "disabled" "$legacy_off" "legacy 'codex_hooks = false' should be 'disabled'" || return 1 } -# ── Test: feature_flag_state honors the global config.toml ────── +# ── Test: project-local precedence over global config ─────────── # Codex reads ~/.codex/config.toml (global) in addition to .codex/config.toml -# (project-local). A user with the flag set globally should NOT see a -# misleading "disabled/missing" warning. Mirrors the docs we link in README. +# (project-local). Project-local should override the global setting — a user +# who turned hooks off globally but back on for this project shouldn't see a +# false warning, and vice versa. test_feature_flag_state_global() { rm -rf "$TEST_PROJECT_DIR/.codex" rm -f "$GLOBAL_CONFIG_FILE" - # Only the global file has the flag — project-local is absent. + # Global disables, no project-local opinion → disabled propagates. cat > "$GLOBAL_CONFIG_FILE" <<'EOF' [features] -codex_hooks = true +hooks = false EOF - local enabled - enabled="$(nvim_eval "require('code-preview.backends.codex').feature_flag_state()")" - assert_eq "enabled" "$enabled" "global config with flag should report 'enabled'" || return 1 + local global_off + global_off="$(nvim_eval "require('code-preview.backends.codex').feature_flag_state()")" + assert_eq "disabled" "$global_off" "global 'hooks = false' should propagate" || return 1 - # Project-local without the flag must NOT downgrade an enabled global. + # Project-local re-enables — must win over the global off. mkdir -p "$TEST_PROJECT_DIR/.codex" cat > "$CONFIG_FILE" <<'EOF' -approval_policy = "on-request" +[features] +hooks = true EOF - local still_enabled - still_enabled="$(nvim_eval "require('code-preview.backends.codex').feature_flag_state()")" - assert_eq "enabled" "$still_enabled" "global flag should win over local-without-flag" || return 1 + local local_wins_on + local_wins_on="$(nvim_eval "require('code-preview.backends.codex').feature_flag_state()")" + assert_eq "enabled" "$local_wins_on" "project-local 'hooks = true' should override global off" || return 1 - # Both files present, neither enables → disabled (not missing). - rm -f "$GLOBAL_CONFIG_FILE" + # Inverse: global enabled, project-local explicitly disables. cat > "$GLOBAL_CONFIG_FILE" <<'EOF' -# nothing useful here +[features] +hooks = true +EOF + cat > "$CONFIG_FILE" <<'EOF' +[features] +hooks = false EOF - local disabled - disabled="$(nvim_eval "require('code-preview.backends.codex').feature_flag_state()")" - assert_eq "disabled" "$disabled" "two configs, neither enabling, should be 'disabled'" || return 1 + local local_wins_off + local_wins_off="$(nvim_eval "require('code-preview.backends.codex').feature_flag_state()")" + assert_eq "disabled" "$local_wins_off" "project-local 'hooks = false' should override global on" || return 1 } # ── Test: install refuses to overwrite a corrupted hooks.json ─── diff --git a/tests/plugin/install_state_spec.lua b/tests/plugin/install_state_spec.lua new file mode 100644 index 0000000..f8a27c8 --- /dev/null +++ b/tests/plugin/install_state_spec.lua @@ -0,0 +1,49 @@ +-- install_state_spec.lua — Contract test for the unified backend +-- install_state() interface (#58). +-- +-- Each backend module must expose: +-- install_state() -> { state = "installed"|"missing", warnings = string[]? } +-- +-- These tests run against a fresh tmp cwd with no backend artifacts present, +-- so every backend should report `missing`. The point is to lock the return +-- shape across all four backends; end-to-end install/uninstall behaviour is +-- covered by the per-backend shell tests under tests/backends/. + +local BACKENDS = { "claudecode", "opencode", "copilot", "codex" } + +-- Preload backend modules while the original cwd (which is on rtp via `.`) is +-- still active; cd-ing first would break the require path. +local LOADED = {} +for _, n in ipairs(BACKENDS) do + LOADED[n] = require("code-preview.backends." .. n) +end + +local original_cwd + +describe("backends install_state() contract", function() + before_each(function() + original_cwd = vim.fn.getcwd() + local tmp = vim.fn.tempname() + vim.fn.mkdir(tmp, "p") + vim.cmd("cd " .. vim.fn.fnameescape(tmp)) + end) + + after_each(function() + vim.cmd("cd " .. vim.fn.fnameescape(original_cwd)) + end) + + for _, name in ipairs(BACKENDS) do + it(name .. " reports missing in an empty project", function() + local mod = LOADED[name] + assert.is_function(mod.install_state) + local s = mod.install_state() + assert.is_table(s) + assert.equals("missing", s.state) + end) + end + + -- The codex degraded path (hooks present but `codex_hooks = true` missing) + -- is exercised end-to-end by tests/backends/codex/test_install.sh, which + -- can actually invoke install() against a real filesystem without fighting + -- the plenary headless cwd handling. +end)