diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua
index f65ebaa..c98ebf9 100644
--- a/lua/pending/buffer.lua
+++ b/lua/pending/buffer.lua
@@ -824,59 +824,46 @@ function M.detail_task_id()
return _detail_task_id
end
----@param bufnr integer
+local VALID_STATUSES = {
+ pending = true,
+ done = true,
+ wip = true,
+ blocked = true,
+ cancelled = true,
+}
+
---@param task pending.Task
----@return nil
-local function apply_detail_extmarks(bufnr, task)
- vim.api.nvim_buf_clear_namespace(bufnr, ns_detail, 0, -1)
- local icons = config.get().icons
- local parts = {}
- local status_label = task.status or 'pending'
- local icon_char = icons[status_label] or icons.pending
- table.insert(parts, { 'Status: [' .. icon_char .. '] ' .. status_label, 'PendingDetailMeta' })
- if task.priority and task.priority > 0 then
- table.insert(parts, { ' ', 'Normal' })
- table.insert(
- parts,
- { 'Priority: ' .. string.rep(icons.priority, task.priority), 'PendingDetailMeta' }
- )
- end
- local line2 = {}
+---@return string[]
+local function build_detail_frontmatter(task)
+ local lines = {}
+ table.insert(lines, 'Status: ' .. (task.status or 'pending'))
+ table.insert(lines, 'Priority: ' .. (task.priority or 0))
if task.category then
- table.insert(line2, { 'Category: ' .. task.category, 'PendingDetailMeta' })
+ table.insert(lines, 'Category: ' .. task.category)
end
if task.due then
- if #line2 > 0 then
- table.insert(line2, { ' ', 'Normal' })
- end
- local due_label = task.due
- local y, mo, d = task.due:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)')
- if y then
- local t = os.time({
- year = tonumber(y) --[[@as integer]],
- month = tonumber(mo) --[[@as integer]],
- day = tonumber(d) --[[@as integer]],
- })
- due_label = os.date(config.get().date_format, t) --[[@as string]]
- end
- table.insert(line2, { 'Due: ' .. due_label, 'PendingDetailMeta' })
+ table.insert(lines, 'Due: ' .. task.due)
end
if task.recur then
- if #line2 > 0 then
- table.insert(line2, { ' ', 'Normal' })
+ local recur_val = task.recur
+ if task.recur_mode == 'completion' then
+ recur_val = '!' .. recur_val
end
- table.insert(line2, { 'Recur: ' .. task.recur, 'PendingDetailMeta' })
+ table.insert(lines, 'Recur: ' .. recur_val)
end
- if #parts > 0 then
- vim.api.nvim_buf_set_extmark(bufnr, ns_detail, 1, 0, {
- virt_text = parts,
- virt_text_pos = 'overlay',
- })
- end
- if #line2 > 0 then
- vim.api.nvim_buf_set_extmark(bufnr, ns_detail, 2, 0, {
- virt_text = line2,
- virt_text_pos = 'overlay',
+ return lines
+end
+
+---@param bufnr integer
+---@param sep_row integer
+---@return nil
+local function apply_detail_extmarks(bufnr, sep_row)
+ vim.api.nvim_buf_clear_namespace(bufnr, ns_detail, 0, -1)
+ for i = 1, sep_row - 1 do
+ vim.api.nvim_buf_set_extmark(bufnr, ns_detail, i, 0, {
+ end_row = i,
+ end_col = #(vim.api.nvim_buf_get_lines(bufnr, i, i + 1, false)[1] or ''),
+ hl_group = 'PendingDetailMeta',
})
end
end
@@ -909,12 +896,12 @@ function M.open_detail(task_id)
vim.bo[bufnr].filetype = 'markdown'
vim.bo[bufnr].swapfile = false
- local lines = {
- '# ' .. task.description,
- '',
- '',
- DETAIL_SEPARATOR,
- }
+ local lines = { '# ' .. task.description }
+ local fm = build_detail_frontmatter(task)
+ for _, fl in ipairs(fm) do
+ table.insert(lines, fl)
+ end
+ table.insert(lines, DETAIL_SEPARATOR)
local notes = task.notes or ''
if notes ~= '' then
for note_line in (notes .. '\n'):gmatch('(.-)\n') do
@@ -927,7 +914,8 @@ function M.open_detail(task_id)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.bo[bufnr].modified = false
- apply_detail_extmarks(bufnr, task)
+ local sep_row = #fm + 1
+ apply_detail_extmarks(bufnr, sep_row)
local winid = task_winid
if winid and vim.api.nvim_win_is_valid(winid) then
@@ -941,8 +929,7 @@ function M.open_detail(task_id)
_detail_bufnr = bufnr
_detail_task_id = task_id
- local separator_row = 3
- local cursor_row = separator_row + 2
+ local cursor_row = sep_row + 2
local total = vim.api.nvim_buf_line_count(bufnr)
if cursor_row > total then
cursor_row = total
@@ -969,6 +956,124 @@ function M.close_detail()
end
end
+---@param lines string[]
+---@return integer? sep_row
+---@return pending.DetailFields? fields
+---@return string? err
+local function parse_detail_frontmatter(lines)
+ local parse = require('pending.parse')
+ local recur = require('pending.recur')
+ local cfg = config.get()
+
+ local sep_row = nil
+ for i, line in ipairs(lines) do
+ if line == DETAIL_SEPARATOR then
+ sep_row = i
+ break
+ end
+ end
+ if not sep_row then
+ return nil, nil, 'missing separator (---)'
+ end
+
+ local desc = lines[1] and lines[1]:match('^# (.+)$')
+ if not desc or desc:match('^%s*$') then
+ return nil, nil, 'missing or empty title (first line must be #
)'
+ end
+
+ ---@class pending.DetailFields
+ ---@field description string
+ ---@field status pending.TaskStatus
+ ---@field priority integer
+ ---@field category? string|userdata
+ ---@field due? string|userdata
+ ---@field recur? string|userdata
+ ---@field recur_mode? pending.RecurMode|userdata
+ local fields = {
+ description = desc,
+ status = 'pending',
+ priority = 0,
+ category = vim.NIL,
+ due = vim.NIL,
+ recur = vim.NIL,
+ recur_mode = vim.NIL,
+ }
+
+ local seen = {} ---@type table
+ for i = 2, sep_row - 1 do
+ local line = lines[i]
+ if line:match('^%s*$') then
+ goto continue
+ end
+ local key, val = line:match('^(%S+):%s*(.*)$')
+ if not key then
+ return nil, nil, 'invalid frontmatter line: ' .. line
+ end
+ key = key:lower()
+ if seen[key] then
+ return nil, nil, 'duplicate field: ' .. key
+ end
+ seen[key] = true
+
+ if key == 'status' then
+ val = val:lower()
+ if not VALID_STATUSES[val] then
+ return nil, nil, 'invalid status: ' .. val
+ end
+ fields.status = val --[[@as pending.TaskStatus]]
+ elseif key == 'priority' then
+ local n = tonumber(val)
+ if not n or n ~= math.floor(n) or n < 0 then
+ return nil, nil, 'invalid priority: ' .. val .. ' (must be integer >= 0)'
+ end
+ local max = cfg.max_priority or 3
+ if n > max then
+ return nil, nil, 'invalid priority: ' .. val .. ' (max is ' .. max .. ')'
+ end
+ fields.priority = n --[[@as integer]]
+ elseif key == 'category' then
+ if val == '' then
+ return nil, nil, 'empty category value'
+ end
+ fields.category = val
+ elseif key == 'due' then
+ if val == '' then
+ return nil, nil, 'empty due value (remove the line to clear)'
+ end
+ local resolved = parse.resolve_date(val)
+ if resolved then
+ fields.due = resolved
+ elseif
+ val:match('^%d%d%d%d%-%d%d%-%d%d$') or val:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$')
+ then
+ fields.due = val
+ else
+ return nil, nil, 'invalid due date: ' .. val
+ end
+ elseif key == 'recur' then
+ if val == '' then
+ return nil, nil, 'empty recur value (remove the line to clear)'
+ end
+ local raw_spec = val
+ local rec_mode = nil
+ if raw_spec:sub(1, 1) == '!' then
+ rec_mode = 'completion'
+ raw_spec = raw_spec:sub(2)
+ end
+ if not recur.validate(raw_spec) then
+ return nil, nil, 'invalid recurrence: ' .. val
+ end
+ fields.recur = raw_spec
+ fields.recur_mode = rec_mode or vim.NIL
+ else
+ return nil, nil, 'unknown field: ' .. key
+ end
+ ::continue::
+ end
+
+ return sep_row, fields, nil
+end
+
---@return nil
function M.save_detail()
if not _detail_bufnr or not _detail_task_id or not _store then
@@ -983,16 +1088,16 @@ function M.save_detail()
local lines = vim.api.nvim_buf_get_lines(_detail_bufnr, 0, -1, false)
- local sep_row = nil
- for i, line in ipairs(lines) do
- if line == DETAIL_SEPARATOR then
- sep_row = i
- break
- end
+ local sep_row, fields, err = parse_detail_frontmatter(lines)
+ if err then
+ log.error(err)
+ return
end
+ ---@cast sep_row integer
+ ---@cast fields pending.DetailFields
local notes_text = ''
- if sep_row and sep_row < #lines then
+ if sep_row < #lines then
local note_lines = {}
for i = sep_row + 1, #lines do
table.insert(note_lines, lines[i])
@@ -1001,18 +1106,29 @@ function M.save_detail()
notes_text = notes_text:gsub('%s+$', '')
end
+ local update = {
+ description = fields.description,
+ status = fields.status,
+ priority = fields.priority,
+ category = fields.category,
+ due = fields.due,
+ recur = fields.recur,
+ recur_mode = fields.recur_mode,
+ }
if notes_text == '' then
- _store:update(_detail_task_id, { notes = vim.NIL })
+ update.notes = vim.NIL
else
- _store:update(_detail_task_id, { notes = notes_text })
+ update.notes = notes_text
end
+
+ _store:update(_detail_task_id, update)
_store:save()
vim.bo[_detail_bufnr].modified = false
- local updated = _store:get(_detail_task_id)
- if updated then
- apply_detail_extmarks(_detail_bufnr, updated)
- end
+ apply_detail_extmarks(_detail_bufnr, sep_row - 1)
end
+M._parse_detail_frontmatter = parse_detail_frontmatter
+M._build_detail_frontmatter = build_detail_frontmatter
+
return M
diff --git a/spec/detail_spec.lua b/spec/detail_spec.lua
new file mode 100644
index 0000000..50f7ae7
--- /dev/null
+++ b/spec/detail_spec.lua
@@ -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)