feat(mcp): upcoming_birthdays, and parse BDAY at all #28
No reviewers
Labels
No labels
waiting-on-julian
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
jlxq0/carddav-mcp!28
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/upcoming-birthdays"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #27.
BDAYwas parsed nowhere in the service. Answering "whose birthday iscoming up" meant paging
list_contactsfour times against 366 cards over alimit of 100 and parsing vCards client-side: 5.39 MB of vCard, measured, for
a one-line question.
upcoming_birthdaysdoes it in one call across one or all address books. Todayis day 0, the last day of the window is included, results sort by days
remaining, and the response carries
contacts_scannedandunparseable_birthdaysso a caller can tell an empty answer from an incompleteone.
ContactSummaryalso gains abirthdayfield, so no caller parses a vCardfor one date.
birthday.rsaccepts every ISO form vCard 3.0 and 4.0 permit —YYYY-MM-DD,YYYYMMDD,--MM-DD,--MMDD, each with an optional discarded time part — andreturns
Noneon anything else, because a card with a free-textBDAYis acard and must not fail a query over the whole book.
The address book cannot pin any of this
45 birthdays across 366 cards, none today, none on a leap day, none
unparseable. A suite of live checks is green on every case that matters, so
fixtures are the pin and the live pair is an integration check.
Each rule dies under its own mutation, and each kills only its own test:
a_leap_day_birthday_falls_on_the_first_of_march_in_a_common_year>instead of>=)today_is_zero_and_the_window_edge_is_inclusivea_birthday_just_past_wraps_to_next_year,the_window_crosses_the_year_boundary,age_is_reported_only_when_the_card_records_a_yeara_birthday_does_not_occur_before_the_year_it_recordsThe leap-day rule is a decision rather than a default: 29 February occurs on 1
March in a common year, because 29 February has not happened when 28 February
passes, so advancing is the only choice that never reports a birthday as past
while it is still ahead. It costs a day of notice once every four years and
never reports one late. The reasoning is beside the function, since either
answer is defensible.
Review found what
reference_dateadmitsThe parameter exists so the window is reproducible and can be asked from a local
calendar date rather than from UTC. Codex, read-only, asked only whether the
tool can return a wrong
days_untilor misplace a contact relative to thewindow. It found one: a reference date before the birth year treated the
month-day as already recurring, so
1984-03-17asked from1983-03-16returned 1 day instead of 367, with
turning: -1.The next occurrence is now never earlier than the birth itself,
turningis 0at the birth, and a card with no year still recurs in every year as before.
The acceptance, carried as a fixture
Measured against the live book on 2026-08-29: three birthdays inside 30 days, at
offsets 15, 22 and 30, and therefore none inside 14. That pair is in the
suite as
the_thirty_day_window_answers_where_the_fourteen_day_window_is_empty,so it survives the calendar moving.
The empty arm is a control only because the other arm answers differently over
the same cards on the same day. A test asserting only "14 days returns
nothing" passes against a query that matches nothing, one that errors into an
empty vector, and a correct implementation alike. The fixture also pins offset
15, which is the first card the 14-day window excludes.
The live pair still gets re-derived at deploy time from an
addressbook-queryREPORT, never from the tool under test, since a test that asks the thing it is
testing agrees with itself.
Verification
Five gates at
c698fa7don rustc 1.98.0: fmt, clippy-D warnings, 107tests,
cargo deny,cargo audit(one allowed warning,chacha20yanked,#23, unrelated and not pulled onto this change).
chronois a direct dependency now and adds no crate to the tree — it wasalready there transitively via
iana-time-zoneconsumers.default-features = falsewithclockandstdonly.Ships as
v0.1.6.