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 committed by GitHub
parent 64b19360b1
commit 0e0568769d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 819 additions and 703 deletions

View file

@ -21,14 +21,17 @@ local config = require('pending.config')
---@field tasks pending.Task[]
---@field undo pending.Task[][]
---@class pending.Store
---@field path string
---@field _data pending.Data?
local Store = {}
Store.__index = Store
---@class pending.store
local M = {}
local SUPPORTED_VERSION = 1
---@type pending.Data?
local _data = nil
---@return pending.Data
local function empty_data()
return {
@ -137,18 +140,18 @@ local function table_to_task(t)
end
---@return pending.Data
function M.load()
local path = config.get().data_path
function Store:load()
local path = self.path
local f = io.open(path, 'r')
if not f then
_data = empty_data()
return _data
self._data = empty_data()
return self._data
end
local content = f:read('*a')
f:close()
if content == '' then
_data = empty_data()
return _data
self._data = empty_data()
return self._data
end
local ok, decoded = pcall(vim.json.decode, content)
if not ok then
@ -163,14 +166,14 @@ function M.load()
.. '. Please update the plugin.'
)
end
_data = {
self._data = {
version = decoded.version or SUPPORTED_VERSION,
next_id = decoded.next_id or 1,
tasks = {},
undo = {},
}
for _, t in ipairs(decoded.tasks or {}) do
table.insert(_data.tasks, table_to_task(t))
table.insert(self._data.tasks, table_to_task(t))
end
for _, snapshot in ipairs(decoded.undo or {}) do
if type(snapshot) == 'table' then
@ -178,29 +181,29 @@ function M.load()
for _, raw in ipairs(snapshot) do
table.insert(tasks, table_to_task(raw))
end
table.insert(_data.undo, tasks)
table.insert(self._data.undo, tasks)
end
end
return _data
return self._data
end
---@return nil
function M.save()
if not _data then
function Store:save()
if not self._data then
return
end
local path = config.get().data_path
local path = self.path
ensure_dir(path)
local out = {
version = _data.version,
next_id = _data.next_id,
version = self._data.version,
next_id = self._data.next_id,
tasks = {},
undo = {},
}
for _, task in ipairs(_data.tasks) do
for _, task in ipairs(self._data.tasks) do
table.insert(out.tasks, task_to_table(task))
end
for _, snapshot in ipairs(_data.undo) do
for _, snapshot in ipairs(self._data.undo) do
local serialized = {}
for _, task in ipairs(snapshot) do
table.insert(serialized, task_to_table(task))
@ -223,22 +226,22 @@ function M.save()
end
---@return pending.Data
function M.data()
if not _data then
M.load()
function Store:data()
if not self._data then
self:load()
end
return _data --[[@as pending.Data]]
return self._data --[[@as pending.Data]]
end
---@return pending.Task[]
function M.tasks()
return M.data().tasks
function Store:tasks()
return self:data().tasks
end
---@return pending.Task[]
function M.active_tasks()
function Store:active_tasks()
local result = {}
for _, task in ipairs(M.tasks()) do
for _, task in ipairs(self:tasks()) do
if task.status ~= 'deleted' then
table.insert(result, task)
end
@ -248,8 +251,8 @@ end
---@param id integer
---@return pending.Task?
function M.get(id)
for _, task in ipairs(M.tasks()) do
function Store:get(id)
for _, task in ipairs(self:tasks()) do
if task.id == id then
return task
end
@ -259,8 +262,8 @@ end
---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, recur?: string, recur_mode?: string, order?: integer, _extra?: table }
---@return pending.Task
function M.add(fields)
local data = M.data()
function Store:add(fields)
local data = self:data()
local now = timestamp()
local task = {
id = data.next_id,
@ -285,8 +288,8 @@ end
---@param id integer
---@param fields table<string, any>
---@return pending.Task?
function M.update(id, fields)
local task = M.get(id)
function Store:update(id, fields)
local task = self:get(id)
if not task then
return nil
end
@ -309,14 +312,14 @@ end
---@param id integer
---@return pending.Task?
function M.delete(id)
return M.update(id, { status = 'deleted', ['end'] = timestamp() })
function Store:delete(id)
return self:update(id, { status = 'deleted', ['end'] = timestamp() })
end
---@param id integer
---@return integer?
function M.find_index(id)
for i, task in ipairs(M.tasks()) do
function Store:find_index(id)
for i, task in ipairs(self:tasks()) do
if task.id == id then
return i
end
@ -326,14 +329,14 @@ end
---@param tasks pending.Task[]
---@return nil
function M.replace_tasks(tasks)
M.data().tasks = tasks
function Store:replace_tasks(tasks)
self:data().tasks = tasks
end
---@return pending.Task[]
function M.snapshot()
function Store:snapshot()
local result = {}
for _, task in ipairs(M.active_tasks()) do
for _, task in ipairs(self:active_tasks()) do
local copy = {}
for k, v in pairs(task) do
if k ~= '_extra' then
@ -352,25 +355,44 @@ function M.snapshot()
end
---@return pending.Task[][]
function M.undo_stack()
return M.data().undo
function Store:undo_stack()
return self:data().undo
end
---@param stack pending.Task[][]
---@return nil
function M.set_undo_stack(stack)
M.data().undo = stack
function Store:set_undo_stack(stack)
self:data().undo = stack
end
---@param id integer
---@return nil
function M.set_next_id(id)
M.data().next_id = id
function Store:set_next_id(id)
self:data().next_id = id
end
---@return nil
function M.unload()
_data = nil
function Store:unload()
self._data = nil
end
---@param path string
---@return pending.Store
function M.new(path)
return setmetatable({ path = path, _data = nil }, Store)
end
---@return string
function M.resolve_path()
local results = vim.fs.find('.pending.json', {
upward = true,
path = vim.fn.getcwd(),
type = 'file',
})
if results and #results > 0 then
return results[1]
end
return config.get().data_path
end
return M