pending.nvim/spec/detail_spec.lua
Barrett Ruth f846155ee5
feat(detail): parse and validate editable frontmatter on save (#163)
Problem: the detail buffer rendered metadata as read-only virtual text
overlays. Users could not edit status, priority, category, due, or
recurrence from the detail view.

Solution: render frontmatter as real `Key: value` text lines highlighted
via extmarks. On `:w`, `parse_detail_frontmatter()` validates every
field (status, priority bounds, `resolve_date`, `recur.validate`) and
aborts with `log.error()` on any invalid input. Removing a line clears
the field; editing the `# title` updates the description.
2026-03-13 11:06:45 -04:00

402 lines
11 KiB
Lua

require('spec.helpers')
local config = require('pending.config')
describe('detail frontmatter', function()
local buffer
local tmpdir
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
package.loaded['pending'] = nil
package.loaded['pending.buffer'] = nil
buffer = require('pending.buffer')
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
package.loaded['pending'] = nil
package.loaded['pending.buffer'] = nil
end)
describe('build_detail_frontmatter', function()
it('renders status and priority for minimal task', function()
local lines = buffer._build_detail_frontmatter({
id = 1,
description = 'Test',
status = 'pending',
priority = 0,
entry = '',
modified = '',
order = 0,
})
assert.are.equal(2, #lines)
assert.are.equal('Status: pending', lines[1])
assert.are.equal('Priority: 0', lines[2])
end)
it('renders all fields', function()
local lines = buffer._build_detail_frontmatter({
id = 1,
description = 'Test',
status = 'wip',
priority = 2,
category = 'Work',
due = '2026-03-15',
recur = 'weekly',
entry = '',
modified = '',
order = 0,
})
assert.are.equal(5, #lines)
assert.are.equal('Status: wip', lines[1])
assert.are.equal('Priority: 2', lines[2])
assert.are.equal('Category: Work', lines[3])
assert.are.equal('Due: 2026-03-15', lines[4])
assert.are.equal('Recur: weekly', lines[5])
end)
it('prefixes recur with ! for completion mode', function()
local lines = buffer._build_detail_frontmatter({
id = 1,
description = 'Test',
status = 'pending',
priority = 0,
recur = 'daily',
recur_mode = 'completion',
entry = '',
modified = '',
order = 0,
})
assert.are.equal('Recur: !daily', lines[3])
end)
it('omits optional fields when absent', function()
local lines = buffer._build_detail_frontmatter({
id = 1,
description = 'Test',
status = 'done',
priority = 1,
entry = '',
modified = '',
order = 0,
})
assert.are.equal(2, #lines)
assert.are.equal('Status: done', lines[1])
assert.are.equal('Priority: 1', lines[2])
end)
end)
describe('parse_detail_frontmatter', function()
it('parses minimal frontmatter', function()
local lines = {
'# My task',
'Status: pending',
'Priority: 0',
'---',
'some notes',
}
local sep, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal(4, sep)
assert.are.equal('My task', fields.description)
assert.are.equal('pending', fields.status)
assert.are.equal(0, fields.priority)
end)
it('parses all fields', function()
local lines = {
'# Fix the bug',
'Status: wip',
'Priority: 2',
'Category: Work',
'Due: 2026-03-15',
'Recur: weekly',
'---',
}
local sep, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal(7, sep)
assert.are.equal('Fix the bug', fields.description)
assert.are.equal('wip', fields.status)
assert.are.equal(2, fields.priority)
assert.are.equal('Work', fields.category)
assert.are.equal('2026-03-15', fields.due)
assert.are.equal('weekly', fields.recur)
end)
it('resolves due date keywords', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 0',
'Due: tomorrow',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
local today = os.date('*t') --[[@as osdate]]
local expected = os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + 1 })
)
assert.are.equal(expected, fields.due)
end)
it('parses completion-mode recurrence', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 0',
'Recur: !daily',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('daily', fields.recur)
assert.are.equal('completion', fields.recur_mode)
end)
it('clears optional fields when lines removed', function()
local lines = {
'# Task',
'Status: done',
'Priority: 1',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal(vim.NIL, fields.category)
assert.are.equal(vim.NIL, fields.due)
assert.are.equal(vim.NIL, fields.recur)
end)
it('skips blank lines in frontmatter', function()
local lines = {
'# Task',
'Status: pending',
'',
'Priority: 0',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('pending', fields.status)
assert.are.equal(0, fields.priority)
end)
it('errors on missing separator', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 0',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('missing separator'))
end)
it('errors on missing title', function()
local lines = {
'',
'Status: pending',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('missing or empty title'))
end)
it('errors on empty title', function()
local lines = {
'# ',
'Status: pending',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('missing or empty title'))
end)
it('errors on invalid status', function()
local lines = {
'# Task',
'Status: bogus',
'Priority: 0',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid status'))
end)
it('errors on negative priority', function()
local lines = {
'# Task',
'Status: pending',
'Priority: -1',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid priority'))
end)
it('errors on non-integer priority', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 1.5',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid priority'))
end)
it('errors on priority exceeding max', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 4',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('max is 3'))
end)
it('errors on invalid due date', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 0',
'Due: notadate',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid due date'))
end)
it('errors on empty due value', function()
local lines = {
'# Task',
'Due: ',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('empty due value'))
end)
it('errors on invalid recurrence', function()
local lines = {
'# Task',
'Recur: nope',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid recurrence'))
end)
it('errors on empty recur value', function()
local lines = {
'# Task',
'Recur: ',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('empty recur value'))
end)
it('errors on empty category value', function()
local lines = {
'# Task',
'Category: ',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('empty category'))
end)
it('errors on unknown field', function()
local lines = {
'# Task',
'Status: pending',
'Foo: bar',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('unknown field: foo'))
end)
it('errors on duplicate field', function()
local lines = {
'# Task',
'Status: pending',
'Status: done',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('duplicate field'))
end)
it('errors on malformed frontmatter line', function()
local lines = {
'# Task',
'not a key value pair',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid frontmatter line'))
end)
it('is case-insensitive for field keys', function()
local lines = {
'# Task',
'STATUS: wip',
'PRIORITY: 1',
'CATEGORY: Work',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('wip', fields.status)
assert.are.equal(1, fields.priority)
assert.are.equal('Work', fields.category)
end)
it('accepts datetime due format', function()
local lines = {
'# Task',
'Due: 2026-03-15T14:00',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('2026-03-15T14:00', fields.due)
end)
it('respects custom max_priority', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', max_priority = 5 }
config.reset()
local lines = {
'# Task',
'Priority: 5',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal(5, fields.priority)
end)
it('updates description from title line', function()
local lines = {
'# Updated title',
'Status: pending',
'Priority: 0',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('Updated title', fields.description)
end)
end)
end)