* fix(diff): preserve due/rec when absent from buffer line Problem: `diff.apply` overwrites `task.due` and `task.recur` with `nil` whenever those fields aren't present as inline tokens in the buffer line. Because metadata is rendered as virtual text (never in the line text), every description edit silently clears due dates and recurrence rules. Solution: Only update `due`, `recur`, and `recur_mode` in the existing- task branch when the parsed entry actually contains them (non-nil). Users can still set/change these inline by typing `due:<date>` or `rec:<rule>`; clearing them requires `:Pending edit <id> -due`. * refactor: remove project-local store discovery Problem: `store.resolve_path()` searched upward for `.pending.json`, silently splitting task data across multiple files depending on CWD. Solution: `resolve_path()` now always returns `config.get().data_path`. Remove `M.init()` and the `:Pending init` command and tab-completion entry. Remove the project-local health message. * refactor: extract log.lua, standardise [pending.nvim]: prefix Problem: Notifications were scattered across files using bare `vim.notify` with inconsistent `pending.nvim: ` prefixes, and the `debug` guard in `textobj.lua` and `init.lua` was duplicated inline. Solution: Add `lua/pending/log.lua` with `info`, `warn`, `error`, and `debug` functions (prefix `[pending.nvim]: `). `log.debug` only fires when `config.debug = true` or the optional `override` param is `true`. Replace all `vim.notify` callsites and remove inline debug guards. * feat(parse): configurable input date formats Problem: `due:` only accepted ISO `YYYY-MM-DD` and built-in keywords; users expecting locale-style dates like `03/15/2026` or `15-Mar-2026` had no way to configure alternative input formats. Solution: Add `input_date_formats` config field (string[]). Each entry is a strftime-like format string supporting `%Y`, `%y`, `%m`, `%d`, `%e`, `%b`, `%B`. Formats are tried in order after built-in keywords fail. When no year specifier is present the current or next year is inferred. Update vimdoc and add 8 parse_spec tests.
291 lines
7.9 KiB
Lua
291 lines
7.9 KiB
Lua
require('spec.helpers')
|
|
|
|
local store = require('pending.store')
|
|
|
|
describe('diff', function()
|
|
local tmpdir
|
|
local s
|
|
local diff = require('pending.diff')
|
|
|
|
before_each(function()
|
|
tmpdir = vim.fn.tempname()
|
|
vim.fn.mkdir(tmpdir, 'p')
|
|
s = store.new(tmpdir .. '/tasks.json')
|
|
s:load()
|
|
end)
|
|
|
|
after_each(function()
|
|
vim.fn.delete(tmpdir, 'rf')
|
|
end)
|
|
|
|
describe('parse_buffer', function()
|
|
it('parses headers and tasks', function()
|
|
local lines = {
|
|
'# School',
|
|
'/1/- [ ] Do homework',
|
|
'/2/- [!] Read chapter 5',
|
|
'',
|
|
'# Errands',
|
|
'/3/- [ ] Buy groceries',
|
|
}
|
|
local result = diff.parse_buffer(lines)
|
|
assert.are.equal(6, #result)
|
|
assert.are.equal('header', result[1].type)
|
|
assert.are.equal('School', result[1].category)
|
|
assert.are.equal('task', result[2].type)
|
|
assert.are.equal(1, result[2].id)
|
|
assert.are.equal('Do homework', result[2].description)
|
|
assert.are.equal('School', result[2].category)
|
|
assert.are.equal('task', result[3].type)
|
|
assert.are.equal(1, result[3].priority)
|
|
assert.are.equal('blank', result[4].type)
|
|
assert.are.equal('Errands', result[6].category)
|
|
end)
|
|
|
|
it('handles new tasks without ids', function()
|
|
local lines = {
|
|
'# Inbox',
|
|
'- [ ] New task here',
|
|
}
|
|
local result = diff.parse_buffer(lines)
|
|
assert.are.equal(2, #result)
|
|
assert.are.equal('task', result[2].type)
|
|
assert.is_nil(result[2].id)
|
|
assert.are.equal('New task here', result[2].description)
|
|
end)
|
|
|
|
it('inline cat: token overrides header category', function()
|
|
local lines = {
|
|
'# Inbox',
|
|
'/1/- [ ] Buy milk cat:Work',
|
|
}
|
|
local result = diff.parse_buffer(lines)
|
|
assert.are.equal(2, #result)
|
|
assert.are.equal('task', result[2].type)
|
|
assert.are.equal('Work', result[2].category)
|
|
end)
|
|
|
|
it('extracts rec: token from buffer line', function()
|
|
local lines = {
|
|
'# Inbox',
|
|
'/1/- [ ] Take trash out rec:weekly',
|
|
}
|
|
local result = diff.parse_buffer(lines)
|
|
assert.are.equal('weekly', result[2].rec)
|
|
end)
|
|
|
|
it('extracts rec: with completion mode', function()
|
|
local lines = {
|
|
'# Inbox',
|
|
'/1/- [ ] Water plants rec:!daily',
|
|
}
|
|
local result = diff.parse_buffer(lines)
|
|
assert.are.equal('daily', result[2].rec)
|
|
assert.are.equal('completion', result[2].rec_mode)
|
|
end)
|
|
|
|
it('inline due: token is parsed', function()
|
|
local lines = {
|
|
'# Inbox',
|
|
'/1/- [ ] Buy milk due:2026-03-15',
|
|
}
|
|
local result = diff.parse_buffer(lines)
|
|
assert.are.equal(2, #result)
|
|
assert.are.equal('task', result[2].type)
|
|
assert.are.equal('2026-03-15', result[2].due)
|
|
end)
|
|
end)
|
|
|
|
describe('apply', function()
|
|
it('creates new tasks from buffer lines', function()
|
|
local lines = {
|
|
'# Inbox',
|
|
'- [ ] First task',
|
|
'- [ ] Second task',
|
|
}
|
|
diff.apply(lines, s)
|
|
s:load()
|
|
local tasks = s:active_tasks()
|
|
assert.are.equal(2, #tasks)
|
|
assert.are.equal('First task', tasks[1].description)
|
|
assert.are.equal('Second task', tasks[2].description)
|
|
end)
|
|
|
|
it('deletes tasks removed from buffer', function()
|
|
s:add({ description = 'Keep me' })
|
|
s:add({ description = 'Delete me' })
|
|
s:save()
|
|
local lines = {
|
|
'# Inbox',
|
|
'/1/- [ ] Keep me',
|
|
}
|
|
diff.apply(lines, s)
|
|
s:load()
|
|
local active = s:active_tasks()
|
|
assert.are.equal(1, #active)
|
|
assert.are.equal('Keep me', active[1].description)
|
|
local deleted = s:get(2)
|
|
assert.are.equal('deleted', deleted.status)
|
|
end)
|
|
|
|
it('updates modified tasks', function()
|
|
s:add({ description = 'Original' })
|
|
s:save()
|
|
local lines = {
|
|
'# Inbox',
|
|
'/1/- [ ] Renamed',
|
|
}
|
|
diff.apply(lines, s)
|
|
s:load()
|
|
local task = s:get(1)
|
|
assert.are.equal('Renamed', task.description)
|
|
end)
|
|
|
|
it('updates modified when description is renamed', function()
|
|
local t = s:add({ description = 'Original', category = 'Inbox' })
|
|
t.modified = '2020-01-01T00:00:00Z'
|
|
s:save()
|
|
local lines = {
|
|
'# Inbox',
|
|
'/1/- [ ] Renamed',
|
|
}
|
|
diff.apply(lines, s)
|
|
s:load()
|
|
local task = s:get(1)
|
|
assert.are.equal('Renamed', task.description)
|
|
assert.is_not.equal('2020-01-01T00:00:00Z', task.modified)
|
|
end)
|
|
|
|
it('handles duplicate ids as copies', function()
|
|
s:add({ description = 'Original' })
|
|
s:save()
|
|
local lines = {
|
|
'# Inbox',
|
|
'/1/- [ ] Original',
|
|
'/1/- [ ] Copy of original',
|
|
}
|
|
diff.apply(lines, s)
|
|
s:load()
|
|
local tasks = s:active_tasks()
|
|
assert.are.equal(2, #tasks)
|
|
end)
|
|
|
|
it('moves tasks between categories', function()
|
|
s:add({ description = 'Moving task', category = 'Inbox' })
|
|
s:save()
|
|
local lines = {
|
|
'# Work',
|
|
'/1/- [ ] Moving task',
|
|
}
|
|
diff.apply(lines, s)
|
|
s:load()
|
|
local task = s:get(1)
|
|
assert.are.equal('Work', task.category)
|
|
end)
|
|
|
|
it('does not update modified when task is unchanged', function()
|
|
s:add({ description = 'Stable task', category = 'Inbox' })
|
|
s:save()
|
|
local lines = {
|
|
'# Inbox',
|
|
'/1/- [ ] Stable task',
|
|
}
|
|
diff.apply(lines, s)
|
|
s:load()
|
|
local modified_after_first = s:get(1).modified
|
|
diff.apply(lines, s)
|
|
s:load()
|
|
local task = s:get(1)
|
|
assert.are.equal(modified_after_first, task.modified)
|
|
end)
|
|
|
|
it('preserves due when not present in buffer line', function()
|
|
s:add({ description = 'Pay bill', due = '2026-03-15' })
|
|
s:save()
|
|
local lines = {
|
|
'# Inbox',
|
|
'/1/- [ ] Pay bill',
|
|
}
|
|
diff.apply(lines, s)
|
|
s:load()
|
|
local task = s:get(1)
|
|
assert.are.equal('2026-03-15', task.due)
|
|
end)
|
|
|
|
it('updates due when inline token is present', function()
|
|
s:add({ description = 'Pay bill', due = '2026-03-15' })
|
|
s:save()
|
|
local lines = {
|
|
'# Inbox',
|
|
'/1/- [ ] Pay bill due:2026-04-01',
|
|
}
|
|
diff.apply(lines, s)
|
|
s:load()
|
|
local task = s:get(1)
|
|
assert.are.equal('2026-04-01', task.due)
|
|
end)
|
|
|
|
it('stores recur field on new tasks from buffer', function()
|
|
local lines = {
|
|
'# Inbox',
|
|
'- [ ] Take out trash rec:weekly',
|
|
}
|
|
diff.apply(lines, s)
|
|
s:load()
|
|
local tasks = s:active_tasks()
|
|
assert.are.equal(1, #tasks)
|
|
assert.are.equal('weekly', tasks[1].recur)
|
|
end)
|
|
|
|
it('updates recur field when changed inline', function()
|
|
s:add({ description = 'Task', recur = 'daily' })
|
|
s:save()
|
|
local lines = {
|
|
'# Todo',
|
|
'/1/- [ ] Task rec:weekly',
|
|
}
|
|
diff.apply(lines, s)
|
|
s:load()
|
|
local task = s:get(1)
|
|
assert.are.equal('weekly', task.recur)
|
|
end)
|
|
|
|
it('preserves recur when not present in buffer line', function()
|
|
s:add({ description = 'Task', recur = 'daily' })
|
|
s:save()
|
|
local lines = {
|
|
'# Todo',
|
|
'/1/- [ ] Task',
|
|
}
|
|
diff.apply(lines, s)
|
|
s:load()
|
|
local task = s:get(1)
|
|
assert.are.equal('daily', task.recur)
|
|
end)
|
|
|
|
it('parses rec: with completion mode prefix', function()
|
|
local lines = {
|
|
'# Inbox',
|
|
'- [ ] Water plants rec:!weekly',
|
|
}
|
|
diff.apply(lines, s)
|
|
s:load()
|
|
local tasks = s:active_tasks()
|
|
assert.are.equal('weekly', tasks[1].recur)
|
|
assert.are.equal('completion', tasks[1].recur_mode)
|
|
end)
|
|
|
|
it('clears priority when [N] is removed from buffer line', function()
|
|
s:add({ description = 'Task name', priority = 1 })
|
|
s:save()
|
|
local lines = {
|
|
'# Inbox',
|
|
'/1/- [ ] Task name',
|
|
}
|
|
diff.apply(lines, s)
|
|
s:load()
|
|
local task = s:get(1)
|
|
assert.are.equal(0, task.priority)
|
|
end)
|
|
end)
|
|
end)
|