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

View file

@ -47,7 +47,7 @@ REQUIREMENTS *pending-requirements*
- Neovim 0.10+ - Neovim 0.10+
- No external dependencies for local use - No external dependencies for local use
- `curl` and `openssl` are required for Google Calendar sync - `curl` and `openssl` are required for the `gcal` sync backend
============================================================================== ==============================================================================
INSTALL *pending-install* INSTALL *pending-install*
@ -250,10 +250,23 @@ COMMANDS *pending-commands*
Open the list with |:copen| to navigate to each task's category. Open the list with |:copen| to navigate to each task's category.
*:Pending-sync* *:Pending-sync*
:Pending sync :Pending sync {backend} [{action}]
Push pending tasks that have a due date to Google Calendar as all-day Run a sync action against a named backend. {backend} is required — bare
events. Requires |pending-gcal| to be configured. See |pending-gcal| for `:Pending sync` prints a usage message. {action} defaults to `sync`
full details on what gets created, updated, and deleted. when omitted. Each backend lives at `lua/pending/sync/<name>.lua`.
Examples: >vim
:Pending sync gcal " runs gcal.sync()
:Pending sync gcal auth " runs gcal.auth()
:Pending sync gcal sync " explicit sync (same as bare)
<
Tab completion after `:Pending sync ` lists discovered backends.
Tab completion after `:Pending sync gcal ` lists available actions.
Built-in backends: ~
`gcal` Google Calendar one-way push. See |pending-gcal|.
*:Pending-undo* *:Pending-undo*
:Pending undo :Pending undo
@ -440,9 +453,11 @@ loads: >lua
next_task = ']t', next_task = ']t',
prev_task = '[t', prev_task = '[t',
}, },
gcal = { sync = {
calendar = 'Tasks', gcal = {
credentials_path = '/path/to/client_secret.json', calendar = 'Tasks',
credentials_path = '/path/to/client_secret.json',
},
}, },
} }
< <
@ -508,10 +523,16 @@ Fields: ~
vim.g.pending = { debug = true } vim.g.pending = { debug = true }
< <
{sync} (table, default: {}) *pending.SyncConfig*
Sync backend configuration. Each key is a backend
name and the value is the backend-specific config
table. Currently only `gcal` is built-in.
{gcal} (table, default: nil) {gcal} (table, default: nil)
Google Calendar sync configuration. See Legacy shorthand for `sync.gcal`. If `gcal` is set
|pending.GcalConfig|. Omit this field entirely to but `sync.gcal` is not, the value is migrated
disable Google Calendar sync. automatically. New configs should use `sync.gcal`
instead. See |pending.GcalConfig|.
============================================================================== ==============================================================================
LUA API *pending-api* LUA API *pending-api*
@ -632,13 +653,18 @@ not pulled back into pending.nvim.
Configuration: >lua Configuration: >lua
vim.g.pending = { vim.g.pending = {
gcal = { sync = {
calendar = 'Tasks', gcal = {
credentials_path = '/path/to/client_secret.json', calendar = 'Tasks',
credentials_path = '/path/to/client_secret.json',
},
}, },
} }
< <
The legacy `gcal` top-level key is still accepted and migrated automatically.
New configurations should use `sync.gcal`.
*pending.GcalConfig* *pending.GcalConfig*
Fields: ~ Fields: ~
{calendar} (string, default: 'Pendings') {calendar} (string, default: 'Pendings')
@ -654,7 +680,7 @@ Fields: ~
that Google provides or as a bare credentials object. that Google provides or as a bare credentials object.
OAuth flow: ~ OAuth flow: ~
On the first `:Pending sync` call the plugin detects that no refresh token On the first `:Pending sync gcal` call the plugin detects that no refresh token
exists and opens the Google authorization URL in the browser using exists and opens the Google authorization URL in the browser using
|vim.ui.open()|. A temporary local HTTP server listens on port 18392 for the |vim.ui.open()|. A temporary local HTTP server listens on port 18392 for the
OAuth redirect. The PKCE (Proof Key for Code Exchange) flow is used — OAuth redirect. The PKCE (Proof Key for Code Exchange) flow is used —
@ -664,7 +690,7 @@ authorization code is exchanged for tokens and the refresh token is stored at
use the stored refresh token and refresh the access token automatically when use the stored refresh token and refresh the access token automatically when
it is about to expire. it is about to expire.
`:Pending sync` behavior: ~ `:Pending sync gcal` behavior: ~
For each task in the store: For each task in the store:
- A pending task with a due date and no existing event: a new all-day event is - A pending task with a due date and no existing event: a new all-day event is
created and the event ID is stored in the task's `_extra` table. created and the event ID is stored in the task's `_extra` table.
@ -677,6 +703,30 @@ For each task in the store:
A summary notification is shown after sync: `created: N, updated: N, A summary notification is shown after sync: `created: N, updated: N,
deleted: N`. deleted: N`.
==============================================================================
SYNC BACKENDS *pending-sync-backend*
Sync backends are Lua modules under `lua/pending/sync/<name>.lua`. Each
module returns a table conforming to the backend interface: >lua
---@class pending.SyncBackend
---@field name string
---@field auth fun(): nil
---@field sync fun(): nil
---@field health? fun(): nil
<
Required fields: ~
{name} Backend identifier (matches the filename).
{sync} Main sync action. Called by `:Pending sync <name>`.
{auth} Authorization flow. Called by `:Pending sync <name> auth`.
Optional fields: ~
{health} Called by `:checkhealth pending` to report backend-specific
diagnostics (e.g. checking for external tools).
Backend-specific configuration goes under `sync.<name>` in |pending-config|.
============================================================================== ==============================================================================
HIGHLIGHT GROUPS *pending-highlights* HIGHLIGHT GROUPS *pending-highlights*
@ -728,8 +778,8 @@ Checks performed: ~
- Whether the data directory exists (warning if not yet created) - Whether the data directory exists (warning if not yet created)
- Whether the data file exists and can be parsed; reports total task count - Whether the data file exists and can be parsed; reports total task count
- Validates recurrence specs on stored tasks - Validates recurrence specs on stored tasks
- Whether `curl` is available (required for Google Calendar sync) - Discovers sync backends under `lua/pending/sync/` and runs each backend's
- Whether `openssl` is available (required for OAuth PKCE) `health()` function if it exists (e.g. gcal checks for `curl` and `openssl`)
============================================================================== ==============================================================================
DATA FORMAT *pending-data* DATA FORMAT *pending-data*

View file

@ -2,6 +2,9 @@
---@field calendar? string ---@field calendar? string
---@field credentials_path? string ---@field credentials_path? string
---@class pending.SyncConfig
---@field gcal? pending.GcalConfig
---@class pending.Keymaps ---@class pending.Keymaps
---@field close? string|false ---@field close? string|false
---@field toggle? string|false ---@field toggle? string|false
@ -32,6 +35,7 @@
---@field drawer_height? integer ---@field drawer_height? integer
---@field debug? boolean ---@field debug? boolean
---@field keymaps pending.Keymaps ---@field keymaps pending.Keymaps
---@field sync? pending.SyncConfig
---@field gcal? pending.GcalConfig ---@field gcal? pending.GcalConfig
---@class pending.config ---@class pending.config
@ -65,6 +69,7 @@ local defaults = {
next_task = ']t', next_task = ']t',
prev_task = '[t', prev_task = '[t',
}, },
sync = {},
} }
---@type pending.Config? ---@type pending.Config?
@ -77,6 +82,10 @@ function M.get()
end end
local user = vim.g.pending or {} local user = vim.g.pending or {}
_resolved = vim.tbl_deep_extend('force', defaults, user) _resolved = vim.tbl_deep_extend('force', defaults, user)
if _resolved.gcal and not (_resolved.sync and _resolved.sync.gcal) then
_resolved.sync = _resolved.sync or {}
_resolved.sync.gcal = _resolved.gcal
end
return _resolved return _resolved
end end

View file

@ -47,16 +47,18 @@ function M.check()
vim.health.info('No data file yet (will be created on first save)') vim.health.info('No data file yet (will be created on first save)')
end end
if vim.fn.executable('curl') == 1 then local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)
vim.health.ok('curl found (required for Google Calendar sync)') if #sync_paths == 0 then
vim.health.info('No sync backends found')
else else
vim.health.warn('curl not found (needed for Google Calendar sync)') for _, path in ipairs(sync_paths) do
end local name = vim.fn.fnamemodify(path, ':t:r')
local bok, backend = pcall(require, 'pending.sync.' .. name)
if vim.fn.executable('openssl') == 1 then if bok and type(backend.health) == 'function' then
vim.health.ok('openssl found (required for OAuth PKCE)') vim.health.start('pending.nvim: sync/' .. name)
else backend.health()
vim.health.warn('openssl not found (needed for Google Calendar OAuth)') end
end
end end
end end

View file

@ -414,14 +414,25 @@ function M.add(text)
vim.notify('Pending added: ' .. description) vim.notify('Pending added: ' .. description)
end end
---@param backend_name string
---@param action? string
---@return nil ---@return nil
function M.sync() function M.sync(backend_name, action)
local ok, gcal = pcall(require, 'pending.sync.gcal') if not backend_name or backend_name == '' then
if not ok then vim.notify('Usage: :Pending sync <backend> [action]', vim.log.levels.ERROR)
vim.notify('Google Calendar sync module not available.', vim.log.levels.ERROR)
return return
end end
gcal.sync() action = (action and action ~= '') and action or 'sync'
local ok, backend = pcall(require, 'pending.sync.' .. backend_name)
if not ok then
vim.notify('Unknown sync backend: ' .. backend_name, vim.log.levels.ERROR)
return
end
if type(backend[action]) ~= 'function' then
vim.notify(backend_name .. " backend has no '" .. action .. "' action", vim.log.levels.ERROR)
return
end
backend[action]()
end end
---@param days? integer ---@param days? integer
@ -700,7 +711,8 @@ function M.command(args)
local id_str, edit_rest = rest:match('^(%S+)%s*(.*)') local id_str, edit_rest = rest:match('^(%S+)%s*(.*)')
M.edit(id_str, edit_rest) M.edit(id_str, edit_rest)
elseif cmd == 'sync' then elseif cmd == 'sync' then
M.sync() local backend, action = rest:match('^(%S+)%s*(.*)')
M.sync(backend, action)
elseif cmd == 'archive' then elseif cmd == 'archive' then
local d = rest ~= '' and tonumber(rest) or nil local d = rest ~= '' and tonumber(rest) or nil
M.archive(d) M.archive(d)

View file

@ -3,6 +3,8 @@ local store = require('pending.store')
local M = {} local M = {}
M.name = 'gcal'
local BASE_URL = 'https://www.googleapis.com/calendar/v3' local BASE_URL = 'https://www.googleapis.com/calendar/v3'
local TOKEN_URL = 'https://oauth2.googleapis.com/token' local TOKEN_URL = 'https://oauth2.googleapis.com/token'
local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
@ -22,7 +24,7 @@ local SCOPE = 'https://www.googleapis.com/auth/calendar'
---@return table<string, any> ---@return table<string, any>
local function gcal_config() local function gcal_config()
local cfg = config.get() local cfg = config.get()
return cfg.gcal or {} return (cfg.sync and cfg.sync.gcal) or cfg.gcal or {}
end end
---@return string ---@return string
@ -199,7 +201,7 @@ local function get_access_token()
end end
local tokens = load_tokens() local tokens = load_tokens()
if not tokens or not tokens.refresh_token then if not tokens or not tokens.refresh_token then
M.authorize() M.auth()
tokens = load_tokens() tokens = load_tokens()
if not tokens then if not tokens then
return nil return nil
@ -218,7 +220,7 @@ local function get_access_token()
return tokens.access_token return tokens.access_token
end end
function M.authorize() function M.auth()
local creds = load_credentials() local creds = load_credentials()
if not creds then if not creds then
vim.notify( vim.notify(
@ -514,4 +516,18 @@ function M.sync()
) )
end end
---@return nil
function M.health()
if vim.fn.executable('curl') == 1 then
vim.health.ok('curl found (required for gcal sync)')
else
vim.health.warn('curl not found (needed for gcal sync)')
end
if vim.fn.executable('openssl') == 1 then
vim.health.ok('openssl found (required for gcal OAuth PKCE)')
else
vim.health.warn('openssl not found (needed for gcal OAuth)')
end
end
return M return M

View file

@ -171,6 +171,34 @@ end, {
if cmd_line:match('^Pending%s+edit') then if cmd_line:match('^Pending%s+edit') then
return complete_edit(arg_lead, cmd_line) return complete_edit(arg_lead, cmd_line)
end end
if cmd_line:match('^Pending%s+sync') then
local after_sync = cmd_line:match('^Pending%s+sync%s+(.*)')
if not after_sync then
return {}
end
local parts = {}
for part in after_sync:gmatch('%S+') do
table.insert(parts, part)
end
local trailing_space = after_sync:match('%s$')
if #parts == 0 or (#parts == 1 and not trailing_space) then
local backends = {}
local pattern = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)
for _, path in ipairs(pattern) do
local name = vim.fn.fnamemodify(path, ':t:r')
table.insert(backends, name)
end
table.sort(backends)
return filter_candidates(arg_lead, backends)
end
if #parts == 1 and trailing_space then
return filter_candidates(arg_lead, { 'auth', 'sync' })
end
if #parts >= 2 and not trailing_space then
return filter_candidates(arg_lead, { 'auth', 'sync' })
end
return {}
end
return {} return {}
end, end,
}) })

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)