diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5c98886 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,27 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + neovim: [stable, nightly] + steps: + - uses: actions/checkout@v4 + + - uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: ${{ matrix.neovim }} + + - name: Install Lua 5.1 (for luac) + run: sudo apt-get update && sudo apt-get install -y lua5.1 + + - name: Run tests + run: ./scripts/test.sh diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..aa0a0f6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,153 @@ +# CLAUDE.md — Maintainer guide for toolpath.nvim + +## What this is + +Neovim plugin that records editing history as Toolpath Path documents. Thin Lua +wrapper (~500 lines) around `path track` CLI subcommands. The plugin owns undo +tree mapping and VCS awareness; the CLI owns diffing, session state, and +document building. + +## Project layout + +``` +lua/toolpath/init.lua ← all plugin logic (setup, tracking, git, async queue) +lua/toolpath/health.lua ← :checkhealth toolpath +plugin/toolpath.lua ← user command definitions +doc/toolpath.txt ← Neovim :help documentation +README.md ← user-facing docs +local/ ← design docs from the toolpath CLI team (not shipped) +docs/ ← design docs we authored (not shipped) +``` + +## Version + +`M.version` in `lua/toolpath/init.lua` is the single source of truth. Bump it +on every user-visible change. It's displayed in `:ToolpathStatus` and +`:checkhealth toolpath`. + +## Checklist for any change + +1. **Update `lua/toolpath/init.lua`** — the code itself. +2. **Update `doc/toolpath.txt`** — if you added/changed/removed any command, + Lua API function, config option, buffer variable, or behavior. The help file + is the authoritative reference. Keep the contents table section numbers in + sync. +3. **Update `README.md`** — if the change is user-facing. The README mirrors + the help doc but is less exhaustive. Update the commands table, Lua API + listing, config examples, and any relevant prose sections. +4. **Update `plugin/toolpath.lua`** — if you added/changed/removed a user + command. +5. **Update `lua/toolpath/health.lua`** — if you added new dependencies, + config requirements, or internal accessors. +6. **Bump `M.version`** — in `lua/toolpath/init.lua`. +7. **Run `./scripts/test.sh`** — syntax checks all Lua files and runs the + test suites. Add tests for any new pure functions or API changes. + +## CLI interface + +The plugin shells out to `path track `. The current subcommands +and their args are documented in `local/track-source-annotate.md` and +`docs/cli-graph-output.md`. Key points: + +- `init` — creates session, returns session path on stdout. Accepts + `--base-uri` and `--base-ref` for VCS anchoring. +- `step` — records an edit. Accepts `--source` (JSON vcsSource). Async in + normal flow, sync during gap replay. +- `visit` — caches content for undo/redo navigation. No `--source`. +- `note` — sets intent on head step. Still works, not deprecated. +- `annotate` — generalized note. Can target any step, set intent, source, refs. +- `close` — exports Path document on stdout and deletes session file. +- `export` — like close but non-destructive. + +All CLI communication is via args + stdin (buffer content) + stdout (results). +No temp files, no sockets. + +## Architecture decisions + +**Plugin owns VCS awareness.** The CLI is VCS-agnostic. The plugin runs +`git rev-parse` and passes structured data via `--base-uri`, `--base-ref`, +and `--source` JSON. This keeps the CLI editor-agnostic and VCS-agnostic. + +**Git cache refreshed on events, not keystrokes.** `FocusGained` and +`ShellCmdPost` trigger cache refresh. Never run git commands in the +`on_text_changed` hot path. + +**Async for step/visit, sync for everything else.** The `on_text_changed` +handler enqueues step and visit calls on a per-buffer serial queue using +`vim.system()` callbacks. Gap replay (intermediate undo entries) is sync +because it moves the buffer through undo states. `init`, `close`, `note`, +`annotate`, and `export` are sync. + +**Stop order matters.** `M.stop()` must: (1) delete the augroup first to +prevent new events, (2) drain the async queue synchronously, (3) then call +`close`. Getting this wrong causes races or lost steps. + +**Archive only if edits were made.** `stop()` checks `step_count > 0` before +writing to `archive/`. Viewing a file without editing it shouldn't produce +archive files. + +## Directory layout for session data + +`record.dir` (e.g. `~/.local/share/toolpath`) gets this structure: + +``` +/ +├── README.md ← auto-generated on first use, explains the directory +├── live/ ← active session state files (temporary, CLI manages lifecycle) +└── archive/ ← exported Path documents (..path.json) +``` + +## Common pitfalls + +- **`unpack` vs `table.unpack`**: Neovim's LuaJIT has `unpack` as a global. + Standard Lua 5.2+ uses `table.unpack`. We use `unpack` throughout. +- **Buffer-local autocmds + global events**: `VimLeavePre` is global but our + autocmd uses `buffer = bufnr`. It fires for the active buffer on quit; other + buffers get `BufUnload`. Combined with `once = true`, each buffer's stop + handler fires exactly once. +- **`vim.b` variable cleanup**: Always nil out buffer variables in + `clear_buf_vars()` when stopping. Stale `vim.b.toolpath_tracking = true` on + an untracked buffer would confuse statusline consumers. +- **`vim.validate` positional form**: We use the Neovim 0.10+ positional form + `vim.validate("name", value, "type", optional)`. The older table form is + deprecated. + +## Testing + +Run all tests locally: + +``` +./scripts/test.sh +``` + +This runs `luac -p` syntax checks on all Lua files, then two test suites via +`nvim --headless -l`: + +- **`test/test_internals.lua`** — pure function tests for `format_base_uri`, + `build_parent_map`, and `find_step_ancestor` (exported via `M._internals`). +- **`test/test_api.lua`** — public API surface tests (version format, + `_setup_done` state, `statusline()`, `_get_bin()`, config validation). + +The test harness is `test/harness.lua` (~50 lines, no external dependencies). + +**Adding a new test:** open the appropriate `test/test_*.lua` file and add a +`T.test("name", function() ... end)` block. Use `T.eq(a, b)` for deep equality. + +**CI:** GitHub Actions (`.github/workflows/test.yml`) runs the test script on +every push to `main` and every PR, against Neovim stable and nightly. + +### Manual verification (not covered by automated tests) + +1. `:checkhealth toolpath` — all checks pass +2. Open a file — `vim.b.toolpath_tracking` should be `true` (with auto mode) +3. Make edits — `:ToolpathStatus` shows step count incrementing, no UI lag +4. `:ToolpathStop` — clean shutdown, no errors +5. Check `archive/` — file appears with correct name pattern + +## Coordination with the toolpath CLI + +Design documents in `local/` come from the CLI team. When the CLI adds new +flags or changes behavior, update `local/` with their docs and adapt the +plugin accordingly. The `docs/` directory contains design docs we authored +for the CLI team (e.g. `cli-graph-output.md` was our original proposal; +`local/track-source-annotate.md` is what they actually implemented). diff --git a/README.md b/README.md index a9863c4..7953780 100644 --- a/README.md +++ b/README.md @@ -157,8 +157,9 @@ When a buffer is closed, the session is exported to `archive/` as | `:ToolpathStart [actor]` | Start tracking the current buffer. Optional actor override. | | `:ToolpathStop` | Stop tracking and close the session. | | `:ToolpathNote ` | Annotate the current head step with intent. | +| `:ToolpathAnnotate [text]` | Annotate the head step. Use Lua API for full control. | | `:ToolpathExport [file]` | Export the Path document. Opens in a scratch buffer if no file given. | -| `:ToolpathStatus` | Print session status: path, actor, step count, undo seq, version. | +| `:ToolpathStatus` | Print session status: path, actor, step count, VCS commit, version. | ## Statusline @@ -216,12 +217,30 @@ toolpath.setup(opts) -- configure the plugin toolpath.start(actor, quiet) -- start tracking current buffer toolpath.stop(bufnr) -- stop tracking (default: current buffer) toolpath.note(intent) -- annotate the current head step +toolpath.annotate(opts) -- annotate any step (intent, source, refs) toolpath.export(output_file) -- export Path document toolpath.statusline() -- "[toolpath: N steps]" or "" toolpath.status() -- print session info via vim.notify toolpath.version -- "0.1.0" ``` +## VCS integration + +When a tracked file is inside a git repository, the plugin automatically +anchors the session to VCS state: + +- **Session start**: `--base-uri` (from git remote) and `--base-ref` (HEAD + commit) are passed to `path track init`, setting `path.base` in the output. +- **Each edit**: `--source '{"type":"git","revision":"...","branch":"..."}'` is + passed to `path track step`, landing in `step.meta.source`. +- **Cache refresh**: git HEAD is cached per repo and refreshed on `FocusGained` + and `ShellCmdPost` — no git calls on every keystroke. + +Multiple files in the same repo share `base.ref` and `source.revision` values, +so consumers can correlate their Path documents without session UUIDs. + +Files outside a git repo are tracked normally without VCS metadata. + ## Undo tree navigation The plugin correctly handles undo, redo, and undo tree browsers like diff --git a/doc/toolpath.txt b/doc/toolpath.txt index 8156908..455a437 100644 --- a/doc/toolpath.txt +++ b/doc/toolpath.txt @@ -14,7 +14,8 @@ CONTENTS *toolpath-contents* 6. Statusline .............................. |toolpath-statusline| 7. Buffer variables ........................ |toolpath-variables| 8. Health check ............................ |toolpath-health| - 9. How it works ............................ |toolpath-how-it-works| + 9. VCS integration ......................... |toolpath-vcs| + 10. How it works ............................ |toolpath-how-it-works| ============================================================================== 1. INTRODUCTION *toolpath-introduction* @@ -121,10 +122,16 @@ record ~ If {file} is given, writes to that path. Otherwise opens in a new scratch buffer with filetype=json. + *:ToolpathAnnotate* +:ToolpathAnnotate [text] + Annotate the current head step. Accepts optional intent text. + For full control (targeting specific steps, attaching source or + refs), use |toolpath.annotate()| from Lua. + *:ToolpathStatus* :ToolpathStatus Print current session status: session path, actor, step count, - undo sequence number, and plugin version. + undo sequence number, VCS commit, and plugin version. ============================================================================== 5. LUA API *toolpath-api* @@ -152,6 +159,38 @@ toolpath.note({intent}) toolpath.export([output_file]) Export the Path document. Opens in scratch buffer if no file given. + *toolpath.annotate()* +toolpath.annotate({opts}) + Annotate a step with metadata. {opts} is a table: + + step ~ + string (default: head step) + The step ID to annotate. + + intent ~ + string + Intent text for the step. + + source ~ + table + VCS source object, e.g. `{ type = "git", revision = "abc123" }`. + + refs ~ + table[] + Array of ref objects, e.g. + `{{ rel = "issue", href = "https://..." }}`. + + Example: +>lua + require("toolpath").annotate({ + intent = "fix edge case", + source = { type = "git", revision = "abc123" }, + refs = { + { rel = "issue", href = "https://github.com/org/repo/issues/42" }, + }, + }) +< + *toolpath.statusline()* toolpath.statusline() Returns a string for use in statusline components. @@ -222,7 +261,31 @@ Run `:checkhealth toolpath` to verify your setup. The health check verifies: - Reports plugin version ============================================================================== -9. HOW IT WORKS *toolpath-how-it-works* +9. VCS INTEGRATION *toolpath-vcs* + +When a tracked file is inside a git repository, the plugin automatically +anchors the session to the current VCS state: + +- At session start, `--base-uri` (derived from the git remote) and + `--base-ref` (current HEAD commit) are passed to `path track init`. + This sets `path.base` in the output document. + +- On each edit, `--source` is passed to `path track step` with the + cached git HEAD as a JSON `vcsSource` object: + `{"type":"git","revision":"abc123","branch":"main"}` + +- The git HEAD cache is refreshed on |FocusGained| and |ShellCmdPost| + events, so commits made in external terminals or via |:!| are picked + up without polling on every keystroke. + +When multiple files in the same repo are being tracked, their Path +documents share `base.ref` and `source.revision` values. Consumers can +correlate them by matching these identifiers. + +Files not in a git repo are tracked normally without VCS metadata. + +============================================================================== +10. HOW IT WORKS *toolpath-how-it-works* 1. |:ToolpathStart| (or auto mode) calls `path track init`, piping the buffer content as the initial state. The CLI returns a session file path. diff --git a/lua/toolpath/init.lua b/lua/toolpath/init.lua index 1edc8a2..c835d63 100644 --- a/lua/toolpath/init.lua +++ b/lua/toolpath/init.lua @@ -5,7 +5,7 @@ local M = {} -M.version = "0.1.0" +M.version = "0.2.0" -- Per-buffer state: { session_path, last_seq, async_queue, step_count, ... } local buf_state = {} @@ -120,6 +120,105 @@ local function drain_queue(bufnr) state.queue_running = false end +-- ── Git helpers ────────────────────────────────────────────────────────── + +-- Cache: repo_root → { commit, branch, remote_url } +local git_cache = {} + +--- Resolve the git repo root for a file directory. Returns nil if not in a repo. +local function get_repo_root(file_dir) + local result = vim.system( + { "git", "-C", file_dir, "rev-parse", "--show-toplevel" }, + { text = true } + ):wait() + if result.code ~= 0 then return nil end + return vim.trim(result.stdout) +end + +--- Refresh the git state cache for a repo root. +local function refresh_git_cache(repo_root) + local head = vim.system( + { "git", "-C", repo_root, "rev-parse", "HEAD" }, + { text = true } + ):wait() + if head.code ~= 0 then + git_cache[repo_root] = nil + return nil + end + + local branch = vim.system( + { "git", "-C", repo_root, "rev-parse", "--abbrev-ref", "HEAD" }, + { text = true } + ):wait() + + local remote = vim.system( + { "git", "-C", repo_root, "remote", "get-url", "origin" }, + { text = true } + ):wait() + + git_cache[repo_root] = { + commit = vim.trim(head.stdout), + branch = (branch.code == 0) and vim.trim(branch.stdout) or nil, + remote_url = (remote.code == 0) and vim.trim(remote.stdout) or nil, + } + return git_cache[repo_root] +end + +--- Normalize a git remote URL into a base-uri string. +--- "git@github.com:org/repo.git" → "github:org/repo" +--- "https://github.com/org/repo.git" → "github:org/repo" +--- Falls back to the raw URL if the pattern isn't recognized. +local function format_base_uri(remote_url) + if not remote_url then return nil end + + -- SSH format: git@:/.git + local host, path = remote_url:match("^git@([^:]+):(.+)$") + if not host then + -- HTTPS format: https:////.git + host, path = remote_url:match("^https?://([^/]+)/(.+)$") + end + + if host and path then + path = path:gsub("%.git$", "") + local short = host:match("^([^.]+)%.") + if short then + return short .. ":" .. path + end + return host .. ":" .. path + end + + return remote_url +end + +--- Get cached git state for a file. Returns (state, repo_root) or (nil, nil). +local function get_git_state(file) + local dir = vim.fn.fnamemodify(file, ":h") + local root = get_repo_root(dir) + if not root then return nil, nil end + local state = git_cache[root] or refresh_git_cache(root) + return state, root +end + +--- Build the --source JSON string for a step call from cached git state. +local function build_source_json(repo_root) + local state = git_cache[repo_root] + if not state then return nil end + local obj = { type = "git", revision = state.commit } + if state.branch and state.branch ~= "HEAD" then + obj.branch = state.branch + end + return vim.json.encode(obj) +end + +--- Refresh all cached repos (called on FocusGained, ShellCmdPost). +local function refresh_all_git_caches() + for root, _ in pairs(git_cache) do + refresh_git_cache(root) + end +end + +-- ── Undo tree helpers ──────────────────────────────────────────────────── + --- Build a map of seq → parent_seq from undotree().entries. --- The entries list is the current undo path; each entry's `alt` field --- holds branches that were replaced at that point. @@ -187,6 +286,9 @@ local function on_text_changed(bufnr) state.last_seq = cur_seq state.last_seq_last = seq_last + -- Build --source arg from cached git state + local source_json = state.repo_root and build_source_json(state.repo_root) + if is_new_edit then -- Check for intermediate undo entries (TextChanged batched > 1 entry) local gap = cur_seq - prev_seq @@ -196,12 +298,17 @@ local function on_text_changed(bufnr) for seq = prev_seq + 1, cur_seq - 1 do vim.cmd("silent! undo " .. seq) local content = get_buffer_text(bufnr) - run_cmd({ + local step_args = { "step", "--session", state.session_path, "--seq", tostring(seq), "--parent-seq", tostring(parent_map[seq] or prev_seq), - }, content) + } + if source_json then + table.insert(step_args, "--source") + table.insert(step_args, source_json) + end + run_cmd(step_args, content) state.step_seqs[seq] = true state.step_count = state.step_count + 1 end @@ -212,12 +319,17 @@ local function on_text_changed(bufnr) -- Record the current edit (async) local content = get_buffer_text(bufnr) - enqueue(bufnr, { + local step_args = { "step", "--session", state.session_path, "--seq", tostring(cur_seq), "--parent-seq", tostring(prev_seq), - }, content, function(stdout, ok) + } + if source_json then + table.insert(step_args, "--source") + table.insert(step_args, source_json) + end + enqueue(bufnr, step_args, content, function(stdout, ok) if not buf_state[bufnr] then return end if ok and stdout and stdout ~= "skip" then vim.notify("[toolpath] " .. stdout, vim.log.levels.DEBUG) @@ -261,6 +373,9 @@ function M.start(actor_override, quiet) local actor = get_actor(actor_override) local content = get_buffer_text(bufnr) + -- Resolve git state for VCS anchoring + local git_state, repo_root = get_git_state(file) + local init_args = { "init", "--file", file, "--actor", actor } if actor_def_json then table.insert(init_args, "--actor-def") @@ -279,6 +394,17 @@ function M.start(actor_override, quiet) table.insert(init_args, live_dir) end + -- VCS anchoring: --base-uri and --base-ref + if git_state then + local base_uri = format_base_uri(git_state.remote_url) + if base_uri then + table.insert(init_args, "--base-uri") + table.insert(init_args, base_uri) + end + table.insert(init_args, "--base-ref") + table.insert(init_args, git_state.commit) + end + local session_path = run_cmd(init_args, content) if not session_path then return end @@ -286,6 +412,7 @@ function M.start(actor_override, quiet) buf_state[bufnr] = { session_path = session_path, actor = actor, + repo_root = repo_root, -- nil if file is not in a git repo last_seq = tree.seq_cur or 0, last_seq_last = tree.seq_last or 0, step_seqs = {}, -- seqs that have recorded steps (for ancestor lookup) @@ -364,6 +491,51 @@ function M.note(intent) vim.notify("[toolpath] noted: " .. intent, vim.log.levels.INFO) end +--- Annotate a step with intent, source, and/or refs. +---@param opts table +--- - step: string|nil — step ID to annotate (default: head) +--- - intent: string|nil — intent text +--- - source: table|nil — vcsSource object (e.g. { type = "git", revision = "abc123" }) +--- - refs: table[]|nil — array of ref objects (e.g. {{ rel = "issue", href = "..." }}) +function M.annotate(opts) + opts = opts or {} + local bufnr = vim.api.nvim_get_current_buf() + local state = buf_state[bufnr] + if not state then + vim.notify("[toolpath] not tracking this buffer", vim.log.levels.WARN) + return + end + + local args = { "annotate", "--session", state.session_path } + + if opts.step then + table.insert(args, "--step") + table.insert(args, opts.step) + end + if opts.intent then + table.insert(args, "--intent") + table.insert(args, opts.intent) + end + if opts.source then + table.insert(args, "--source") + table.insert(args, vim.json.encode(opts.source)) + end + if opts.refs then + for _, ref in ipairs(opts.refs) do + table.insert(args, "--ref") + table.insert(args, vim.json.encode(ref)) + end + end + + run_cmd(args) + + if opts.intent then + vim.notify("[toolpath] annotated: " .. opts.intent, vim.log.levels.INFO) + else + vim.notify("[toolpath] step annotated", vim.log.levels.INFO) + end +end + function M.export(output_file) local bufnr = vim.api.nvim_get_current_buf() local state = buf_state[bufnr] @@ -416,14 +588,22 @@ function M.status() return end local tree = vim.fn.undotree() - vim.notify(table.concat({ + local lines = { "[toolpath] status", " session: " .. state.session_path, " actor: " .. state.actor, " steps: " .. state.step_count, " undo seq: " .. (tree.seq_cur or 0), " version: " .. M.version, - }, "\n"), vim.log.levels.INFO) + } + if state.repo_root then + local gs = git_cache[state.repo_root] + if gs then + table.insert(lines, " vcs: " .. gs.commit:sub(1, 12) + .. (gs.branch and (" (" .. gs.branch .. ")") or "")) + end + end + vim.notify(table.concat(lines, "\n"), vim.log.levels.INFO) end --- Internal accessor: resolved binary path (used by health check). @@ -546,7 +726,22 @@ function M.setup(opts) end end + -- Refresh git caches when Neovim regains focus or a shell command completes + -- (catches commits made in external terminals or via :! commands) + local git_group = vim.api.nvim_create_augroup("ToolpathGit", { clear = true }) + vim.api.nvim_create_autocmd({ "FocusGained", "ShellCmdPost" }, { + group = git_group, + callback = refresh_all_git_caches, + }) + M._setup_done = true end +-- Exported for testing only. Not part of the public API. +M._internals = { + format_base_uri = format_base_uri, + build_parent_map = build_parent_map, + find_step_ancestor = find_step_ancestor, +} + return M diff --git a/plugin/toolpath.lua b/plugin/toolpath.lua index a59b80b..088865a 100644 --- a/plugin/toolpath.lua +++ b/plugin/toolpath.lua @@ -12,6 +12,10 @@ vim.api.nvim_create_user_command("ToolpathNote", function(opts) require("toolpath").note(opts.args) end, { nargs = 1, desc = "Set intent on the current head step" }) +vim.api.nvim_create_user_command("ToolpathAnnotate", function(opts) + require("toolpath").annotate({ intent = opts.args ~= "" and opts.args or nil }) +end, { nargs = "?", desc = "Annotate the current head step (optional: intent)" }) + vim.api.nvim_create_user_command("ToolpathExport", function(opts) require("toolpath").export(opts.args ~= "" and opts.args or nil) end, { nargs = "?", desc = "Export Toolpath document (optional: output file)" }) diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..aee84a8 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +# Add the plugin to Neovim's runtimepath so require("toolpath") works +NVIM=(nvim --headless --cmd 'set rtp+=.') + +failed=0 + +echo "=== Syntax check ===" +for f in lua/toolpath/init.lua lua/toolpath/health.lua plugin/toolpath.lua \ + test/harness.lua test/test_internals.lua test/test_api.lua; do + if luac -p "$f"; then + echo " OK $f" + else + echo " FAIL $f" + failed=1 + fi +done + +echo "" +echo "=== test/test_internals.lua ===" +if "${NVIM[@]}" -l test/test_internals.lua; then + : +else + failed=1 +fi + +echo "" +echo "=== test/test_api.lua ===" +if "${NVIM[@]}" -l test/test_api.lua; then + : +else + failed=1 +fi + +echo "" +if [ "$failed" -ne 0 ]; then + echo "SOME CHECKS FAILED" + exit 1 +else + echo "ALL CHECKS PASSED" +fi diff --git a/test/harness.lua b/test/harness.lua new file mode 100644 index 0000000..4d1f27f --- /dev/null +++ b/test/harness.lua @@ -0,0 +1,64 @@ +-- Minimal test harness for toolpath.nvim +-- Usage: local T = require("test.harness") / T.test("name", fn) / os.exit(T.run()) + +local T = {} + +local tests = {} + +function T.test(name, fn) + tests[#tests + 1] = { name = name, fn = fn } +end + +function T.eq(a, b, msg) + if type(a) == "table" and type(b) == "table" then + local ok, err = T._table_eq(a, b, "") + if not ok then + error((msg and (msg .. ": ") or "") .. err, 2) + end + return + end + if a ~= b then + error((msg and (msg .. ": ") or "") + .. "expected " .. tostring(a) .. " == " .. tostring(b), 2) + end +end + +function T._table_eq(a, b, path) + if type(a) ~= type(b) then + return false, path .. ": type mismatch (" .. type(a) .. " vs " .. type(b) .. ")" + end + if type(a) ~= "table" then + if a ~= b then + return false, path .. ": " .. tostring(a) .. " ~= " .. tostring(b) + end + return true + end + for k, v in pairs(a) do + local ok, err = T._table_eq(v, b[k], path .. "." .. tostring(k)) + if not ok then return false, err end + end + for k in pairs(b) do + if a[k] == nil then + return false, path .. "." .. tostring(k) .. ": unexpected key in second table" + end + end + return true +end + +function T.run() + local passed, failed = 0, 0 + for _, t in ipairs(tests) do + local ok, err = pcall(t.fn) + if ok then + passed = passed + 1 + print("PASS " .. t.name) + else + failed = failed + 1 + print("FAIL " .. t.name .. ": " .. tostring(err)) + end + end + print(string.format("\n%d passed, %d failed", passed, failed)) + return failed > 0 and 1 or 0 +end + +return T diff --git a/test/test_api.lua b/test/test_api.lua new file mode 100644 index 0000000..cdc642d --- /dev/null +++ b/test/test_api.lua @@ -0,0 +1,87 @@ +-- Tests for the public API surface (requires headless Neovim) +-- Run with: nvim -l test/test_api.lua + +local T = require("test.harness") + +-- ── Version ──────────────────────────────────────────────────────────── + +T.test("M.version is a semver string", function() + local tp = require("toolpath") + assert(type(tp.version) == "string", "version is not a string") + assert(tp.version:match("^%d+%.%d+%.%d+"), + "version does not match semver: " .. tp.version) +end) + +-- ── Setup state ──────────────────────────────────────────────────────── + +T.test("M._setup_done is false before setup", function() + -- Re-require to get a fresh module (setup hasn't been called in this process) + local tp = require("toolpath") + -- We haven't called setup, but the module may have been loaded already. + -- In a fresh nvim -l invocation, _setup_done should still be false. + T.eq(tp._setup_done, false) +end) + +T.test("M._setup_done is true after setup", function() + local tp = require("toolpath") + tp.setup({}) + T.eq(tp._setup_done, true) +end) + +-- ── Statusline ───────────────────────────────────────────────────────── + +T.test("M.statusline returns empty string when not tracking", function() + local tp = require("toolpath") + T.eq(tp.statusline(), "") +end) + +-- ── Internal accessors ───────────────────────────────────────────────── + +T.test("M._get_bin returns 'path' by default", function() + -- Need a fresh module to test default. Since we already called setup({}), + -- and didn't set bin, it should still be the default. + local tp = require("toolpath") + T.eq(tp._get_bin(), "path") +end) + +T.test("M._get_bin respects opts.bin", function() + package.loaded["toolpath"] = nil + local tp = require("toolpath") + tp.setup({ bin = "/custom/path" }) + T.eq(tp._get_bin(), "/custom/path") +end) + +T.test("M._get_session_dir returns nil without record config", function() + package.loaded["toolpath"] = nil + local tp = require("toolpath") + tp.setup({}) + T.eq(tp._get_session_dir(), nil) +end) + +-- ── Config validation ────────────────────────────────────────────────── + +T.test("M.setup rejects invalid bin type", function() + package.loaded["toolpath"] = nil + local tp = require("toolpath") + local ok, err = pcall(tp.setup, { bin = 42 }) + assert(not ok, "expected error for bin = 42") + assert(tostring(err):find("bin"), "error should mention 'bin': " .. tostring(err)) +end) + +T.test("M.setup rejects invalid actor type", function() + package.loaded["toolpath"] = nil + local tp = require("toolpath") + local ok, err = pcall(tp.setup, { actor = {} }) + assert(not ok, "expected error for actor = {}") + assert(tostring(err):find("actor"), "error should mention 'actor': " .. tostring(err)) +end) + +T.test("M.setup rejects invalid record type", function() + package.loaded["toolpath"] = nil + local tp = require("toolpath") + local ok, err = pcall(tp.setup, { record = "bad" }) + assert(not ok, "expected error for record = 'bad'") + assert(tostring(err):find("record"), "error should mention 'record': " .. tostring(err)) +end) + +os.exit(T.run()) diff --git a/test/test_internals.lua b/test/test_internals.lua new file mode 100644 index 0000000..d314a4f --- /dev/null +++ b/test/test_internals.lua @@ -0,0 +1,90 @@ +-- Tests for pure functions exported via M._internals +-- Run with: nvim -l test/test_internals.lua + +local T = require("test.harness") +local I = require("toolpath")._internals + +-- ── format_base_uri ──────────────────────────────────────────────────── + +T.test("format_base_uri: SSH github URL", function() + T.eq(I.format_base_uri("git@github.com:org/repo.git"), "github:org/repo") +end) + +T.test("format_base_uri: HTTPS github URL", function() + T.eq(I.format_base_uri("https://github.com/org/repo.git"), "github:org/repo") +end) + +T.test("format_base_uri: SSH gitlab URL", function() + T.eq(I.format_base_uri("git@gitlab.com:team/project.git"), "gitlab:team/project") +end) + +T.test("format_base_uri: HTTPS without .git suffix", function() + T.eq(I.format_base_uri("https://github.com/org/repo"), "github:org/repo") +end) + +T.test("format_base_uri: nil input", function() + T.eq(I.format_base_uri(nil), nil) +end) + +T.test("format_base_uri: unrecognized format fallback", function() + T.eq(I.format_base_uri("file:///local/repo"), "file:///local/repo") +end) + +T.test("format_base_uri: nested path", function() + T.eq(I.format_base_uri("git@github.com:org/sub/repo.git"), "github:org/sub/repo") +end) + +-- ── build_parent_map ─────────────────────────────────────────────────── + +T.test("build_parent_map: linear chain", function() + local entries = { { seq = 1 }, { seq = 2 }, { seq = 3 } } + local map = I.build_parent_map(entries) + T.eq(map, { [1] = 0, [2] = 1, [3] = 2 }) +end) + +T.test("build_parent_map: branching with alt entries", function() + -- seq 1 → seq 2 (main), with an alt branch at seq 1 → seq 3 + local entries = { + { seq = 1 }, + { seq = 2, alt = { { seq = 3 }, { seq = 4 } } }, + } + local map = I.build_parent_map(entries) + -- Main: 1→0, 2→1. Alt branch forks from same parent as seq 2 (i.e. 1): 3→1, 4→3 + T.eq(map[1], 0) + T.eq(map[2], 1) + T.eq(map[3], 1) + T.eq(map[4], 3) +end) + +T.test("build_parent_map: empty entries", function() + local map = I.build_parent_map({}) + T.eq(map, {}) +end) + +-- ── find_step_ancestor ───────────────────────────────────────────────── + +T.test("find_step_ancestor: direct parent is a step", function() + local parent_map = { [1] = 0, [2] = 1, [3] = 2 } + local step_seqs = { [2] = true } + T.eq(I.find_step_ancestor(parent_map, 3, step_seqs), 2) +end) + +T.test("find_step_ancestor: ancestor several levels up", function() + local parent_map = { [1] = 0, [2] = 1, [3] = 2, [4] = 3 } + local step_seqs = { [1] = true } + T.eq(I.find_step_ancestor(parent_map, 4, step_seqs), 1) +end) + +T.test("find_step_ancestor: no ancestor returns 0", function() + local parent_map = { [1] = 0, [2] = 1 } + local step_seqs = {} + T.eq(I.find_step_ancestor(parent_map, 2, step_seqs), 0) +end) + +T.test("find_step_ancestor: target seq is 0 (initial state)", function() + local parent_map = { [1] = 0 } + local step_seqs = {} + T.eq(I.find_step_ancestor(parent_map, 1, step_seqs), 0) +end) + +os.exit(T.run())