refactor: organize tests and dry (#49)

* refactor(store): convert singleton to Store.new() factory

Problem: store.lua used module-level _data singleton, making
project-local stores impossible and creating hidden global state.

Solution: introduce Store metatable with all operations as instance
methods. M.new(path) constructs an instance; M.resolve_path()
searches upward for .pending.json and falls back to
config.get().data_path. Singleton module API is removed.

* refactor(diff): accept store instance as parameter

Problem: diff.apply called store singleton methods directly, coupling
it to global state and preventing use with project-local stores.

Solution: change signature to apply(lines, s, hidden_ids?) where s is
a pending.Store instance. All store operations now go through s.

* refactor(buffer): add set_store/store accessors, drop singleton dep

Problem: buffer.lua imported store directly and called singleton
methods, preventing it from working with per-project store instances.

Solution: add module-level _store, M.set_store(s), and M.store()
accessors. open() and render() use _store instead of the singleton.
init.lua will call buffer.set_store(s) before buffer.open().

* refactor(complete,health,sync,plugin): update callers to store instance API

Problem: complete.lua, health.lua, sync/gcal.lua, and plugin/pending.lua
all called singleton store methods directly.

Solution: complete.lua uses buffer.store() for category lookups;
health.lua uses store.new(store.resolve_path()) and reports the
resolved path; gcal.lua calls require('pending').store() for task
access; plugin tab-completion creates ephemeral store instances via
store.new(store.resolve_path()). Add 'init' to the subcommands list.

* feat(init): thread Store instance through init, add :Pending init

Problem: init.lua called singleton store methods throughout, and there
was no way to create a project-local .pending.json file.

Solution: add module-level _store and private get_store() that
lazy-constructs via store.new(store.resolve_path()). Add public
M.store() accessor used by specs and sync backends. M.open() calls
buffer.set_store(get_store()) before buffer.open(). All store
callsites converted to get_store():method(). goto_file() and
add_here() derive the data directory from get_store().path.

Add M.init() which creates .pending.json in cwd and dispatches from
M.command() as ':Pending init'.

* test: update all specs for Store instance API

Problem: every spec used the old singleton API (store.unload(),
store.load(), store.add(), etc.) and diff.apply(lines, hidden).

Solution: lower-level specs (store, diff, views, complete, file) use
s = store.new(path); s:load() directly. Higher-level specs (archive,
edit, filter, status, sync) reset package.loaded['pending'] in
before_each and use pending.store() to access the live instance.
diff.apply calls updated to diff.apply(lines, s, hidden_ids).

* docs(pending): document :Pending init and store resolution

Add *pending-store-resolution* section explaining upward .pending.json
discovery and fallback to the global data_path. Document :Pending init
under COMMANDS. Add a cross-reference from the data_path config field.

* ci: format

* ci: remove unused variable
This commit is contained in:
Barrett Ruth 2026-02-26 20:03:42 -05:00
parent dbd76d6759
commit 41bda24570
19 changed files with 819 additions and 703 deletions

View file

@ -1,5 +1,4 @@
local buffer = require('pending.buffer')
local config = require('pending.config')
local diff = require('pending.diff')
local parse = require('pending.parse')
local store = require('pending.store')
@ -19,6 +18,22 @@ local UNDO_MAX = 20
---@type pending.Counts?
local _counts = nil
---@type pending.Store?
local _store = nil
---@return pending.Store
local function get_store()
if not _store then
_store = store.new(store.resolve_path())
end
return _store
end
---@return pending.Store
function M.store()
return get_store()
end
---@return nil
function M._recompute_counts()
local cfg = require('pending.config').get()
@ -30,7 +45,7 @@ function M._recompute_counts()
local next_due = nil ---@type string?
local today_str = os.date('%Y-%m-%d') --[[@as string]]
for _, task in ipairs(store.active_tasks()) do
for _, task in ipairs(get_store():active_tasks()) do
if task.status == 'pending' then
pending = pending + 1
if task.priority > 0 then
@ -63,14 +78,14 @@ end
---@return nil
local function _save_and_notify()
store.save()
get_store():save()
M._recompute_counts()
end
---@return pending.Counts
function M.counts()
if not _counts then
store.load()
get_store():load()
M._recompute_counts()
end
return _counts --[[@as pending.Counts]]
@ -138,6 +153,8 @@ end
---@return integer bufnr
function M.open()
local s = get_store()
buffer.set_store(s)
local bufnr = buffer.open()
M._setup_autocmds(bufnr)
M._setup_buf_mappings(bufnr)
@ -159,7 +176,7 @@ function M.filter(pred_str)
for word in pred_str:gmatch('%S+') do
table.insert(predicates, word)
end
local tasks = store.active_tasks()
local tasks = get_store():active_tasks()
local hidden = compute_hidden_ids(tasks, predicates)
buffer.set_filter(predicates, hidden)
local bufnr = buffer.bufnr()
@ -184,7 +201,7 @@ function M._setup_autocmds(bufnr)
buffer = bufnr,
callback = function()
if not vim.bo[bufnr].modified then
store.load()
get_store():load()
buffer.render(bufnr)
end
end,
@ -333,29 +350,31 @@ function M._on_write(bufnr)
elseif #buffer.filter_predicates() > 0 then
predicates = {}
end
local tasks = store.active_tasks()
local s = get_store()
local tasks = s:active_tasks()
local hidden = compute_hidden_ids(tasks, predicates)
buffer.set_filter(predicates, hidden)
local snapshot = store.snapshot()
local stack = store.undo_stack()
local snapshot = s:snapshot()
local stack = s:undo_stack()
table.insert(stack, snapshot)
if #stack > UNDO_MAX then
table.remove(stack, 1)
end
diff.apply(lines, hidden)
diff.apply(lines, s, hidden)
M._recompute_counts()
buffer.render(bufnr)
end
---@return nil
function M.undo_write()
local stack = store.undo_stack()
local s = get_store()
local stack = s:undo_stack()
if #stack == 0 then
vim.notify('Nothing to undo.', vim.log.levels.WARN)
return
end
local state = table.remove(stack)
store.replace_tasks(state)
s:replace_tasks(state)
_save_and_notify()
buffer.render(buffer.bufnr())
end
@ -375,18 +394,19 @@ function M.toggle_complete()
if not id then
return
end
local task = store.get(id)
local s = get_store()
local task = s:get(id)
if not task then
return
end
if task.status == 'done' then
store.update(id, { status = 'pending', ['end'] = vim.NIL })
s:update(id, { status = 'pending', ['end'] = vim.NIL })
else
if task.recur and task.due then
local recur = require('pending.recur')
local mode = task.recur_mode or 'scheduled'
local next_date = recur.next_due(task.due, task.recur, mode)
store.add({
s:add({
description = task.description,
category = task.category,
priority = task.priority,
@ -395,7 +415,7 @@ function M.toggle_complete()
recur_mode = task.recur_mode,
})
end
store.update(id, { status = 'done' })
s:update(id, { status = 'done' })
end
_save_and_notify()
buffer.render(bufnr)
@ -422,12 +442,13 @@ function M.toggle_priority()
if not id then
return
end
local task = store.get(id)
local s = get_store()
local task = s:get(id)
if not task then
return
end
local new_priority = task.priority > 0 and 0 or 1
store.update(id, { priority = new_priority })
s:update(id, { priority = new_priority })
_save_and_notify()
buffer.render(bufnr)
for lnum, m in ipairs(buffer.meta()) do
@ -470,7 +491,7 @@ function M.prompt_date()
return
end
end
store.update(id, { due = due })
get_store():update(id, { due = due })
_save_and_notify()
buffer.render(bufnr)
end)
@ -483,13 +504,14 @@ function M.add(text)
vim.notify('Usage: :Pending add <description>', vim.log.levels.ERROR)
return
end
store.load()
local s = get_store()
s:load()
local description, metadata = parse.command_add(text)
if not description or description == '' then
vim.notify('Pending must have a description.', vim.log.levels.ERROR)
return
end
store.add({
s:add({
description = description,
category = metadata.cat,
due = metadata.due,
@ -530,12 +552,13 @@ end
function M.archive(days)
days = days or 30
local cutoff = os.time() - (days * 86400)
local tasks = store.tasks()
local s = get_store()
local tasks = s:tasks()
local archived = 0
local kept = {}
for _, task in ipairs(tasks) do
if (task.status == 'done' or task.status == 'deleted') and task['end'] then
local y, mo, d, h, mi, s = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$')
local y, mo, d, h, mi, sec = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$')
if y then
local t = os.time({
year = tonumber(y) --[[@as integer]],
@ -543,7 +566,7 @@ function M.archive(days)
day = tonumber(d) --[[@as integer]],
hour = tonumber(h) --[[@as integer]],
min = tonumber(mi) --[[@as integer]],
sec = tonumber(s) --[[@as integer]],
sec = tonumber(sec) --[[@as integer]],
})
if t < cutoff then
archived = archived + 1
@ -554,7 +577,7 @@ function M.archive(days)
table.insert(kept, task)
::skip::
end
store.replace_tasks(kept)
s:replace_tasks(kept)
_save_and_notify()
vim.notify('Archived ' .. archived .. ' tasks.')
local bufnr = buffer.bufnr()
@ -578,7 +601,7 @@ function M.due()
and m.status ~= 'done'
and (parse.is_overdue(m.raw_due) or parse.is_today(m.raw_due))
then
local task = store.get(m.id or 0)
local task = get_store():get(m.id or 0)
local label = parse.is_overdue(m.raw_due) and '[OVERDUE] ' or '[DUE] '
table.insert(qf_items, {
bufnr = bufnr,
@ -589,8 +612,9 @@ function M.due()
end
end
else
store.load()
for _, task in ipairs(store.active_tasks()) do
local s = get_store()
s:load()
for _, task in ipairs(s:active_tasks()) do
if
task.status == 'pending'
and task.due
@ -712,8 +736,9 @@ function M.edit(id_str, rest)
return
end
store.load()
local task = store.get(id)
local s = get_store()
s:load()
local task = s:get(id)
if not task then
vim.notify('No task with ID ' .. id .. '.', vim.log.levels.ERROR)
return
@ -776,17 +801,17 @@ function M.edit(id_str, rest)
end
end
local snapshot = store.snapshot()
local stack = store.undo_stack()
local snapshot = s:snapshot()
local stack = s:undo_stack()
table.insert(stack, snapshot)
if #stack > UNDO_MAX then
table.remove(stack, 1)
end
store.update(id, updates)
s:update(id, updates)
if updates.file_clear then
local t = store.get(id)
local t = s:get(id)
if t and t._extra then
t._extra.file = nil
if next(t._extra) == nil then
@ -796,7 +821,7 @@ function M.edit(id_str, rest)
end
end
store.save()
s:save()
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
@ -819,7 +844,7 @@ function M.goto_file()
vim.notify('No task on this line', vim.log.levels.WARN)
return
end
local task = store.get(m.id)
local task = get_store():get(m.id)
if not task or not task._extra or not task._extra.file then
vim.notify('No file attached to this task', vim.log.levels.WARN)
return
@ -830,7 +855,7 @@ function M.goto_file()
vim.notify('Invalid file spec: ' .. file_spec, vim.log.levels.ERROR)
return
end
local data_dir = vim.fn.fnamemodify(config.get().data_path, ':h')
local data_dir = vim.fn.fnamemodify(get_store().path, ':h')
local abs_path = data_dir .. '/' .. rel_path
if vim.fn.filereadable(abs_path) == 0 then
vim.notify('File not found: ' .. abs_path, vim.log.levels.ERROR)
@ -854,7 +879,8 @@ function M.add_here()
return
end
local cur_lnum = vim.api.nvim_win_get_cursor(0)[1]
local data_dir = vim.fn.fnamemodify(config.get().data_path, ':h')
local s = get_store()
local data_dir = vim.fn.fnamemodify(s.path, ':h')
local abs_file = vim.fn.fnamemodify(cur_file, ':p')
local rel_file
if abs_file:sub(1, #data_dir + 1) == data_dir .. '/' then
@ -863,8 +889,8 @@ function M.add_here()
rel_file = abs_file
end
local file_spec = rel_file .. ':' .. cur_lnum
store.load()
local tasks = store.active_tasks()
s:load()
local tasks = s:active_tasks()
if #tasks == 0 then
vim.notify('No active tasks', vim.log.levels.INFO)
return
@ -885,11 +911,24 @@ function M.add_here()
task._extra = task._extra or {}
task._extra.file = file_spec
task.modified = os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
store.save()
s:save()
vim.notify('Attached ' .. file_spec .. ' to task ' .. task.id)
end)
end
---@return nil
function M.init()
local path = vim.fn.getcwd() .. '/.pending.json'
if vim.fn.filereadable(path) == 1 then
vim.notify('pending.nvim: .pending.json already exists', vim.log.levels.WARN)
return
end
local s = store.new(path)
s:load()
s:save()
vim.notify('pending.nvim: created ' .. path)
end
---@param args string
---@return nil
function M.command(args)
@ -915,6 +954,8 @@ function M.command(args)
M.filter(rest)
elseif cmd == 'undo' then
M.undo_write()
elseif cmd == 'init' then
M.init()
else
vim.notify('Unknown Pending subcommand: ' .. cmd, vim.log.levels.ERROR)
end