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.
This commit is contained in:
parent
20506af849
commit
649f238683
2 changed files with 585 additions and 67 deletions
402
spec/detail_spec.lua
Normal file
402
spec/detail_spec.lua
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue