feat(sync): backend interface + CLI refactor (#42)

* refactor(sync): extract backend interface, adapt gcal module

Problem: :Pending sync hardcodes Google Calendar — M.sync() does
pcall(require, 'pending.sync.gcal') and calls gcal.sync() directly.
The config has a flat gcal field. This prevents adding new sync backends
without modifying init.lua.

Solution: Define a backend interface contract (name, auth, sync, health
fields), refactor :Pending sync to dispatch via require('pending.sync.'
.. backend_name), add sync table to config with legacy gcal migration,
rename gcal.authorize to gcal.auth, add gcal.health for checkhealth,
and add tab completion for backend names and actions.

* docs(sync): update vimdoc for backend interface

Problem: Vimdoc documents :Pending sync as a bare command that pushes
to Google Calendar, with no mention of backends or the sync table config.

Solution: Update :Pending sync section to show {backend} [{action}]
syntax with examples, add SYNC BACKENDS section documenting the interface
contract, update config example to use sync.gcal, document legacy gcal
migration, and update health check description.

* test(sync): add backend dispatch tests

Problem: No test coverage for sync dispatch logic, config migration,
or gcal module interface conformance.

Solution: Add spec/sync_spec.lua with tests for: bare sync errors,
empty backend errors, unknown backend errors, unknown action errors,
default-to-sync routing, explicit sync/auth routing, legacy gcal config
migration, explicit sync.gcal precedence, and gcal module interface
fields (name, auth, sync, health).
This commit is contained in:
Barrett Ruth 2026-02-26 17:59:04 -05:00
parent 85cf0d42ed
commit 306e11aee6
7 changed files with 327 additions and 36 deletions

174
spec/sync_spec.lua Normal file
View file

@ -0,0 +1,174 @@
require('spec.helpers')
local config = require('pending.config')
local store = require('pending.store')
describe('sync', function()
local tmpdir
local pending
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
package.loaded['pending'] = nil
pending = require('pending')
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
store.unload()
package.loaded['pending'] = nil
end)
describe('dispatch', function()
it('errors on bare :Pending sync with no backend', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.sync(nil)
vim.notify = orig
assert.are.equal('Usage: :Pending sync <backend> [action]', msg)
end)
it('errors on empty backend string', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.sync('')
vim.notify = orig
assert.are.equal('Usage: :Pending sync <backend> [action]', msg)
end)
it('errors on unknown backend', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.sync('notreal')
vim.notify = orig
assert.are.equal('Unknown sync backend: notreal', msg)
end)
it('errors on unknown action for valid backend', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.sync('gcal', 'notreal')
vim.notify = orig
assert.are.equal("gcal backend has no 'notreal' action", msg)
end)
it('defaults to sync action when action is omitted', function()
local called = false
local gcal = require('pending.sync.gcal')
local orig_sync = gcal.sync
gcal.sync = function()
called = true
end
pending.sync('gcal')
gcal.sync = orig_sync
assert.is_true(called)
end)
it('routes explicit sync action', function()
local called = false
local gcal = require('pending.sync.gcal')
local orig_sync = gcal.sync
gcal.sync = function()
called = true
end
pending.sync('gcal', 'sync')
gcal.sync = orig_sync
assert.is_true(called)
end)
it('routes auth action', function()
local called = false
local gcal = require('pending.sync.gcal')
local orig_auth = gcal.auth
gcal.auth = function()
called = true
end
pending.sync('gcal', 'auth')
gcal.auth = orig_auth
assert.is_true(called)
end)
end)
describe('config migration', function()
it('migrates legacy gcal to sync.gcal', function()
config.reset()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
gcal = { calendar = 'MyCalendar' },
}
local cfg = config.get()
assert.is_not_nil(cfg.sync)
assert.is_not_nil(cfg.sync.gcal)
assert.are.equal('MyCalendar', cfg.sync.gcal.calendar)
end)
it('does not overwrite explicit sync.gcal with legacy gcal', function()
config.reset()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
gcal = { calendar = 'Legacy' },
sync = { gcal = { calendar = 'Explicit' } },
}
local cfg = config.get()
assert.are.equal('Explicit', cfg.sync.gcal.calendar)
end)
it('works with sync.gcal and no legacy gcal', function()
config.reset()
vim.g.pending = {
data_path = tmpdir .. '/tasks.json',
sync = { gcal = { calendar = 'NewStyle' } },
}
local cfg = config.get()
assert.are.equal('NewStyle', cfg.sync.gcal.calendar)
end)
end)
describe('gcal module', function()
it('has name field', function()
local gcal = require('pending.sync.gcal')
assert.are.equal('gcal', gcal.name)
end)
it('has auth function', function()
local gcal = require('pending.sync.gcal')
assert.are.equal('function', type(gcal.auth))
end)
it('has sync function', function()
local gcal = require('pending.sync.gcal')
assert.are.equal('function', type(gcal.sync))
end)
it('has health function', function()
local gcal = require('pending.sync.gcal')
assert.are.equal('function', type(gcal.health))
end)
end)
end)