feat(mcp): upcoming_birthdays, and parse BDAY at all #28

Merged
jlxq0 merged 1 commit from feat/upcoming-birthdays into main 2026-08-29 00:18:14 +00:00
Owner

Closes #27.

BDAY was parsed nowhere in the service. Answering "whose birthday is
coming up" meant paging list_contacts four times against 366 cards over a
limit of 100 and parsing vCards client-side: 5.39 MB of vCard, measured, for
a one-line question.

upcoming_birthdays does it in one call across one or all address books. Today
is day 0, the last day of the window is included, results sort by days
remaining, and the response carries contacts_scanned and
unparseable_birthdays so a caller can tell an empty answer from an incomplete
one. ContactSummary also gains a birthday field, so no caller parses a vCard
for one date.

birthday.rs accepts every ISO form vCard 3.0 and 4.0 permit — YYYY-MM-DD,
YYYYMMDD, --MM-DD, --MMDD, each with an optional discarded time part — and
returns None on anything else, because a card with a free-text BDAY is a
card 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:

mutation test that fails
29 Feb falls back to 28 Feb instead of 1 Mar a_leap_day_birthday_falls_on_the_first_of_march_in_a_common_year
window edge exclusive (> instead of >=) that test, and today_is_zero_and_the_window_edge_is_inclusive
drop the wrap into next year a_birthday_just_past_wraps_to_next_year, the_window_crosses_the_year_boundary, age_is_reported_only_when_the_card_records_a_year
remove the birth-year floor a_birthday_does_not_occur_before_the_year_it_records

The 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_date admits

The 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_until or misplace a contact relative to the
window. It found one: a reference date before the birth year treated the
month-day as already recurring
, so 1984-03-17 asked from 1983-03-16
returned 1 day instead of 367, with turning: -1.

The next occurrence is now never earlier than the birth itself, turning is 0
at 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-query
REPORT, never from the tool under test, since a test that asks the thing it is
testing agrees with itself.

Verification

Five gates at c698fa7d on rustc 1.98.0: fmt, clippy -D warnings, 107
tests
, cargo deny, cargo audit (one allowed warning, chacha20 yanked,
#23, unrelated and not pulled onto this change).

chrono is a direct dependency now and adds no crate to the tree — it was
already there transitively via iana-time-zone consumers. default-features = false with clock and std only.

Ships as v0.1.6.

Closes #27. `BDAY` was parsed **nowhere** in the service. Answering "whose birthday is coming up" meant paging `list_contacts` four times against 366 cards over a limit of 100 and parsing vCards client-side: **5.39 MB of vCard, measured**, for a one-line question. `upcoming_birthdays` does it in one call across one or all address books. Today is day 0, the last day of the window is included, results sort by days remaining, and the response carries `contacts_scanned` and `unparseable_birthdays` so a caller can tell an empty answer from an incomplete one. `ContactSummary` also gains a `birthday` field, so no caller parses a vCard for one date. `birthday.rs` accepts every ISO form vCard 3.0 and 4.0 permit — `YYYY-MM-DD`, `YYYYMMDD`, `--MM-DD`, `--MMDD`, each with an optional discarded time part — and returns `None` on anything else, because a card with a free-text `BDAY` is a card 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: | mutation | test that fails | |---|---| | 29 Feb falls back to 28 Feb instead of 1 Mar | `a_leap_day_birthday_falls_on_the_first_of_march_in_a_common_year` | | window edge exclusive (`>` instead of `>=`) | that test, and `today_is_zero_and_the_window_edge_is_inclusive` | | drop the wrap into next year | `a_birthday_just_past_wraps_to_next_year`, `the_window_crosses_the_year_boundary`, `age_is_reported_only_when_the_card_records_a_year` | | remove the birth-year floor | `a_birthday_does_not_occur_before_the_year_it_records` | The 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_date` admits The 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_until` or misplace a contact relative to the window. It found one: **a reference date before the birth year treated the month-day as already recurring**, so `1984-03-17` asked from `1983-03-16` returned 1 day instead of 367, with `turning: -1`. The next occurrence is now never earlier than the birth itself, `turning` is 0 at 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-query` REPORT, never from the tool under test, since a test that asks the thing it is testing agrees with itself. ## Verification Five gates at `c698fa7d` on rustc 1.98.0: fmt, clippy `-D warnings`, **107 tests**, `cargo deny`, `cargo audit` (one allowed warning, `chacha20` yanked, #23, unrelated and not pulled onto this change). `chrono` is a direct dependency now and **adds no crate to the tree** — it was already there transitively via `iana-time-zone` consumers. `default-features = false` with `clock` and `std` only. Ships as `v0.1.6`.
feat(mcp): upcoming_birthdays, and parse BDAY at all
All checks were successful
CI / cargo (pull_request) Successful in 59s
CI / docker (pull_request) Successful in 58s
c698fa7d22
BDAY was read nowhere in the service. Answering "whose birthday is coming up"
meant paging list_contacts four times against 366 cards over a limit of 100 and
parsing vCards client-side: 5.39 MB of vCard, measured, for a one-line question.

upcoming_birthdays does it in one call across one or all address books. Today is
day 0, the last day of the window is included, results sort by days remaining,
and the response carries contacts_scanned and unparseable_birthdays so a caller
can tell an empty answer from an incomplete one.

birthday.rs handles every ISO form vCard 3.0 and 4.0 permit -- YYYY-MM-DD,
YYYYMMDD, --MM-DD, --MMDD, each with an optional discarded time part -- and
returns None on anything else, because a card with a free-text BDAY is a card
and must not fail a query over the whole book. ContactSummary also gains a
birthday field so no caller parses a vCard for one date.

The address book cannot pin any of this: 45 birthdays across 366 cards, none
today, none on a leap day, none unparseable, so a live suite is green on every
case that matters. Fixtures pin it instead, and each rule dies under its own
mutation: the leap-day rule (29 Feb occurs on 1 Mar in a common year, so a
birthday is never reported as past while it is still ahead), the inclusive
window edge, and the wrap into next year.

The reference_date parameter exists so the window is reproducible and can be
asked from a local calendar date rather than from UTC. Cross-engine review found
what that admits: a reference date before the birth year treated the month-day
as already recurring, so 1984-03-17 asked from 1983-03-16 returned 1 day instead
of 367. The next occurrence is now never earlier than the birth itself, with a
test that dies when the guard is removed.

Also carries the live pair as a fixture, so the acceptance survives the calendar
moving: with three birthdays at offsets 15, 22 and 30, a 30-day window answers 3
where a 14-day window answers 0. The empty arm is a control only because the
other arm answers differently over the same cards on the same day.

chrono is a direct dependency now and adds no crate to the tree; it was already
there transitively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jlxq0 merged commit 7c766ea752 into main 2026-08-29 00:18:14 +00:00
jlxq0 deleted branch feat/upcoming-birthdays 2026-08-29 00:18:14 +00:00
Sign in to join this conversation.
No reviewers
No labels
waiting-on-julian
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
jlxq0/carddav-mcp!28
No description provided.