require('spec.helpers') local config = require('pending.config') describe('parse', function() before_each(function() vim.g.pending = nil config.reset() end) after_each(function() vim.g.pending = nil config.reset() end) local parse = require('pending.parse') describe('body', function() it('returns plain description when no metadata', function() local desc, meta = parse.body('Buy groceries') assert.are.equal('Buy groceries', desc) assert.are.same({}, meta) end) it('extracts due date', function() local desc, meta = parse.body('Buy groceries due:2026-03-15') assert.are.equal('Buy groceries', desc) assert.are.equal('2026-03-15', meta.due) end) it('extracts category', function() local desc, meta = parse.body('Buy groceries cat:Errands') assert.are.equal('Buy groceries', desc) assert.are.equal('Errands', meta.cat) end) it('extracts both due and cat', function() local desc, meta = parse.body('Buy milk due:2026-03-15 cat:Errands') assert.are.equal('Buy milk', desc) assert.are.equal('2026-03-15', meta.due) assert.are.equal('Errands', meta.cat) end) it('extracts metadata in any order', function() local desc, meta = parse.body('Buy milk cat:Errands due:2026-03-15') assert.are.equal('Buy milk', desc) assert.are.equal('2026-03-15', meta.due) assert.are.equal('Errands', meta.cat) end) it('stops at duplicate key', function() local desc, meta = parse.body('Buy milk due:2026-03-15 due:2026-04-01') assert.are.equal('Buy milk due:2026-03-15', desc) assert.are.equal('2026-04-01', meta.due) end) it('stops at non-meta token', function() local desc, meta = parse.body('Buy milk for breakfast due:2026-03-15') assert.are.equal('Buy milk for breakfast', desc) assert.are.equal('2026-03-15', meta.due) end) it('rejects invalid dates', function() local desc, meta = parse.body('Buy milk due:2026-13-15') assert.are.equal('Buy milk due:2026-13-15', desc) assert.is_nil(meta.due) end) it('preserves colons in description', function() local desc, meta = parse.body('Meeting at 3:00pm') assert.are.equal('Meeting at 3:00pm', desc) assert.are.same({}, meta) end) it('uses configurable date syntax', function() vim.g.pending = { date_syntax = 'by' } config.reset() local desc, meta = parse.body('Buy milk by:2026-03-15') assert.are.equal('Buy milk', desc) assert.are.equal('2026-03-15', meta.due) end) it('ignores old syntax when date_syntax is changed', function() vim.g.pending = { date_syntax = 'by' } config.reset() local desc, meta = parse.body('Buy milk due:2026-03-15') assert.are.equal('Buy milk due:2026-03-15', desc) assert.is_nil(meta.due) end) it('resolves due:today to today date', function() local desc, meta = parse.body('Buy milk due:today') assert.are.equal('Buy milk', desc) assert.are.equal(os.date('%Y-%m-%d'), meta.due) end) it('resolves due:+2d to today plus 2 days', function() 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 + 2 }) ) local desc, meta = parse.body('Task due:+2d') assert.are.equal('Task', desc) assert.are.equal(expected, meta.due) end) it('leaves unresolvable due token in description', function() local desc, meta = parse.body('Task due:garbage') assert.is_nil(meta.due) assert.truthy(desc:find('due:garbage', 1, true)) end) end) describe('parse.resolve_date', function() it("returns today's date for 'today'", function() local result = parse.resolve_date('today') assert.are.equal(os.date('%Y-%m-%d'), result) end) it("returns tomorrow's date for 'tomorrow'", function() local expected = os.date('%Y-%m-%d', os.time() + 86400) local result = parse.resolve_date('tomorrow') assert.are.equal(expected, result) end) it("returns today + 3 days for '+3d'", function() 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 + 3 }) ) local result = parse.resolve_date('+3d') assert.are.equal(expected, result) end) it("returns today for '+0d'", function() local result = parse.resolve_date('+0d') assert.are.equal(os.date('%Y-%m-%d'), result) end) it("returns a future Monday (or today) for 'mon'", function() local result = parse.resolve_date('mon') assert.is_not_nil(result) assert.truthy(result:match('^%d%d%d%d%-%d%d%-%d%d$')) end) it('returns nil for garbage input', function() local result = parse.resolve_date('notadate') assert.is_nil(result) end) it('returns nil for empty string', function() local result = parse.resolve_date('') assert.is_nil(result) end) it("returns yesterday's date for 'yesterday'", function() local expected = os.date('%Y-%m-%d', os.time() - 86400) local result = parse.resolve_date('yesterday') assert.are.equal(expected, result) end) it("returns today's date for 'eod'", function() local result = parse.resolve_date('eod') assert.are.equal(os.date('%Y-%m-%d'), result) end) it('returns Monday of current week for sow', function() local result = parse.resolve_date('sow') assert.is_not_nil(result) local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$') local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) }) local wday = os.date('*t', t).wday assert.are.equal(2, wday) end) it('returns Sunday of current week for eow', function() local result = parse.resolve_date('eow') assert.is_not_nil(result) local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$') local t = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) }) local wday = os.date('*t', t).wday assert.are.equal(1, wday) end) it('returns first day of current month for som', function() local today = os.date('*t') --[[@as osdate]] local expected = string.format('%04d-%02d-01', today.year, today.month) local result = parse.resolve_date('som') assert.are.equal(expected, result) end) it('returns last day of current month for eom', function() local today = os.date('*t') --[[@as osdate]] local expected = os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 })) local result = parse.resolve_date('eom') assert.are.equal(expected, result) end) it('returns first day of current quarter for soq', function() local today = os.date('*t') --[[@as osdate]] local q = math.ceil(today.month / 3) local first_month = (q - 1) * 3 + 1 local expected = string.format('%04d-%02d-01', today.year, first_month) local result = parse.resolve_date('soq') assert.are.equal(expected, result) end) it('returns last day of current quarter for eoq', function() local today = os.date('*t') --[[@as osdate]] local q = math.ceil(today.month / 3) local last_month = q * 3 local expected = os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 })) local result = parse.resolve_date('eoq') assert.are.equal(expected, result) end) it('returns Jan 1 of current year for soy', function() local today = os.date('*t') --[[@as osdate]] local expected = string.format('%04d-01-01', today.year) local result = parse.resolve_date('soy') assert.are.equal(expected, result) end) it('returns Dec 31 of current year for eoy', function() local today = os.date('*t') --[[@as osdate]] local expected = string.format('%04d-12-31', today.year) local result = parse.resolve_date('eoy') assert.are.equal(expected, result) end) it('resolves +2w to 14 days from today', function() 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 + 14 }) ) local result = parse.resolve_date('+2w') assert.are.equal(expected, result) end) it('resolves +3m to 3 months from today', function() local today = os.date('*t') --[[@as osdate]] local expected = os.date( '%Y-%m-%d', os.time({ year = today.year, month = today.month + 3, day = today.day }) ) local result = parse.resolve_date('+3m') assert.are.equal(expected, result) end) it('resolves -2d to 2 days ago', function() 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 - 2 }) ) local result = parse.resolve_date('-2d') assert.are.equal(expected, result) end) it('resolves -1w to 7 days ago', function() 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 - 7 }) ) local result = parse.resolve_date('-1w') assert.are.equal(expected, result) end) it("resolves 'later' to someday_date", function() local result = parse.resolve_date('later') assert.are.equal('9999-12-30', result) end) it("resolves 'someday' to someday_date", function() local result = parse.resolve_date('someday') assert.are.equal('9999-12-30', result) end) it('resolves 15th to next 15th of month', function() local result = parse.resolve_date('15th') assert.is_not_nil(result) local _, _, d = result:match('^(%d+)-(%d+)-(%d+)$') assert.are.equal('15', d) end) it('resolves 1st to next 1st of month', function() local result = parse.resolve_date('1st') assert.is_not_nil(result) local _, _, d = result:match('^(%d+)-(%d+)-(%d+)$') assert.are.equal('01', d) end) it('resolves jan to next January 1st', function() local today = os.date('*t') --[[@as osdate]] local result = parse.resolve_date('jan') assert.is_not_nil(result) local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$') assert.are.equal('01', m) assert.are.equal('01', d) if today.month >= 1 then assert.are.equal(tostring(today.year + 1), y) end end) it('resolves dec to next December 1st', function() local today = os.date('*t') --[[@as osdate]] local result = parse.resolve_date('dec') assert.is_not_nil(result) local y, m, d = result:match('^(%d+)-(%d+)-(%d+)$') assert.are.equal('12', m) assert.are.equal('01', d) if today.month >= 12 then assert.are.equal(tostring(today.year + 1), y) else assert.are.equal(tostring(today.year), y) end end) end) describe('resolve_date with time suffix', function() local today = os.date('*t') --[[@as osdate]] local tomorrow_str = os.date( '%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 }) ) --[[@as string]] it('resolves bare hour to T09:00', function() local result = parse.resolve_date('tomorrow@9') assert.are.equal(tomorrow_str .. 'T09:00', result) end) it('resolves bare military hour to T14:00', function() local result = parse.resolve_date('tomorrow@14') assert.are.equal(tomorrow_str .. 'T14:00', result) end) it('resolves H:MM to T09:30', function() local result = parse.resolve_date('tomorrow@9:30') assert.are.equal(tomorrow_str .. 'T09:30', result) end) it('resolves HH:MM (existing format) to T09:30', function() local result = parse.resolve_date('tomorrow@09:30') assert.are.equal(tomorrow_str .. 'T09:30', result) end) it('resolves 2pm to T14:00', function() local result = parse.resolve_date('tomorrow@2pm') assert.are.equal(tomorrow_str .. 'T14:00', result) end) it('resolves 9am to T09:00', function() local result = parse.resolve_date('tomorrow@9am') assert.are.equal(tomorrow_str .. 'T09:00', result) end) it('resolves 9:30pm to T21:30', function() local result = parse.resolve_date('tomorrow@9:30pm') assert.are.equal(tomorrow_str .. 'T21:30', result) end) it('resolves 12am to T00:00', function() local result = parse.resolve_date('tomorrow@12am') assert.are.equal(tomorrow_str .. 'T00:00', result) end) it('resolves 12pm to T12:00', function() local result = parse.resolve_date('tomorrow@12pm') assert.are.equal(tomorrow_str .. 'T12:00', result) end) it('rejects hour 24', function() assert.is_nil(parse.resolve_date('tomorrow@24')) end) it('rejects 13am', function() assert.is_nil(parse.resolve_date('tomorrow@13am')) end) it('rejects minute 60', function() assert.is_nil(parse.resolve_date('tomorrow@9:60')) end) it('rejects alphabetic garbage', function() assert.is_nil(parse.resolve_date('tomorrow@abc')) end) end) describe('command_add', function() it('parses simple text', function() local desc, meta = parse.command_add('Buy milk') assert.are.equal('Buy milk', desc) assert.are.same({}, meta) end) it('detects category prefix', function() local desc, meta = parse.command_add('School: Do homework') assert.are.equal('Do homework', desc) assert.are.equal('School', meta.cat) end) it('ignores lowercase prefix', function() local desc, _ = parse.command_add('hello: world') assert.are.equal('hello: world', desc) end) it('combines category prefix with inline metadata', function() local desc, meta = parse.command_add('School: Do homework due:2026-03-15') assert.are.equal('Do homework', desc) assert.are.equal('School', meta.cat) assert.are.equal('2026-03-15', meta.due) end) end) end)