tenseleyflow/rcal / adeab44

Browse files

Add reminder notification daemon

Authored by espadonne
SHA
adeab446f350a29061aeeb3596e7b14613273313
Parents
b2c7109
Tree
a32f3d2

7 changed files

StatusFile+-
M Cargo.lock 767 3
M Cargo.toml 3 0
M README.md 8 2
M src/cli.rs 361 1
M src/lib.rs 2 0
A src/reminders.rs 762 0
A src/services.rs 564 0
Cargo.lockmodified
@@ -23,6 +23,137 @@ version = "1.0.102"
2323
 source = "registry+https://github.com/rust-lang/crates.io-index"
2424
 checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
2525
 
26
+[[package]]
27
+name = "async-broadcast"
28
+version = "0.7.2"
29
+source = "registry+https://github.com/rust-lang/crates.io-index"
30
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
31
+dependencies = [
32
+ "event-listener",
33
+ "event-listener-strategy",
34
+ "futures-core",
35
+ "pin-project-lite",
36
+]
37
+
38
+[[package]]
39
+name = "async-channel"
40
+version = "2.5.0"
41
+source = "registry+https://github.com/rust-lang/crates.io-index"
42
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
43
+dependencies = [
44
+ "concurrent-queue",
45
+ "event-listener-strategy",
46
+ "futures-core",
47
+ "pin-project-lite",
48
+]
49
+
50
+[[package]]
51
+name = "async-executor"
52
+version = "1.14.0"
53
+source = "registry+https://github.com/rust-lang/crates.io-index"
54
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
55
+dependencies = [
56
+ "async-task",
57
+ "concurrent-queue",
58
+ "fastrand",
59
+ "futures-lite",
60
+ "pin-project-lite",
61
+ "slab",
62
+]
63
+
64
+[[package]]
65
+name = "async-io"
66
+version = "2.6.0"
67
+source = "registry+https://github.com/rust-lang/crates.io-index"
68
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
69
+dependencies = [
70
+ "autocfg",
71
+ "cfg-if",
72
+ "concurrent-queue",
73
+ "futures-io",
74
+ "futures-lite",
75
+ "parking",
76
+ "polling",
77
+ "rustix",
78
+ "slab",
79
+ "windows-sys 0.61.2",
80
+]
81
+
82
+[[package]]
83
+name = "async-lock"
84
+version = "3.4.2"
85
+source = "registry+https://github.com/rust-lang/crates.io-index"
86
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
87
+dependencies = [
88
+ "event-listener",
89
+ "event-listener-strategy",
90
+ "pin-project-lite",
91
+]
92
+
93
+[[package]]
94
+name = "async-process"
95
+version = "2.5.0"
96
+source = "registry+https://github.com/rust-lang/crates.io-index"
97
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
98
+dependencies = [
99
+ "async-channel",
100
+ "async-io",
101
+ "async-lock",
102
+ "async-signal",
103
+ "async-task",
104
+ "blocking",
105
+ "cfg-if",
106
+ "event-listener",
107
+ "futures-lite",
108
+ "rustix",
109
+]
110
+
111
+[[package]]
112
+name = "async-recursion"
113
+version = "1.1.1"
114
+source = "registry+https://github.com/rust-lang/crates.io-index"
115
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
116
+dependencies = [
117
+ "proc-macro2",
118
+ "quote",
119
+ "syn 2.0.117",
120
+]
121
+
122
+[[package]]
123
+name = "async-signal"
124
+version = "0.2.14"
125
+source = "registry+https://github.com/rust-lang/crates.io-index"
126
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
127
+dependencies = [
128
+ "async-io",
129
+ "async-lock",
130
+ "atomic-waker",
131
+ "cfg-if",
132
+ "futures-core",
133
+ "futures-io",
134
+ "rustix",
135
+ "signal-hook-registry",
136
+ "slab",
137
+ "windows-sys 0.61.2",
138
+]
139
+
140
+[[package]]
141
+name = "async-task"
142
+version = "4.7.1"
143
+source = "registry+https://github.com/rust-lang/crates.io-index"
144
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
145
+
146
+[[package]]
147
+name = "async-trait"
148
+version = "0.1.89"
149
+source = "registry+https://github.com/rust-lang/crates.io-index"
150
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
151
+dependencies = [
152
+ "proc-macro2",
153
+ "quote",
154
+ "syn 2.0.117",
155
+]
156
+
26157
 [[package]]
27158
 name = "atomic"
28159
 version = "0.6.1"
@@ -86,6 +217,28 @@ dependencies = [
86217
  "generic-array",
87218
 ]
88219
 
220
+[[package]]
221
+name = "block2"
222
+version = "0.6.2"
223
+source = "registry+https://github.com/rust-lang/crates.io-index"
224
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
225
+dependencies = [
226
+ "objc2",
227
+]
228
+
229
+[[package]]
230
+name = "blocking"
231
+version = "1.6.2"
232
+source = "registry+https://github.com/rust-lang/crates.io-index"
233
+checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
234
+dependencies = [
235
+ "async-channel",
236
+ "async-task",
237
+ "futures-io",
238
+ "futures-lite",
239
+ "piper",
240
+]
241
+
89242
 [[package]]
90243
 name = "bumpalo"
91244
 version = "3.20.2"
@@ -149,6 +302,15 @@ dependencies = [
149302
  "static_assertions",
150303
 ]
151304
 
305
+[[package]]
306
+name = "concurrent-queue"
307
+version = "2.5.0"
308
+source = "registry+https://github.com/rust-lang/crates.io-index"
309
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
310
+dependencies = [
311
+ "crossbeam-utils",
312
+]
313
+
152314
 [[package]]
153315
 name = "convert_case"
154316
 version = "0.10.0"
@@ -167,6 +329,12 @@ dependencies = [
167329
  "libc",
168330
 ]
169331
 
332
+[[package]]
333
+name = "crossbeam-utils"
334
+version = "0.8.21"
335
+source = "registry+https://github.com/rust-lang/crates.io-index"
336
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
337
+
170338
 [[package]]
171339
 name = "crossterm"
172340
 version = "0.29.0"
@@ -295,6 +463,37 @@ dependencies = [
295463
  "crypto-common",
296464
 ]
297465
 
466
+[[package]]
467
+name = "directories"
468
+version = "6.0.0"
469
+source = "registry+https://github.com/rust-lang/crates.io-index"
470
+checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
471
+dependencies = [
472
+ "dirs-sys",
473
+]
474
+
475
+[[package]]
476
+name = "dirs-sys"
477
+version = "0.5.0"
478
+source = "registry+https://github.com/rust-lang/crates.io-index"
479
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
480
+dependencies = [
481
+ "libc",
482
+ "option-ext",
483
+ "redox_users",
484
+ "windows-sys 0.61.2",
485
+]
486
+
487
+[[package]]
488
+name = "dispatch2"
489
+version = "0.3.1"
490
+source = "registry+https://github.com/rust-lang/crates.io-index"
491
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
492
+dependencies = [
493
+ "bitflags 2.11.1",
494
+ "objc2",
495
+]
496
+
298497
 [[package]]
299498
 name = "displaydoc"
300499
 version = "0.2.5"
@@ -321,6 +520,33 @@ version = "1.15.0"
321520
 source = "registry+https://github.com/rust-lang/crates.io-index"
322521
 checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
323522
 
523
+[[package]]
524
+name = "endi"
525
+version = "1.1.1"
526
+source = "registry+https://github.com/rust-lang/crates.io-index"
527
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
528
+
529
+[[package]]
530
+name = "enumflags2"
531
+version = "0.7.12"
532
+source = "registry+https://github.com/rust-lang/crates.io-index"
533
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
534
+dependencies = [
535
+ "enumflags2_derive",
536
+ "serde",
537
+]
538
+
539
+[[package]]
540
+name = "enumflags2_derive"
541
+version = "0.7.12"
542
+source = "registry+https://github.com/rust-lang/crates.io-index"
543
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
544
+dependencies = [
545
+ "proc-macro2",
546
+ "quote",
547
+ "syn 2.0.117",
548
+]
549
+
324550
 [[package]]
325551
 name = "equivalent"
326552
 version = "1.0.2"
@@ -346,6 +572,27 @@ dependencies = [
346572
  "num-traits",
347573
 ]
348574
 
575
+[[package]]
576
+name = "event-listener"
577
+version = "5.4.1"
578
+source = "registry+https://github.com/rust-lang/crates.io-index"
579
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
580
+dependencies = [
581
+ "concurrent-queue",
582
+ "parking",
583
+ "pin-project-lite",
584
+]
585
+
586
+[[package]]
587
+name = "event-listener-strategy"
588
+version = "0.5.4"
589
+source = "registry+https://github.com/rust-lang/crates.io-index"
590
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
591
+dependencies = [
592
+ "event-listener",
593
+ "pin-project-lite",
594
+]
595
+
349596
 [[package]]
350597
 name = "fancy-regex"
351598
 version = "0.11.0"
@@ -356,6 +603,12 @@ dependencies = [
356603
  "regex",
357604
 ]
358605
 
606
+[[package]]
607
+name = "fastrand"
608
+version = "2.4.1"
609
+source = "registry+https://github.com/rust-lang/crates.io-index"
610
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
611
+
359612
 [[package]]
360613
 name = "filedescriptor"
361614
 version = "0.8.3"
@@ -412,6 +665,16 @@ dependencies = [
412665
  "percent-encoding",
413666
 ]
414667
 
668
+[[package]]
669
+name = "fs2"
670
+version = "0.4.3"
671
+source = "registry+https://github.com/rust-lang/crates.io-index"
672
+checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
673
+dependencies = [
674
+ "libc",
675
+ "winapi",
676
+]
677
+
415678
 [[package]]
416679
 name = "futures-channel"
417680
 version = "0.3.32"
@@ -434,6 +697,19 @@ version = "0.3.32"
434697
 source = "registry+https://github.com/rust-lang/crates.io-index"
435698
 checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
436699
 
700
+[[package]]
701
+name = "futures-lite"
702
+version = "2.6.1"
703
+source = "registry+https://github.com/rust-lang/crates.io-index"
704
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
705
+dependencies = [
706
+ "fastrand",
707
+ "futures-core",
708
+ "futures-io",
709
+ "parking",
710
+ "pin-project-lite",
711
+]
712
+
437713
 [[package]]
438714
 name = "futures-sink"
439715
 version = "0.3.32"
@@ -543,6 +819,12 @@ version = "0.5.0"
543819
 source = "registry+https://github.com/rust-lang/crates.io-index"
544820
 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
545821
 
822
+[[package]]
823
+name = "hermit-abi"
824
+version = "0.5.2"
825
+source = "registry+https://github.com/rust-lang/crates.io-index"
826
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
827
+
546828
 [[package]]
547829
 name = "hex"
548830
 version = "0.4.3"
@@ -874,6 +1156,15 @@ version = "0.2.185"
8741156
 source = "registry+https://github.com/rust-lang/crates.io-index"
8751157
 checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
8761158
 
1159
+[[package]]
1160
+name = "libredox"
1161
+version = "0.1.16"
1162
+source = "registry+https://github.com/rust-lang/crates.io-index"
1163
+checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
1164
+dependencies = [
1165
+ "libc",
1166
+]
1167
+
8771168
 [[package]]
8781169
 name = "line-clipping"
8791170
 version = "0.3.7"
@@ -931,6 +1222,18 @@ version = "0.1.2"
9311222
 source = "registry+https://github.com/rust-lang/crates.io-index"
9321223
 checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
9331224
 
1225
+[[package]]
1226
+name = "mac-notification-sys"
1227
+version = "0.6.12"
1228
+source = "registry+https://github.com/rust-lang/crates.io-index"
1229
+checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3"
1230
+dependencies = [
1231
+ "cc",
1232
+ "objc2",
1233
+ "objc2-foundation",
1234
+ "time",
1235
+]
1236
+
9341237
 [[package]]
9351238
 name = "mac_address"
9361239
 version = "1.1.8"
@@ -1003,6 +1306,20 @@ dependencies = [
10031306
  "minimal-lexical",
10041307
 ]
10051308
 
1309
+[[package]]
1310
+name = "notify-rust"
1311
+version = "4.16.0"
1312
+source = "registry+https://github.com/rust-lang/crates.io-index"
1313
+checksum = "5e551a9f0db223eaf3eb156906f99f46897fd951ee66dd1cb0be14db4d36d2fa"
1314
+dependencies = [
1315
+ "futures-lite",
1316
+ "log",
1317
+ "mac-notification-sys",
1318
+ "serde",
1319
+ "tauri-winrt-notification",
1320
+ "zbus",
1321
+]
1322
+
10061323
 [[package]]
10071324
 name = "num-conv"
10081325
 version = "0.2.1"
@@ -1038,12 +1355,57 @@ dependencies = [
10381355
  "libc",
10391356
 ]
10401357
 
1358
+[[package]]
1359
+name = "objc2"
1360
+version = "0.6.4"
1361
+source = "registry+https://github.com/rust-lang/crates.io-index"
1362
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
1363
+dependencies = [
1364
+ "objc2-encode",
1365
+]
1366
+
1367
+[[package]]
1368
+name = "objc2-core-foundation"
1369
+version = "0.3.2"
1370
+source = "registry+https://github.com/rust-lang/crates.io-index"
1371
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
1372
+dependencies = [
1373
+ "bitflags 2.11.1",
1374
+ "dispatch2",
1375
+ "objc2",
1376
+]
1377
+
1378
+[[package]]
1379
+name = "objc2-encode"
1380
+version = "4.1.0"
1381
+source = "registry+https://github.com/rust-lang/crates.io-index"
1382
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
1383
+
1384
+[[package]]
1385
+name = "objc2-foundation"
1386
+version = "0.3.2"
1387
+source = "registry+https://github.com/rust-lang/crates.io-index"
1388
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
1389
+dependencies = [
1390
+ "bitflags 2.11.1",
1391
+ "block2",
1392
+ "libc",
1393
+ "objc2",
1394
+ "objc2-core-foundation",
1395
+]
1396
+
10411397
 [[package]]
10421398
 name = "once_cell"
10431399
 version = "1.21.4"
10441400
 source = "registry+https://github.com/rust-lang/crates.io-index"
10451401
 checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
10461402
 
1403
+[[package]]
1404
+name = "option-ext"
1405
+version = "0.2.0"
1406
+source = "registry+https://github.com/rust-lang/crates.io-index"
1407
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
1408
+
10471409
 [[package]]
10481410
 name = "ordered-float"
10491411
 version = "4.6.0"
@@ -1053,6 +1415,22 @@ dependencies = [
10531415
  "num-traits",
10541416
 ]
10551417
 
1418
+[[package]]
1419
+name = "ordered-stream"
1420
+version = "0.2.0"
1421
+source = "registry+https://github.com/rust-lang/crates.io-index"
1422
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
1423
+dependencies = [
1424
+ "futures-core",
1425
+ "pin-project-lite",
1426
+]
1427
+
1428
+[[package]]
1429
+name = "parking"
1430
+version = "2.2.1"
1431
+source = "registry+https://github.com/rust-lang/crates.io-index"
1432
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
1433
+
10561434
 [[package]]
10571435
 name = "parking_lot"
10581436
 version = "0.12.5"
@@ -1073,7 +1451,7 @@ dependencies = [
10731451
  "libc",
10741452
  "redox_syscall",
10751453
  "smallvec",
1076
- "windows-link",
1454
+ "windows-link 0.2.1",
10771455
 ]
10781456
 
10791457
 [[package]]
@@ -1183,6 +1561,31 @@ version = "0.2.17"
11831561
 source = "registry+https://github.com/rust-lang/crates.io-index"
11841562
 checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
11851563
 
1564
+[[package]]
1565
+name = "piper"
1566
+version = "0.2.5"
1567
+source = "registry+https://github.com/rust-lang/crates.io-index"
1568
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
1569
+dependencies = [
1570
+ "atomic-waker",
1571
+ "fastrand",
1572
+ "futures-io",
1573
+]
1574
+
1575
+[[package]]
1576
+name = "polling"
1577
+version = "3.11.0"
1578
+source = "registry+https://github.com/rust-lang/crates.io-index"
1579
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
1580
+dependencies = [
1581
+ "cfg-if",
1582
+ "concurrent-queue",
1583
+ "hermit-abi",
1584
+ "pin-project-lite",
1585
+ "rustix",
1586
+ "windows-sys 0.61.2",
1587
+]
1588
+
11861589
 [[package]]
11871590
 name = "portable-atomic"
11881591
 version = "1.13.1"
@@ -1223,6 +1626,15 @@ dependencies = [
12231626
  "syn 2.0.117",
12241627
 ]
12251628
 
1629
+[[package]]
1630
+name = "proc-macro-crate"
1631
+version = "3.5.0"
1632
+source = "registry+https://github.com/rust-lang/crates.io-index"
1633
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
1634
+dependencies = [
1635
+ "toml_edit",
1636
+]
1637
+
12261638
 [[package]]
12271639
 name = "proc-macro2"
12281640
 version = "1.0.106"
@@ -1232,6 +1644,15 @@ dependencies = [
12321644
  "unicode-ident",
12331645
 ]
12341646
 
1647
+[[package]]
1648
+name = "quick-xml"
1649
+version = "0.37.5"
1650
+source = "registry+https://github.com/rust-lang/crates.io-index"
1651
+checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
1652
+dependencies = [
1653
+ "memchr",
1654
+]
1655
+
12351656
 [[package]]
12361657
 name = "quinn"
12371658
 version = "0.11.9"
@@ -1442,6 +1863,9 @@ name = "rcal"
14421863
 version = "0.1.0"
14431864
 dependencies = [
14441865
  "crossterm",
1866
+ "directories",
1867
+ "fs2",
1868
+ "notify-rust",
14451869
  "ratatui",
14461870
  "reqwest",
14471871
  "serde",
@@ -1458,6 +1882,17 @@ dependencies = [
14581882
  "bitflags 2.11.1",
14591883
 ]
14601884
 
1885
+[[package]]
1886
+name = "redox_users"
1887
+version = "0.5.2"
1888
+source = "registry+https://github.com/rust-lang/crates.io-index"
1889
+checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
1890
+dependencies = [
1891
+ "getrandom 0.2.17",
1892
+ "libredox",
1893
+ "thiserror 2.0.18",
1894
+]
1895
+
14611896
 [[package]]
14621897
 name = "regex"
14631898
 version = "1.12.3"
@@ -1671,6 +2106,17 @@ dependencies = [
16712106
  "zmij",
16722107
 ]
16732108
 
2109
+[[package]]
2110
+name = "serde_repr"
2111
+version = "0.1.20"
2112
+source = "registry+https://github.com/rust-lang/crates.io-index"
2113
+checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
2114
+dependencies = [
2115
+ "proc-macro2",
2116
+ "quote",
2117
+ "syn 2.0.117",
2118
+]
2119
+
16742120
 [[package]]
16752121
 name = "serde_urlencoded"
16762122
 version = "0.7.1"
@@ -1846,6 +2292,31 @@ dependencies = [
18462292
  "syn 2.0.117",
18472293
 ]
18482294
 
2295
+[[package]]
2296
+name = "tauri-winrt-notification"
2297
+version = "0.7.2"
2298
+source = "registry+https://github.com/rust-lang/crates.io-index"
2299
+checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
2300
+dependencies = [
2301
+ "quick-xml",
2302
+ "thiserror 2.0.18",
2303
+ "windows",
2304
+ "windows-version",
2305
+]
2306
+
2307
+[[package]]
2308
+name = "tempfile"
2309
+version = "3.27.0"
2310
+source = "registry+https://github.com/rust-lang/crates.io-index"
2311
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
2312
+dependencies = [
2313
+ "fastrand",
2314
+ "getrandom 0.4.2",
2315
+ "once_cell",
2316
+ "rustix",
2317
+ "windows-sys 0.61.2",
2318
+]
2319
+
18492320
 [[package]]
18502321
 name = "terminfo"
18512322
 version = "0.9.0"
@@ -2030,6 +2501,36 @@ dependencies = [
20302501
  "tokio",
20312502
 ]
20322503
 
2504
+[[package]]
2505
+name = "toml_datetime"
2506
+version = "1.1.1+spec-1.1.0"
2507
+source = "registry+https://github.com/rust-lang/crates.io-index"
2508
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
2509
+dependencies = [
2510
+ "serde_core",
2511
+]
2512
+
2513
+[[package]]
2514
+name = "toml_edit"
2515
+version = "0.25.11+spec-1.1.0"
2516
+source = "registry+https://github.com/rust-lang/crates.io-index"
2517
+checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
2518
+dependencies = [
2519
+ "indexmap",
2520
+ "toml_datetime",
2521
+ "toml_parser",
2522
+ "winnow 1.0.2",
2523
+]
2524
+
2525
+[[package]]
2526
+name = "toml_parser"
2527
+version = "1.1.2+spec-1.1.0"
2528
+source = "registry+https://github.com/rust-lang/crates.io-index"
2529
+checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
2530
+dependencies = [
2531
+ "winnow 1.0.2",
2532
+]
2533
+
20332534
 [[package]]
20342535
 name = "tower"
20352536
 version = "0.5.3"
@@ -2082,9 +2583,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
20822583
 checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
20832584
 dependencies = [
20842585
  "pin-project-lite",
2586
+ "tracing-attributes",
20852587
  "tracing-core",
20862588
 ]
20872589
 
2590
+[[package]]
2591
+name = "tracing-attributes"
2592
+version = "0.1.31"
2593
+source = "registry+https://github.com/rust-lang/crates.io-index"
2594
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
2595
+dependencies = [
2596
+ "proc-macro2",
2597
+ "quote",
2598
+ "syn 2.0.117",
2599
+]
2600
+
20882601
 [[package]]
20892602
 name = "tracing-core"
20902603
 version = "0.1.36"
@@ -2112,6 +2625,17 @@ version = "0.1.7"
21122625
 source = "registry+https://github.com/rust-lang/crates.io-index"
21132626
 checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
21142627
 
2628
+[[package]]
2629
+name = "uds_windows"
2630
+version = "1.2.1"
2631
+source = "registry+https://github.com/rust-lang/crates.io-index"
2632
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
2633
+dependencies = [
2634
+ "memoffset",
2635
+ "tempfile",
2636
+ "windows-sys 0.61.2",
2637
+]
2638
+
21152639
 [[package]]
21162640
 name = "unicode-ident"
21172641
 version = "1.0.24"
@@ -2186,6 +2710,7 @@ dependencies = [
21862710
  "atomic",
21872711
  "getrandom 0.4.2",
21882712
  "js-sys",
2713
+ "serde_core",
21892714
  "wasm-bindgen",
21902715
 ]
21912716
 
@@ -2449,12 +2974,114 @@ version = "0.4.0"
24492974
 source = "registry+https://github.com/rust-lang/crates.io-index"
24502975
 checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
24512976
 
2977
+[[package]]
2978
+name = "windows"
2979
+version = "0.61.3"
2980
+source = "registry+https://github.com/rust-lang/crates.io-index"
2981
+checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
2982
+dependencies = [
2983
+ "windows-collections",
2984
+ "windows-core",
2985
+ "windows-future",
2986
+ "windows-link 0.1.3",
2987
+ "windows-numerics",
2988
+]
2989
+
2990
+[[package]]
2991
+name = "windows-collections"
2992
+version = "0.2.0"
2993
+source = "registry+https://github.com/rust-lang/crates.io-index"
2994
+checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
2995
+dependencies = [
2996
+ "windows-core",
2997
+]
2998
+
2999
+[[package]]
3000
+name = "windows-core"
3001
+version = "0.61.2"
3002
+source = "registry+https://github.com/rust-lang/crates.io-index"
3003
+checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
3004
+dependencies = [
3005
+ "windows-implement",
3006
+ "windows-interface",
3007
+ "windows-link 0.1.3",
3008
+ "windows-result",
3009
+ "windows-strings",
3010
+]
3011
+
3012
+[[package]]
3013
+name = "windows-future"
3014
+version = "0.2.1"
3015
+source = "registry+https://github.com/rust-lang/crates.io-index"
3016
+checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
3017
+dependencies = [
3018
+ "windows-core",
3019
+ "windows-link 0.1.3",
3020
+ "windows-threading",
3021
+]
3022
+
3023
+[[package]]
3024
+name = "windows-implement"
3025
+version = "0.60.2"
3026
+source = "registry+https://github.com/rust-lang/crates.io-index"
3027
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
3028
+dependencies = [
3029
+ "proc-macro2",
3030
+ "quote",
3031
+ "syn 2.0.117",
3032
+]
3033
+
3034
+[[package]]
3035
+name = "windows-interface"
3036
+version = "0.59.3"
3037
+source = "registry+https://github.com/rust-lang/crates.io-index"
3038
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
3039
+dependencies = [
3040
+ "proc-macro2",
3041
+ "quote",
3042
+ "syn 2.0.117",
3043
+]
3044
+
3045
+[[package]]
3046
+name = "windows-link"
3047
+version = "0.1.3"
3048
+source = "registry+https://github.com/rust-lang/crates.io-index"
3049
+checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
3050
+
24523051
 [[package]]
24533052
 name = "windows-link"
24543053
 version = "0.2.1"
24553054
 source = "registry+https://github.com/rust-lang/crates.io-index"
24563055
 checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
24573056
 
3057
+[[package]]
3058
+name = "windows-numerics"
3059
+version = "0.2.0"
3060
+source = "registry+https://github.com/rust-lang/crates.io-index"
3061
+checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
3062
+dependencies = [
3063
+ "windows-core",
3064
+ "windows-link 0.1.3",
3065
+]
3066
+
3067
+[[package]]
3068
+name = "windows-result"
3069
+version = "0.3.4"
3070
+source = "registry+https://github.com/rust-lang/crates.io-index"
3071
+checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
3072
+dependencies = [
3073
+ "windows-link 0.1.3",
3074
+]
3075
+
3076
+[[package]]
3077
+name = "windows-strings"
3078
+version = "0.4.2"
3079
+source = "registry+https://github.com/rust-lang/crates.io-index"
3080
+checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
3081
+dependencies = [
3082
+ "windows-link 0.1.3",
3083
+]
3084
+
24583085
 [[package]]
24593086
 name = "windows-sys"
24603087
 version = "0.52.0"
@@ -2479,7 +3106,7 @@ version = "0.61.2"
24793106
 source = "registry+https://github.com/rust-lang/crates.io-index"
24803107
 checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
24813108
 dependencies = [
2482
- "windows-link",
3109
+ "windows-link 0.2.1",
24833110
 ]
24843111
 
24853112
 [[package]]
@@ -2504,7 +3131,7 @@ version = "0.53.5"
25043131
 source = "registry+https://github.com/rust-lang/crates.io-index"
25053132
 checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
25063133
 dependencies = [
2507
- "windows-link",
3134
+ "windows-link 0.2.1",
25083135
  "windows_aarch64_gnullvm 0.53.1",
25093136
  "windows_aarch64_msvc 0.53.1",
25103137
  "windows_i686_gnu 0.53.1",
@@ -2515,6 +3142,24 @@ dependencies = [
25153142
  "windows_x86_64_msvc 0.53.1",
25163143
 ]
25173144
 
3145
+[[package]]
3146
+name = "windows-threading"
3147
+version = "0.1.0"
3148
+source = "registry+https://github.com/rust-lang/crates.io-index"
3149
+checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
3150
+dependencies = [
3151
+ "windows-link 0.1.3",
3152
+]
3153
+
3154
+[[package]]
3155
+name = "windows-version"
3156
+version = "0.1.7"
3157
+source = "registry+https://github.com/rust-lang/crates.io-index"
3158
+checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
3159
+dependencies = [
3160
+ "windows-link 0.2.1",
3161
+]
3162
+
25183163
 [[package]]
25193164
 name = "windows_aarch64_gnullvm"
25203165
 version = "0.52.6"
@@ -2611,6 +3256,24 @@ version = "0.53.1"
26113256
 source = "registry+https://github.com/rust-lang/crates.io-index"
26123257
 checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
26133258
 
3259
+[[package]]
3260
+name = "winnow"
3261
+version = "0.7.15"
3262
+source = "registry+https://github.com/rust-lang/crates.io-index"
3263
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
3264
+dependencies = [
3265
+ "memchr",
3266
+]
3267
+
3268
+[[package]]
3269
+name = "winnow"
3270
+version = "1.0.2"
3271
+source = "registry+https://github.com/rust-lang/crates.io-index"
3272
+checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
3273
+dependencies = [
3274
+ "memchr",
3275
+]
3276
+
26143277
 [[package]]
26153278
 name = "wit-bindgen"
26163279
 version = "0.51.0"
@@ -2734,6 +3397,67 @@ dependencies = [
27343397
  "synstructure",
27353398
 ]
27363399
 
3400
+[[package]]
3401
+name = "zbus"
3402
+version = "5.14.0"
3403
+source = "registry+https://github.com/rust-lang/crates.io-index"
3404
+checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc"
3405
+dependencies = [
3406
+ "async-broadcast",
3407
+ "async-executor",
3408
+ "async-io",
3409
+ "async-lock",
3410
+ "async-process",
3411
+ "async-recursion",
3412
+ "async-task",
3413
+ "async-trait",
3414
+ "blocking",
3415
+ "enumflags2",
3416
+ "event-listener",
3417
+ "futures-core",
3418
+ "futures-lite",
3419
+ "hex",
3420
+ "libc",
3421
+ "ordered-stream",
3422
+ "rustix",
3423
+ "serde",
3424
+ "serde_repr",
3425
+ "tracing",
3426
+ "uds_windows",
3427
+ "uuid",
3428
+ "windows-sys 0.61.2",
3429
+ "winnow 0.7.15",
3430
+ "zbus_macros",
3431
+ "zbus_names",
3432
+ "zvariant",
3433
+]
3434
+
3435
+[[package]]
3436
+name = "zbus_macros"
3437
+version = "5.14.0"
3438
+source = "registry+https://github.com/rust-lang/crates.io-index"
3439
+checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222"
3440
+dependencies = [
3441
+ "proc-macro-crate",
3442
+ "proc-macro2",
3443
+ "quote",
3444
+ "syn 2.0.117",
3445
+ "zbus_names",
3446
+ "zvariant",
3447
+ "zvariant_utils",
3448
+]
3449
+
3450
+[[package]]
3451
+name = "zbus_names"
3452
+version = "4.3.1"
3453
+source = "registry+https://github.com/rust-lang/crates.io-index"
3454
+checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
3455
+dependencies = [
3456
+ "serde",
3457
+ "winnow 0.7.15",
3458
+ "zvariant",
3459
+]
3460
+
27373461
 [[package]]
27383462
 name = "zerocopy"
27393463
 version = "0.8.48"
@@ -2819,3 +3543,43 @@ name = "zmij"
28193543
 version = "1.0.21"
28203544
 source = "registry+https://github.com/rust-lang/crates.io-index"
28213545
 checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
3546
+
3547
+[[package]]
3548
+name = "zvariant"
3549
+version = "5.10.0"
3550
+source = "registry+https://github.com/rust-lang/crates.io-index"
3551
+checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b"
3552
+dependencies = [
3553
+ "endi",
3554
+ "enumflags2",
3555
+ "serde",
3556
+ "winnow 0.7.15",
3557
+ "zvariant_derive",
3558
+ "zvariant_utils",
3559
+]
3560
+
3561
+[[package]]
3562
+name = "zvariant_derive"
3563
+version = "5.10.0"
3564
+source = "registry+https://github.com/rust-lang/crates.io-index"
3565
+checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c"
3566
+dependencies = [
3567
+ "proc-macro-crate",
3568
+ "proc-macro2",
3569
+ "quote",
3570
+ "syn 2.0.117",
3571
+ "zvariant_utils",
3572
+]
3573
+
3574
+[[package]]
3575
+name = "zvariant_utils"
3576
+version = "3.3.0"
3577
+source = "registry+https://github.com/rust-lang/crates.io-index"
3578
+checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
3579
+dependencies = [
3580
+ "proc-macro2",
3581
+ "quote",
3582
+ "serde",
3583
+ "syn 2.0.117",
3584
+ "winnow 0.7.15",
3585
+]
Cargo.tomlmodified
@@ -5,6 +5,9 @@ edition = "2024"
55
 
66
 [dependencies]
77
 crossterm = "0.29.0"
8
+directories = "6.0.0"
9
+fs2 = "0.4.3"
10
+notify-rust = "4"
811
 ratatui = "0.30.0"
912
 reqwest = { version = "0.12.24", default-features = false, features = ["blocking", "rustls-tls"] }
1013
 serde = { version = "1.0.228", features = ["derive"] }
README.mdmodified
@@ -26,6 +26,11 @@ cargo run -- --date 2026-04-23
2626
 
2727
 ```sh
2828
 rcal [--date YYYY-MM-DD] [--events-file PATH] [--holiday-source off|us-federal|nager] [--holiday-country CC]
29
+rcal reminders run [--events-file PATH] [--state-file PATH] [--once]
30
+rcal reminders install [--events-file PATH]
31
+rcal reminders uninstall
32
+rcal reminders status
33
+rcal reminders test
2934
 ```
3035
 
3136
 Options:
@@ -69,7 +74,9 @@ access.
6974
 Created events are stored locally as JSON and are shown immediately in month,
7075
 week, and day views. The create/edit modal supports timed events, single-day
7176
 all-day events, recurrence, location, notes, and multiple reminder offsets.
72
-Reminder notifications are not delivered yet.
77
+Reminder notifications are delivered by a user-level background service. Use
78
+`rcal reminders install` to install it, `rcal reminders status` to inspect it,
79
+and `rcal reminders test` to send a test notification.
7380
 
7481
 ## Layout
7582
 
@@ -81,7 +88,6 @@ back to a focused day summary.
8188
 
8289
 - Real account integrations for Outlook, Google Calendar, Exchange, and similar
8390
   providers are not implemented yet.
84
-- Reminder offsets are stored but do not trigger notifications yet.
8591
 - Packaging is currently source-based through Cargo.
8692
 
8793
 ## Development
src/cli.rsmodified
@@ -22,6 +22,14 @@ use crate::{
2222
         KeyboardInput, MouseInput, RecurrenceChoiceInputResult,
2323
     },
2424
     calendar::CalendarDate,
25
+    reminders::{
26
+        ReminderDaemonConfig, ReminderError, SystemNotifier, default_state_file, run_daemon,
27
+        run_once, test_notification,
28
+    },
29
+    services::{
30
+        ServiceConfig, ServiceError, SystemCommandRunner, install_service, service_status,
31
+        uninstall_service,
32
+    },
2533
     tui::{
2634
         AppView, DEFAULT_RENDER_HEIGHT, DEFAULT_RENDER_WIDTH, hit_test_app_date,
2735
         render_app_to_string_with_agenda_source,
@@ -34,6 +42,11 @@ const HELP: &str = concat!(
3442
     "\n\n",
3543
     "Usage:\n",
3644
     "  rcal [--date YYYY-MM-DD] [--events-file PATH] [--holiday-source off|us-federal|nager] [--holiday-country CC]\n\n",
45
+    "  rcal reminders run [--events-file PATH] [--state-file PATH] [--once]\n",
46
+    "  rcal reminders install [--events-file PATH]\n",
47
+    "  rcal reminders uninstall\n",
48
+    "  rcal reminders status\n",
49
+    "  rcal reminders test\n\n",
3750
     "Options:\n",
3851
     "  --date YYYY-MM-DD                   Open with the given date selected.\n",
3952
     "  --events-file PATH                  Read and write local user events at PATH.\n",
@@ -54,7 +67,7 @@ const HELP: &str = concat!(
5467
     "Mouse:\n",
5568
     "  Left click selects a visible date; double-click a visible date to open day view.\n\n",
5669
     "Notes:\n",
57
-    "  Real calendar-account integration and reminder notifications are not in this milestone.\n",
70
+    "  Reminder services are user-level background jobs. Provider account integration is not in this milestone.\n",
5871
 );
5972
 
6073
 const VERSION: &str = concat!(env!("CARGO_PKG_NAME"), " ", env!("CARGO_PKG_VERSION"), "\n");
@@ -89,10 +102,27 @@ pub enum HolidaySourceConfig {
89102
 #[derive(Debug, Clone, PartialEq, Eq)]
90103
 pub enum CliAction {
91104
     Run(AppConfig),
105
+    Reminders(ReminderCliAction),
92106
     Help,
93107
     Version,
94108
 }
95109
 
110
+#[derive(Debug, Clone, PartialEq, Eq)]
111
+pub enum ReminderCliAction {
112
+    Run(ReminderRunConfig),
113
+    Install { events_file: PathBuf },
114
+    Uninstall,
115
+    Status,
116
+    Test,
117
+}
118
+
119
+#[derive(Debug, Clone, PartialEq, Eq)]
120
+pub struct ReminderRunConfig {
121
+    pub events_file: PathBuf,
122
+    pub state_file: PathBuf,
123
+    pub once: bool,
124
+}
125
+
96126
 #[derive(Debug, Clone, PartialEq, Eq)]
97127
 pub enum CliError {
98128
     DuplicateDate,
@@ -106,6 +136,10 @@ pub enum CliError {
106136
     MissingHolidayCountryValue,
107137
     InvalidHolidayCountry(String),
108138
     HolidayCountryRequiresNager,
139
+    MissingReminderCommand,
140
+    UnknownReminderCommand(String),
141
+    DuplicateStateFile,
142
+    MissingStateFileValue,
109143
     UnknownArgument(String),
110144
     InvalidDate { input: String, reason: String },
111145
 }
@@ -144,6 +178,15 @@ impl fmt::Display for CliError {
144178
                     "--holiday-country may only be used with --holiday-source nager"
145179
                 )
146180
             }
181
+            Self::MissingReminderCommand => write!(
182
+                f,
183
+                "reminders requires one of: run, install, uninstall, status, test"
184
+            ),
185
+            Self::UnknownReminderCommand(command) => {
186
+                write!(f, "unknown reminders command: {command}")
187
+            }
188
+            Self::DuplicateStateFile => write!(f, "--state-file may only be provided once"),
189
+            Self::MissingStateFileValue => write!(f, "--state-file requires a path"),
147190
             Self::UnknownArgument(arg) => write!(f, "unknown argument: {arg}"),
148191
             Self::InvalidDate { input, reason } => {
149192
                 write!(f, "invalid --date value '{input}': {reason}")
@@ -189,6 +232,7 @@ where
189232
                 Err(err) => io_error_exit(&mut stderr, err),
190233
             }
191234
         }
235
+        Ok(CliAction::Reminders(action)) => run_reminder_action(action, &mut stdout, &mut stderr),
192236
         Ok(CliAction::Help) => match write!(stdout, "{HELP}") {
193237
             Ok(()) => std::process::ExitCode::SUCCESS,
194238
             Err(err) => io_error_exit(&mut stderr, err),
@@ -222,6 +266,7 @@ where
222266
                 Err(err) => io_error_exit(&mut stderr, err),
223267
             }
224268
         }
269
+        Ok(CliAction::Reminders(action)) => run_reminder_action(action, &mut stdout, &mut stderr),
225270
         Ok(CliAction::Help) => match write!(stdout, "{HELP}") {
226271
             Ok(()) => std::process::ExitCode::SUCCESS,
227272
             Err(err) => io_error_exit(&mut stderr, err),
@@ -238,6 +283,20 @@ where
238283
 }
239284
 
240285
 pub fn parse_args<I>(args: I, today: Date) -> Result<CliAction, CliError>
286
+where
287
+    I: IntoIterator<Item = OsString>,
288
+{
289
+    let args = args.into_iter().collect::<Vec<_>>();
290
+    if let Some(first) = args.first()
291
+        && first == "reminders"
292
+    {
293
+        return parse_reminder_args(args.into_iter().skip(1));
294
+    }
295
+
296
+    parse_calendar_args(args, today)
297
+}
298
+
299
+fn parse_calendar_args<I>(args: I, today: Date) -> Result<CliAction, CliError>
241300
 where
242301
     I: IntoIterator<Item = OsString>,
243302
 {
@@ -362,6 +421,140 @@ where
362421
     Ok(CliAction::Run(config))
363422
 }
364423
 
424
+fn parse_reminder_args<I>(args: I) -> Result<CliAction, CliError>
425
+where
426
+    I: IntoIterator<Item = OsString>,
427
+{
428
+    let mut args = args.into_iter();
429
+    let command = args.next().ok_or(CliError::MissingReminderCommand)?;
430
+    let Some(command) = command.to_str() else {
431
+        return Err(CliError::UnknownReminderCommand(display_arg(&command)));
432
+    };
433
+
434
+    match command {
435
+        "run" => parse_reminder_run_args(args),
436
+        "install" => parse_reminder_install_args(args),
437
+        "uninstall" => no_extra_reminder_args(args, ReminderCliAction::Uninstall),
438
+        "status" => no_extra_reminder_args(args, ReminderCliAction::Status),
439
+        "test" => no_extra_reminder_args(args, ReminderCliAction::Test),
440
+        "--help" | "-h" => Ok(CliAction::Help),
441
+        _ => Err(CliError::UnknownReminderCommand(command.to_string())),
442
+    }
443
+}
444
+
445
+fn parse_reminder_run_args<I>(args: I) -> Result<CliAction, CliError>
446
+where
447
+    I: IntoIterator<Item = OsString>,
448
+{
449
+    let mut events_file = None;
450
+    let mut state_file = None;
451
+    let mut once = false;
452
+    let mut args = args.into_iter();
453
+
454
+    while let Some(arg) = args.next() {
455
+        if arg == "--events-file" {
456
+            if events_file.is_some() {
457
+                return Err(CliError::DuplicateEventsFile);
458
+            }
459
+            events_file = Some(PathBuf::from(
460
+                args.next().ok_or(CliError::MissingEventsFileValue)?,
461
+            ));
462
+            continue;
463
+        }
464
+        if let Some(value) = arg
465
+            .to_str()
466
+            .and_then(|value| value.strip_prefix("--events-file="))
467
+        {
468
+            if events_file.is_some() {
469
+                return Err(CliError::DuplicateEventsFile);
470
+            }
471
+            events_file = Some(PathBuf::from(value));
472
+            continue;
473
+        }
474
+        if arg == "--state-file" {
475
+            if state_file.is_some() {
476
+                return Err(CliError::DuplicateStateFile);
477
+            }
478
+            state_file = Some(PathBuf::from(
479
+                args.next().ok_or(CliError::MissingStateFileValue)?,
480
+            ));
481
+            continue;
482
+        }
483
+        if let Some(value) = arg
484
+            .to_str()
485
+            .and_then(|value| value.strip_prefix("--state-file="))
486
+        {
487
+            if state_file.is_some() {
488
+                return Err(CliError::DuplicateStateFile);
489
+            }
490
+            state_file = Some(PathBuf::from(value));
491
+            continue;
492
+        }
493
+        if arg == "--once" {
494
+            once = true;
495
+            continue;
496
+        }
497
+
498
+        return Err(CliError::UnknownArgument(display_arg(&arg)));
499
+    }
500
+
501
+    Ok(CliAction::Reminders(ReminderCliAction::Run(
502
+        ReminderRunConfig {
503
+            events_file: events_file.unwrap_or_else(default_events_file),
504
+            state_file: state_file.unwrap_or_else(default_state_file),
505
+            once,
506
+        },
507
+    )))
508
+}
509
+
510
+fn parse_reminder_install_args<I>(args: I) -> Result<CliAction, CliError>
511
+where
512
+    I: IntoIterator<Item = OsString>,
513
+{
514
+    let mut events_file = None;
515
+    let mut args = args.into_iter();
516
+
517
+    while let Some(arg) = args.next() {
518
+        if arg == "--events-file" {
519
+            if events_file.is_some() {
520
+                return Err(CliError::DuplicateEventsFile);
521
+            }
522
+            events_file = Some(PathBuf::from(
523
+                args.next().ok_or(CliError::MissingEventsFileValue)?,
524
+            ));
525
+            continue;
526
+        }
527
+        if let Some(value) = arg
528
+            .to_str()
529
+            .and_then(|value| value.strip_prefix("--events-file="))
530
+        {
531
+            if events_file.is_some() {
532
+                return Err(CliError::DuplicateEventsFile);
533
+            }
534
+            events_file = Some(PathBuf::from(value));
535
+            continue;
536
+        }
537
+
538
+        return Err(CliError::UnknownArgument(display_arg(&arg)));
539
+    }
540
+
541
+    Ok(CliAction::Reminders(ReminderCliAction::Install {
542
+        events_file: events_file.unwrap_or_else(default_events_file),
543
+    }))
544
+}
545
+
546
+fn no_extra_reminder_args<I>(args: I, action: ReminderCliAction) -> Result<CliAction, CliError>
547
+where
548
+    I: IntoIterator<Item = OsString>,
549
+{
550
+    let mut args = args.into_iter();
551
+    if let Some(arg) = args.next() {
552
+        return Err(CliError::UnknownArgument(display_arg(&arg)));
553
+    }
554
+
555
+    Ok(CliAction::Reminders(action))
556
+}
557
+
365558
 fn default_start_date() -> Date {
366559
     OffsetDateTime::now_local()
367560
         .unwrap_or_else(|_| OffsetDateTime::now_utc())
@@ -385,6 +578,85 @@ fn agenda_source(config: &AppConfig) -> Result<ConfiguredAgendaSource, LocalEven
385578
     ConfiguredAgendaSource::from_events_file(config.events_file.clone(), holidays)
386579
 }
387580
 
581
+fn run_reminder_action(
582
+    action: ReminderCliAction,
583
+    stdout: &mut impl Write,
584
+    stderr: &mut impl Write,
585
+) -> std::process::ExitCode {
586
+    match action {
587
+        ReminderCliAction::Run(config) => {
588
+            let daemon_config = ReminderDaemonConfig::new(config.events_file, config.state_file);
589
+            let mut notifier = SystemNotifier;
590
+            if config.once {
591
+                match run_once(
592
+                    &daemon_config,
593
+                    crate::reminders::current_local_datetime(),
594
+                    &mut notifier,
595
+                ) {
596
+                    Ok(summary) => {
597
+                        let _ = writeln!(
598
+                            stdout,
599
+                            "delivered={} skipped={} failed={}",
600
+                            summary.delivered, summary.skipped, summary.failed
601
+                        );
602
+                        std::process::ExitCode::SUCCESS
603
+                    }
604
+                    Err(err) => reminder_error_exit(stderr, err),
605
+                }
606
+            } else {
607
+                match run_daemon(daemon_config, &mut notifier) {
608
+                    Ok(()) => std::process::ExitCode::SUCCESS,
609
+                    Err(err) => reminder_error_exit(stderr, err),
610
+                }
611
+            }
612
+        }
613
+        ReminderCliAction::Install { events_file } => {
614
+            let config = match ServiceConfig::new(events_file) {
615
+                Ok(config) => config,
616
+                Err(err) => return service_error_exit(stderr, err),
617
+            };
618
+            let mut runner = SystemCommandRunner;
619
+            match install_service(&config, &mut runner) {
620
+                Ok(()) => {
621
+                    let _ = writeln!(stdout, "installed reminder service");
622
+                    std::process::ExitCode::SUCCESS
623
+                }
624
+                Err(err) => service_error_exit(stderr, err),
625
+            }
626
+        }
627
+        ReminderCliAction::Uninstall => {
628
+            let mut runner = SystemCommandRunner;
629
+            match uninstall_service(&mut runner) {
630
+                Ok(()) => {
631
+                    let _ = writeln!(stdout, "uninstalled reminder service");
632
+                    std::process::ExitCode::SUCCESS
633
+                }
634
+                Err(err) => service_error_exit(stderr, err),
635
+            }
636
+        }
637
+        ReminderCliAction::Status => {
638
+            let mut runner = SystemCommandRunner;
639
+            match service_status(&mut runner) {
640
+                Ok(status) => {
641
+                    let _ = writeln!(stdout, "{status}");
642
+                    std::process::ExitCode::SUCCESS
643
+                }
644
+                Err(err) => service_error_exit(stderr, err),
645
+            }
646
+        }
647
+        ReminderCliAction::Test => {
648
+            let mut notifier = SystemNotifier;
649
+            match test_notification(&mut notifier) {
650
+                Ok(()) => {
651
+                    let _ = writeln!(stdout, "sent test reminder notification");
652
+                    std::process::ExitCode::SUCCESS
653
+                }
654
+                Err(err) => reminder_error_exit(stderr, err),
655
+            }
656
+        }
657
+    }
658
+}
659
+
388660
 fn run_interactive_terminal<W>(
389661
     stdout: W,
390662
     app: AppState,
@@ -687,6 +959,16 @@ fn local_event_error_exit(
687959
     std::process::ExitCode::from(2)
688960
 }
689961
 
962
+fn reminder_error_exit(stderr: &mut impl Write, err: ReminderError) -> std::process::ExitCode {
963
+    let _ = writeln!(stderr, "error: {err}");
964
+    std::process::ExitCode::FAILURE
965
+}
966
+
967
+fn service_error_exit(stderr: &mut impl Write, err: ServiceError) -> std::process::ExitCode {
968
+    let _ = writeln!(stderr, "error: {err}");
969
+    std::process::ExitCode::FAILURE
970
+}
971
+
690972
 #[cfg(test)]
691973
 mod tests {
692974
     use super::*;
@@ -846,6 +1128,84 @@ mod tests {
8461128
         );
8471129
     }
8481130
 
1131
+    #[test]
1132
+    fn reminder_run_args_set_events_and_state_paths() {
1133
+        let today = date(2026, Month::April, 23);
1134
+
1135
+        let action = parse_args(
1136
+            [
1137
+                arg("reminders"),
1138
+                arg("run"),
1139
+                arg("--events-file"),
1140
+                arg("/tmp/events.json"),
1141
+                arg("--state-file=/tmp/state.json"),
1142
+                arg("--once"),
1143
+            ],
1144
+            today.into(),
1145
+        )
1146
+        .expect("parse succeeds");
1147
+
1148
+        assert_eq!(
1149
+            action,
1150
+            CliAction::Reminders(ReminderCliAction::Run(ReminderRunConfig {
1151
+                events_file: PathBuf::from("/tmp/events.json"),
1152
+                state_file: PathBuf::from("/tmp/state.json"),
1153
+                once: true,
1154
+            }))
1155
+        );
1156
+    }
1157
+
1158
+    #[test]
1159
+    fn reminder_install_args_set_events_path() {
1160
+        let today = date(2026, Month::April, 23);
1161
+
1162
+        let action = parse_args(
1163
+            [
1164
+                arg("reminders"),
1165
+                arg("install"),
1166
+                arg("--events-file=/tmp/events.json"),
1167
+            ],
1168
+            today.into(),
1169
+        )
1170
+        .expect("parse succeeds");
1171
+
1172
+        assert_eq!(
1173
+            action,
1174
+            CliAction::Reminders(ReminderCliAction::Install {
1175
+                events_file: PathBuf::from("/tmp/events.json"),
1176
+            })
1177
+        );
1178
+    }
1179
+
1180
+    #[test]
1181
+    fn reminder_args_are_rejected_when_invalid() {
1182
+        let today = date(2026, Month::April, 23);
1183
+
1184
+        assert_eq!(
1185
+            parse_args([arg("reminders")], today.into()).expect_err("missing command fails"),
1186
+            CliError::MissingReminderCommand
1187
+        );
1188
+        assert_eq!(
1189
+            parse_args([arg("reminders"), arg("bogus")], today.into())
1190
+                .expect_err("unknown command fails"),
1191
+            CliError::UnknownReminderCommand("bogus".to_string())
1192
+        );
1193
+        assert_eq!(
1194
+            parse_args(
1195
+                [
1196
+                    arg("reminders"),
1197
+                    arg("run"),
1198
+                    arg("--state-file"),
1199
+                    arg("/tmp/one.json"),
1200
+                    arg("--state-file=/tmp/two.json"),
1201
+                ],
1202
+                today.into(),
1203
+            )
1204
+            .expect_err("duplicate state path fails"),
1205
+            CliError::DuplicateStateFile
1206
+        );
1207
+    }
1208
+
8491209
     #[test]
8501210
     fn invalid_holiday_options_are_rejected() {
8511211
         let today = date(2026, Month::April, 23);
src/lib.rsmodified
@@ -3,4 +3,6 @@ pub mod app;
33
 pub mod calendar;
44
 pub mod cli;
55
 pub mod layout;
6
+pub mod reminders;
7
+pub mod services;
68
 pub mod tui;
src/reminders.rsadded
@@ -0,0 +1,762 @@
1
+use std::{
2
+    collections::HashMap,
3
+    error::Error,
4
+    fmt, fs, io,
5
+    path::{Path, PathBuf},
6
+    thread,
7
+    time::Duration as StdDuration,
8
+};
9
+
10
+use directories::ProjectDirs;
11
+use fs2::FileExt;
12
+use notify_rust::Notification;
13
+use serde::{Deserialize, Serialize};
14
+use time::{Date, Duration, Month, OffsetDateTime, PrimitiveDateTime, Time};
15
+
16
+use crate::{
17
+    agenda::{
18
+        AgendaSource, ConfiguredAgendaSource, DateRange, Event, EventDateTime, EventTiming,
19
+        HolidayProvider,
20
+    },
21
+    calendar::CalendarDate,
22
+};
23
+
24
+const STATE_VERSION: u8 = 1;
25
+const GRACE_MINUTES: i64 = 10;
26
+const PRUNE_AFTER_DAYS: i32 = 30;
27
+const LOOKAHEAD_DAYS: i32 = 47;
28
+const POLL_INTERVAL: StdDuration = StdDuration::from_secs(30);
29
+
30
+#[derive(Debug, Clone, PartialEq, Eq)]
31
+pub struct ReminderDaemonConfig {
32
+    pub events_file: PathBuf,
33
+    pub state_file: PathBuf,
34
+    pub poll_interval: StdDuration,
35
+    pub grace: Duration,
36
+}
37
+
38
+impl ReminderDaemonConfig {
39
+    pub fn new(events_file: PathBuf, state_file: PathBuf) -> Self {
40
+        Self {
41
+            events_file,
42
+            state_file,
43
+            poll_interval: POLL_INTERVAL,
44
+            grace: Duration::minutes(GRACE_MINUTES),
45
+        }
46
+    }
47
+
48
+    pub fn lock_file(&self) -> PathBuf {
49
+        self.state_file.with_extension("lock")
50
+    }
51
+}
52
+
53
+#[derive(Debug, Clone, PartialEq, Eq)]
54
+pub struct ReminderInstance {
55
+    pub key: String,
56
+    pub event_id: String,
57
+    pub title: String,
58
+    pub location: Option<String>,
59
+    pub event_start: EventDateTime,
60
+    pub fire_at: PrimitiveDateTime,
61
+    pub minutes_before: u16,
62
+    pub all_day: bool,
63
+}
64
+
65
+impl ReminderInstance {
66
+    pub fn notification_title(&self) -> String {
67
+        format!("Reminder: {}", self.title)
68
+    }
69
+
70
+    pub fn notification_body(&self) -> String {
71
+        let when = if self.all_day {
72
+            format!("All day on {}", self.event_start.date)
73
+        } else {
74
+            format!(
75
+                "Starts at {:02}:{:02} on {}",
76
+                self.event_start.time.hour(),
77
+                self.event_start.time.minute(),
78
+                self.event_start.date
79
+            )
80
+        };
81
+
82
+        if let Some(location) = &self.location {
83
+            format!("{when}\nLocation: {location}")
84
+        } else {
85
+            when
86
+        }
87
+    }
88
+}
89
+
90
+#[derive(Debug, Clone, PartialEq, Eq)]
91
+pub struct ReminderRunSummary {
92
+    pub delivered: usize,
93
+    pub skipped: usize,
94
+    pub failed: usize,
95
+}
96
+
97
+pub trait Notifier {
98
+    fn notify(&mut self, reminder: &ReminderInstance) -> Result<(), ReminderError>;
99
+}
100
+
101
+#[derive(Debug, Default)]
102
+pub struct SystemNotifier;
103
+
104
+impl Notifier for SystemNotifier {
105
+    fn notify(&mut self, reminder: &ReminderInstance) -> Result<(), ReminderError> {
106
+        Notification::new()
107
+            .summary(&reminder.notification_title())
108
+            .body(&reminder.notification_body())
109
+            .show()
110
+            .map(|_| ())
111
+            .map_err(|err| ReminderError::Notification(err.to_string()))
112
+    }
113
+}
114
+
115
+pub fn default_state_file() -> PathBuf {
116
+    if let Some(project_dirs) = ProjectDirs::from("com", "tenseleyFlow", "rcal") {
117
+        if let Some(state_dir) = project_dirs.state_dir() {
118
+            return state_dir.join("reminders-state.json");
119
+        }
120
+
121
+        return project_dirs.data_local_dir().join("reminders-state.json");
122
+    }
123
+
124
+    std::env::temp_dir()
125
+        .join("rcal")
126
+        .join("reminders-state.json")
127
+}
128
+
129
+pub fn default_log_file() -> PathBuf {
130
+    if let Some(project_dirs) = ProjectDirs::from("com", "tenseleyFlow", "rcal") {
131
+        return project_dirs.cache_dir().join("reminders.log");
132
+    }
133
+
134
+    std::env::temp_dir().join("rcal").join("reminders.log")
135
+}
136
+
137
+pub fn current_local_datetime() -> PrimitiveDateTime {
138
+    let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
139
+    PrimitiveDateTime::new(now.date(), now.time())
140
+}
141
+
142
+pub fn run_daemon(
143
+    config: ReminderDaemonConfig,
144
+    notifier: &mut dyn Notifier,
145
+) -> Result<(), ReminderError> {
146
+    let _lock = DaemonLock::acquire(&config.lock_file())?;
147
+    loop {
148
+        let now = current_local_datetime();
149
+        let _ = run_once(&config, now, notifier)?;
150
+        thread::sleep(config.poll_interval);
151
+    }
152
+}
153
+
154
+pub fn run_once(
155
+    config: &ReminderDaemonConfig,
156
+    now: PrimitiveDateTime,
157
+    notifier: &mut dyn Notifier,
158
+) -> Result<ReminderRunSummary, ReminderError> {
159
+    let source =
160
+        ConfiguredAgendaSource::from_events_file(&config.events_file, HolidayProvider::off())
161
+            .map_err(|err| ReminderError::Events(err.to_string()))?;
162
+    let mut state = ReminderState::load(&config.state_file)?;
163
+    let instances = reminder_instances(&source, now);
164
+    let mut delivered = 0;
165
+    let mut skipped = 0;
166
+    let mut failed = 0;
167
+    let expires_before = now - config.grace;
168
+
169
+    for instance in instances {
170
+        if state.contains(&instance.key) {
171
+            continue;
172
+        }
173
+
174
+        if instance.fire_at > now {
175
+            continue;
176
+        }
177
+
178
+        if instance.fire_at < expires_before {
179
+            state.record(instance, ReminderStatus::Skipped);
180
+            skipped += 1;
181
+            continue;
182
+        }
183
+
184
+        match notifier.notify(&instance) {
185
+            Ok(()) => {
186
+                state.record(instance, ReminderStatus::Delivered);
187
+                delivered += 1;
188
+            }
189
+            Err(_) => {
190
+                failed += 1;
191
+            }
192
+        }
193
+    }
194
+
195
+    state.prune(now.date());
196
+    state.save(&config.state_file)?;
197
+    Ok(ReminderRunSummary {
198
+        delivered,
199
+        skipped,
200
+        failed,
201
+    })
202
+}
203
+
204
+pub fn test_notification(notifier: &mut dyn Notifier) -> Result<(), ReminderError> {
205
+    let date = CalendarDate::from(current_local_datetime().date());
206
+    let reminder = ReminderInstance {
207
+        key: "test".to_string(),
208
+        event_id: "test".to_string(),
209
+        title: "rcal reminder test".to_string(),
210
+        location: None,
211
+        event_start: EventDateTime::new(date, current_local_datetime().time()),
212
+        fire_at: current_local_datetime(),
213
+        minutes_before: 0,
214
+        all_day: false,
215
+    };
216
+    notifier.notify(&reminder)
217
+}
218
+
219
+pub fn reminder_instances(
220
+    source: &dyn AgendaSource,
221
+    now: PrimitiveDateTime,
222
+) -> Vec<ReminderInstance> {
223
+    let range = DateRange::new(
224
+        CalendarDate::from(now.date()).add_days(-PRUNE_AFTER_DAYS),
225
+        CalendarDate::from(now.date()).add_days(LOOKAHEAD_DAYS),
226
+    )
227
+    .expect("reminder scan range is valid");
228
+    let mut instances = source
229
+        .events_intersecting(range)
230
+        .into_iter()
231
+        .filter(Event::is_local)
232
+        .flat_map(reminders_for_event)
233
+        .collect::<Vec<_>>();
234
+    instances.sort_by(|left, right| {
235
+        left.fire_at
236
+            .cmp(&right.fire_at)
237
+            .then(left.title.cmp(&right.title))
238
+            .then(left.key.cmp(&right.key))
239
+    });
240
+    instances
241
+}
242
+
243
+fn reminders_for_event(event: Event) -> Vec<ReminderInstance> {
244
+    let Some(event_start) = reminder_event_start(&event) else {
245
+        return Vec::new();
246
+    };
247
+    let all_day = matches!(event.timing, EventTiming::AllDay { .. });
248
+    let start_at = event_datetime_to_primitive(event_start);
249
+
250
+    event
251
+        .reminders
252
+        .iter()
253
+        .map(|reminder| {
254
+            let fire_at = start_at - Duration::minutes(i64::from(reminder.minutes_before));
255
+            ReminderInstance {
256
+                key: reminder_key(&event, event_start, reminder.minutes_before),
257
+                event_id: event.id.clone(),
258
+                title: event.title.clone(),
259
+                location: event.location.clone(),
260
+                event_start,
261
+                fire_at,
262
+                minutes_before: reminder.minutes_before,
263
+                all_day,
264
+            }
265
+        })
266
+        .collect()
267
+}
268
+
269
+fn reminder_event_start(event: &Event) -> Option<EventDateTime> {
270
+    match event.timing {
271
+        EventTiming::AllDay { date } => Some(EventDateTime::new(date, Time::MIDNIGHT)),
272
+        EventTiming::Timed { start, .. } => Some(start),
273
+    }
274
+}
275
+
276
+fn event_datetime_to_primitive(datetime: EventDateTime) -> PrimitiveDateTime {
277
+    PrimitiveDateTime::new(datetime.date.into(), datetime.time)
278
+}
279
+
280
+fn reminder_key(event: &Event, event_start: EventDateTime, minutes_before: u16) -> String {
281
+    format!(
282
+        "{}|{}T{:02}:{:02}|{}m",
283
+        event.id,
284
+        event_start.date,
285
+        event_start.time.hour(),
286
+        event_start.time.minute(),
287
+        minutes_before
288
+    )
289
+}
290
+
291
+#[derive(Debug, Clone, PartialEq, Eq)]
292
+struct ReminderState {
293
+    records: HashMap<String, ReminderStateRecord>,
294
+}
295
+
296
+impl ReminderState {
297
+    fn empty() -> Self {
298
+        Self {
299
+            records: HashMap::new(),
300
+        }
301
+    }
302
+
303
+    fn load(path: &Path) -> Result<Self, ReminderError> {
304
+        let body = match fs::read_to_string(path) {
305
+            Ok(body) => body,
306
+            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::empty()),
307
+            Err(err) => {
308
+                return Err(ReminderError::StateRead {
309
+                    path: path.to_path_buf(),
310
+                    reason: err.to_string(),
311
+                });
312
+            }
313
+        };
314
+        let file = serde_json::from_str::<ReminderStateFile>(&body).map_err(|err| {
315
+            ReminderError::StateParse {
316
+                path: path.to_path_buf(),
317
+                reason: err.to_string(),
318
+            }
319
+        })?;
320
+        if file.version != STATE_VERSION {
321
+            return Err(ReminderError::StateParse {
322
+                path: path.to_path_buf(),
323
+                reason: format!("unsupported reminder state version {}", file.version),
324
+            });
325
+        }
326
+        for record in &file.reminders {
327
+            if parse_date(&record.fire_date).is_none() || parse_time(&record.fire_time).is_none() {
328
+                return Err(ReminderError::StateParse {
329
+                    path: path.to_path_buf(),
330
+                    reason: format!("invalid reminder fire time for key '{}'", record.key),
331
+                });
332
+            }
333
+        }
334
+
335
+        Ok(Self {
336
+            records: file
337
+                .reminders
338
+                .into_iter()
339
+                .map(|record| (record.key.clone(), record))
340
+                .collect(),
341
+        })
342
+    }
343
+
344
+    fn save(&self, path: &Path) -> Result<(), ReminderError> {
345
+        if let Some(parent) = path.parent() {
346
+            fs::create_dir_all(parent).map_err(|err| ReminderError::StateWrite {
347
+                path: parent.to_path_buf(),
348
+                reason: err.to_string(),
349
+            })?;
350
+        }
351
+
352
+        let mut reminders = self.records.values().cloned().collect::<Vec<_>>();
353
+        reminders.sort_by(|left, right| left.key.cmp(&right.key));
354
+        let file = ReminderStateFile {
355
+            version: STATE_VERSION,
356
+            reminders,
357
+        };
358
+        let body =
359
+            serde_json::to_string_pretty(&file).map_err(|err| ReminderError::StateWrite {
360
+                path: path.to_path_buf(),
361
+                reason: err.to_string(),
362
+            })?;
363
+        let temp_path = path.with_extension(format!(
364
+            "{}.tmp",
365
+            path.extension()
366
+                .and_then(|extension| extension.to_str())
367
+                .unwrap_or("json")
368
+        ));
369
+        fs::write(&temp_path, body).map_err(|err| ReminderError::StateWrite {
370
+            path: temp_path.clone(),
371
+            reason: err.to_string(),
372
+        })?;
373
+        fs::rename(&temp_path, path).map_err(|err| ReminderError::StateWrite {
374
+            path: path.to_path_buf(),
375
+            reason: err.to_string(),
376
+        })
377
+    }
378
+
379
+    fn contains(&self, key: &str) -> bool {
380
+        self.records.contains_key(key)
381
+    }
382
+
383
+    fn record(&mut self, instance: ReminderInstance, status: ReminderStatus) {
384
+        self.records.insert(
385
+            instance.key.clone(),
386
+            ReminderStateRecord {
387
+                key: instance.key,
388
+                status,
389
+                fire_date: instance.fire_at.date().to_string(),
390
+                fire_time: format_time(instance.fire_at.time()),
391
+            },
392
+        );
393
+    }
394
+
395
+    fn prune(&mut self, today: Date) {
396
+        let cutoff = CalendarDate::from(today).add_days(-PRUNE_AFTER_DAYS);
397
+        self.records.retain(|_, record| {
398
+            record
399
+                .fire_date_value()
400
+                .map(|date| date >= cutoff)
401
+                .unwrap_or(true)
402
+        });
403
+    }
404
+}
405
+
406
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
407
+struct ReminderStateFile {
408
+    version: u8,
409
+    #[serde(default)]
410
+    reminders: Vec<ReminderStateRecord>,
411
+}
412
+
413
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
414
+struct ReminderStateRecord {
415
+    key: String,
416
+    status: ReminderStatus,
417
+    fire_date: String,
418
+    fire_time: String,
419
+}
420
+
421
+impl ReminderStateRecord {
422
+    fn fire_date_value(&self) -> Option<CalendarDate> {
423
+        parse_date(&self.fire_date)
424
+    }
425
+}
426
+
427
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
428
+#[serde(rename_all = "snake_case")]
429
+enum ReminderStatus {
430
+    Delivered,
431
+    Skipped,
432
+}
433
+
434
+struct DaemonLock {
435
+    _file: fs::File,
436
+}
437
+
438
+impl DaemonLock {
439
+    fn acquire(path: &Path) -> Result<Self, ReminderError> {
440
+        if let Some(parent) = path.parent() {
441
+            fs::create_dir_all(parent).map_err(|err| ReminderError::Lock {
442
+                path: parent.to_path_buf(),
443
+                reason: err.to_string(),
444
+            })?;
445
+        }
446
+        let file = fs::OpenOptions::new()
447
+            .read(true)
448
+            .write(true)
449
+            .create(true)
450
+            .truncate(false)
451
+            .open(path)
452
+            .map_err(|err| ReminderError::Lock {
453
+                path: path.to_path_buf(),
454
+                reason: err.to_string(),
455
+            })?;
456
+        file.try_lock_exclusive()
457
+            .map_err(|err| ReminderError::Lock {
458
+                path: path.to_path_buf(),
459
+                reason: err.to_string(),
460
+            })?;
461
+
462
+        Ok(Self { _file: file })
463
+    }
464
+}
465
+
466
+#[derive(Debug)]
467
+pub enum ReminderError {
468
+    Events(String),
469
+    Notification(String),
470
+    StateRead { path: PathBuf, reason: String },
471
+    StateParse { path: PathBuf, reason: String },
472
+    StateWrite { path: PathBuf, reason: String },
473
+    Lock { path: PathBuf, reason: String },
474
+}
475
+
476
+impl fmt::Display for ReminderError {
477
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
478
+        match self {
479
+            Self::Events(reason) => write!(f, "failed to load reminder events: {reason}"),
480
+            Self::Notification(reason) => write!(f, "failed to send notification: {reason}"),
481
+            Self::StateRead { path, reason } => {
482
+                write!(
483
+                    f,
484
+                    "failed to read reminder state {}: {reason}",
485
+                    path.display()
486
+                )
487
+            }
488
+            Self::StateParse { path, reason } => {
489
+                write!(
490
+                    f,
491
+                    "failed to parse reminder state {}: {reason}",
492
+                    path.display()
493
+                )
494
+            }
495
+            Self::StateWrite { path, reason } => {
496
+                write!(
497
+                    f,
498
+                    "failed to write reminder state {}: {reason}",
499
+                    path.display()
500
+                )
501
+            }
502
+            Self::Lock { path, reason } => {
503
+                write!(
504
+                    f,
505
+                    "failed to lock reminder daemon {}: {reason}",
506
+                    path.display()
507
+                )
508
+            }
509
+        }
510
+    }
511
+}
512
+
513
+impl Error for ReminderError {}
514
+
515
+fn format_time(time: Time) -> String {
516
+    format!("{:02}:{:02}", time.hour(), time.minute())
517
+}
518
+
519
+fn parse_date(value: &str) -> Option<CalendarDate> {
520
+    let mut parts = value.split('-');
521
+    let year = parts.next()?.parse::<i32>().ok()?;
522
+    let month = parts.next()?.parse::<u8>().ok()?;
523
+    let day = parts.next()?.parse::<u8>().ok()?;
524
+    if parts.next().is_some() {
525
+        return None;
526
+    }
527
+    CalendarDate::from_ymd(year, Month::try_from(month).ok()?, day).ok()
528
+}
529
+
530
+fn parse_time(value: &str) -> Option<Time> {
531
+    let mut parts = value.split(':');
532
+    let hour = parts.next()?.parse::<u8>().ok()?;
533
+    let minute = parts.next()?.parse::<u8>().ok()?;
534
+    if parts.next().is_some() {
535
+        return None;
536
+    }
537
+    Time::from_hms(hour, minute, 0).ok()
538
+}
539
+
540
+#[cfg(test)]
541
+mod tests {
542
+    use super::*;
543
+    use time::Month;
544
+
545
+    use crate::agenda::{
546
+        CreateEventDraft, CreateEventTiming, Event, InMemoryAgendaSource, RecurrenceEnd,
547
+        RecurrenceFrequency, RecurrenceRule, Reminder, SourceMetadata,
548
+    };
549
+
550
+    #[derive(Default)]
551
+    struct FakeNotifier {
552
+        sent: Vec<String>,
553
+        fail: bool,
554
+    }
555
+
556
+    impl Notifier for FakeNotifier {
557
+        fn notify(&mut self, reminder: &ReminderInstance) -> Result<(), ReminderError> {
558
+            if self.fail {
559
+                Err(ReminderError::Notification("boom".to_string()))
560
+            } else {
561
+                self.sent.push(reminder.key.clone());
562
+                Ok(())
563
+            }
564
+        }
565
+    }
566
+
567
+    fn date(day: u8) -> CalendarDate {
568
+        CalendarDate::from_ymd(2026, Month::April, day).expect("valid test date")
569
+    }
570
+
571
+    fn at(date: CalendarDate, hour: u8, minute: u8) -> EventDateTime {
572
+        EventDateTime::new(date, Time::from_hms(hour, minute, 0).expect("valid time"))
573
+    }
574
+
575
+    fn now(day: u8, hour: u8, minute: u8) -> PrimitiveDateTime {
576
+        PrimitiveDateTime::new(
577
+            date(day).into(),
578
+            Time::from_hms(hour, minute, 0).expect("valid time"),
579
+        )
580
+    }
581
+
582
+    fn timed_event(id: &str, title: &str, start: EventDateTime, end: EventDateTime) -> Event {
583
+        Event::timed(id, title, start, end, SourceMetadata::local()).expect("valid timed event")
584
+    }
585
+
586
+    fn temp_path(name: &str, file: &str) -> PathBuf {
587
+        std::env::temp_dir()
588
+            .join(format!("rcal-reminders-test-{}", std::process::id()))
589
+            .join(name)
590
+            .join(file)
591
+    }
592
+
593
+    #[test]
594
+    fn reminder_fire_times_cover_timed_all_day_cross_midnight_and_recurring_events() {
595
+        let day = date(23);
596
+        let timed = timed_event("timed", "Timed", at(day, 9, 0), at(day, 10, 0))
597
+            .with_reminders(vec![Reminder::minutes_before(15)]);
598
+        let all_day = Event::all_day("all-day", "All day", day, SourceMetadata::local())
599
+            .with_reminders(vec![Reminder::minutes_before(60)]);
600
+        let cross_midnight =
601
+            timed_event("late", "Late", at(day, 23, 30), at(day.add_days(1), 1, 0))
602
+                .with_reminders(vec![Reminder::minutes_before(30)]);
603
+        let recurring = timed_event("daily", "Daily", at(day, 8, 0), at(day, 8, 30))
604
+            .with_reminders(vec![Reminder::minutes_before(10)])
605
+            .with_recurrence(RecurrenceRule {
606
+                frequency: RecurrenceFrequency::Daily,
607
+                interval: 1,
608
+                end: RecurrenceEnd::Count(2),
609
+                weekdays: Vec::new(),
610
+                monthly: None,
611
+                yearly: None,
612
+            });
613
+        let source = InMemoryAgendaSource::with_events_and_holidays(
614
+            vec![timed, all_day, cross_midnight, recurring],
615
+            Vec::new(),
616
+        );
617
+
618
+        let instances = reminder_instances(&source, now(23, 9, 0));
619
+        let keys = instances
620
+            .iter()
621
+            .map(|instance| (instance.event_id.as_str(), instance.fire_at))
622
+            .collect::<Vec<_>>();
623
+
624
+        assert!(keys.contains(&("timed", now(23, 8, 45))));
625
+        assert!(keys.contains(&("all-day", now(22, 23, 0))));
626
+        assert!(keys.contains(&("late", now(23, 23, 0))));
627
+        assert!(
628
+            keys.iter()
629
+                .any(|(id, fire_at)| id.starts_with("daily#") && *fire_at == now(24, 7, 50))
630
+        );
631
+    }
632
+
633
+    #[test]
634
+    fn run_once_delivers_within_grace_and_skips_older_reminders() {
635
+        let dir = temp_path("grace", "events.json");
636
+        let _ = std::fs::remove_dir_all(dir.parent().expect("path has parent"));
637
+        let state_file = temp_path("grace", "state.json");
638
+        let mut source = ConfiguredAgendaSource::from_events_file(&dir, HolidayProvider::off())
639
+            .expect("events load");
640
+        source
641
+            .create_event(CreateEventDraft {
642
+                title: "Recent".to_string(),
643
+                timing: CreateEventTiming::Timed {
644
+                    start: at(date(23), 9, 5),
645
+                    end: at(date(23), 10, 0),
646
+                },
647
+                location: None,
648
+                notes: None,
649
+                reminders: vec![Reminder::minutes_before(10)],
650
+                recurrence: None,
651
+            })
652
+            .expect("event saves");
653
+        source
654
+            .create_event(CreateEventDraft {
655
+                title: "Old".to_string(),
656
+                timing: CreateEventTiming::Timed {
657
+                    start: at(date(23), 8, 30),
658
+                    end: at(date(23), 9, 0),
659
+                },
660
+                location: None,
661
+                notes: None,
662
+                reminders: vec![Reminder::minutes_before(30)],
663
+                recurrence: None,
664
+            })
665
+            .expect("event saves");
666
+        let config = ReminderDaemonConfig::new(dir.clone(), state_file);
667
+        let mut notifier = FakeNotifier::default();
668
+
669
+        let summary = run_once(&config, now(23, 9, 0), &mut notifier).expect("run succeeds");
670
+
671
+        let _ = std::fs::remove_dir_all(dir.parent().expect("test dir exists"));
672
+        assert_eq!(summary.delivered, 1);
673
+        assert_eq!(summary.skipped, 1);
674
+        assert_eq!(notifier.sent.len(), 1);
675
+    }
676
+
677
+    #[test]
678
+    fn delivered_state_dedupes_across_runs() {
679
+        let events_file = temp_path("dedupe", "events.json");
680
+        let _ = std::fs::remove_dir_all(events_file.parent().expect("path has parent"));
681
+        let state_file = temp_path("dedupe", "state.json");
682
+        let mut source =
683
+            ConfiguredAgendaSource::from_events_file(&events_file, HolidayProvider::off())
684
+                .expect("events load");
685
+        source
686
+            .create_event(CreateEventDraft {
687
+                title: "Planning".to_string(),
688
+                timing: CreateEventTiming::Timed {
689
+                    start: at(date(23), 9, 5),
690
+                    end: at(date(23), 10, 0),
691
+                },
692
+                location: None,
693
+                notes: None,
694
+                reminders: vec![Reminder::minutes_before(10)],
695
+                recurrence: None,
696
+            })
697
+            .expect("event saves");
698
+        let config = ReminderDaemonConfig::new(events_file.clone(), state_file);
699
+        let mut notifier = FakeNotifier::default();
700
+        let first = run_once(&config, now(23, 9, 0), &mut notifier).expect("first run");
701
+        let second = run_once(&config, now(23, 9, 1), &mut notifier).expect("second run");
702
+
703
+        let _ = std::fs::remove_dir_all(events_file.parent().expect("test dir exists"));
704
+        assert_eq!(first.delivered, 1);
705
+        assert_eq!(second.delivered, 0);
706
+        assert_eq!(notifier.sent.len(), 1);
707
+    }
708
+
709
+    #[test]
710
+    fn malformed_state_is_a_clear_error_and_prune_drops_old_records() {
711
+        let state_file = temp_path("state", "state.json");
712
+        let _ = std::fs::remove_dir_all(state_file.parent().expect("path has parent"));
713
+        std::fs::create_dir_all(state_file.parent().expect("path has parent"))
714
+            .expect("dir creates");
715
+        std::fs::write(&state_file, "not-json").expect("state writes");
716
+        let err = ReminderState::load(&state_file).expect_err("malformed state fails");
717
+        assert!(err.to_string().contains("failed to parse reminder state"));
718
+
719
+        let mut state = ReminderState::empty();
720
+        state.records.insert(
721
+            "old".to_string(),
722
+            ReminderStateRecord {
723
+                key: "old".to_string(),
724
+                status: ReminderStatus::Delivered,
725
+                fire_date: "2026-03-01".to_string(),
726
+                fire_time: "09:00".to_string(),
727
+            },
728
+        );
729
+        state.records.insert(
730
+            "new".to_string(),
731
+            ReminderStateRecord {
732
+                key: "new".to_string(),
733
+                status: ReminderStatus::Delivered,
734
+                fire_date: "2026-04-20".to_string(),
735
+                fire_time: "09:00".to_string(),
736
+            },
737
+        );
738
+        state.prune(date(23).into());
739
+
740
+        let _ = std::fs::remove_dir_all(state_file.parent().expect("test dir exists"));
741
+        assert!(!state.records.contains_key("old"));
742
+        assert!(state.records.contains_key("new"));
743
+    }
744
+
745
+    #[test]
746
+    fn notification_body_includes_time_and_location() {
747
+        let reminder = ReminderInstance {
748
+            key: "key".to_string(),
749
+            event_id: "event".to_string(),
750
+            title: "Planning".to_string(),
751
+            location: Some("Room 1".to_string()),
752
+            event_start: at(date(23), 9, 0),
753
+            fire_at: now(23, 8, 50),
754
+            minutes_before: 10,
755
+            all_day: false,
756
+        };
757
+
758
+        assert_eq!(reminder.notification_title(), "Reminder: Planning");
759
+        assert!(reminder.notification_body().contains("Starts at 09:00"));
760
+        assert!(reminder.notification_body().contains("Location: Room 1"));
761
+    }
762
+}
src/services.rsadded
@@ -0,0 +1,564 @@
1
+use std::{
2
+    error::Error,
3
+    fmt, fs,
4
+    path::{Path, PathBuf},
5
+    process::Command,
6
+};
7
+
8
+use directories::BaseDirs;
9
+
10
+use crate::{
11
+    agenda::default_events_file,
12
+    reminders::{default_log_file, default_state_file},
13
+};
14
+
15
+const SERVICE_LABEL: &str = "com.tenseleyflow.rcal.reminders";
16
+#[cfg(target_os = "linux")]
17
+const SYSTEMD_SERVICE_NAME: &str = "rcal-reminders.service";
18
+const WINDOWS_TASK_NAME: &str = "rcal-reminders";
19
+
20
+#[derive(Debug, Clone, PartialEq, Eq)]
21
+pub struct ServiceConfig {
22
+    pub executable: PathBuf,
23
+    pub events_file: PathBuf,
24
+    pub state_file: PathBuf,
25
+    pub log_file: PathBuf,
26
+}
27
+
28
+impl ServiceConfig {
29
+    pub fn new(events_file: PathBuf) -> Result<Self, ServiceError> {
30
+        let executable = std::env::current_exe().map_err(|err| ServiceError::CurrentExe {
31
+            reason: err.to_string(),
32
+        })?;
33
+        Ok(Self {
34
+            executable,
35
+            events_file,
36
+            state_file: default_state_file(),
37
+            log_file: default_log_file(),
38
+        })
39
+    }
40
+}
41
+
42
+impl Default for ServiceConfig {
43
+    fn default() -> Self {
44
+        Self {
45
+            executable: PathBuf::from("rcal"),
46
+            events_file: default_events_file(),
47
+            state_file: default_state_file(),
48
+            log_file: default_log_file(),
49
+        }
50
+    }
51
+}
52
+
53
+pub trait CommandRunner {
54
+    fn run(&mut self, program: &str, args: &[String]) -> Result<(), ServiceError>;
55
+    fn status(&mut self, program: &str, args: &[String]) -> Result<bool, ServiceError>;
56
+}
57
+
58
+#[derive(Debug, Default)]
59
+pub struct SystemCommandRunner;
60
+
61
+impl CommandRunner for SystemCommandRunner {
62
+    fn run(&mut self, program: &str, args: &[String]) -> Result<(), ServiceError> {
63
+        let status =
64
+            Command::new(program)
65
+                .args(args)
66
+                .status()
67
+                .map_err(|err| ServiceError::Command {
68
+                    program: program.to_string(),
69
+                    reason: err.to_string(),
70
+                })?;
71
+        if status.success() {
72
+            Ok(())
73
+        } else {
74
+            Err(ServiceError::Command {
75
+                program: program.to_string(),
76
+                reason: format!("exited with status {status}"),
77
+            })
78
+        }
79
+    }
80
+
81
+    fn status(&mut self, program: &str, args: &[String]) -> Result<bool, ServiceError> {
82
+        let status =
83
+            Command::new(program)
84
+                .args(args)
85
+                .status()
86
+                .map_err(|err| ServiceError::Command {
87
+                    program: program.to_string(),
88
+                    reason: err.to_string(),
89
+                })?;
90
+        Ok(status.success())
91
+    }
92
+}
93
+
94
+pub fn install_service(
95
+    config: &ServiceConfig,
96
+    runner: &mut dyn CommandRunner,
97
+) -> Result<(), ServiceError> {
98
+    platform_installer().install(config, runner)
99
+}
100
+
101
+pub fn uninstall_service(runner: &mut dyn CommandRunner) -> Result<(), ServiceError> {
102
+    platform_installer().uninstall(runner)
103
+}
104
+
105
+pub fn service_status(runner: &mut dyn CommandRunner) -> Result<ServiceStatus, ServiceError> {
106
+    platform_installer().status(runner)
107
+}
108
+
109
+fn platform_installer() -> Box<dyn ServiceInstaller> {
110
+    #[cfg(target_os = "macos")]
111
+    {
112
+        Box::new(MacLaunchAgent)
113
+    }
114
+    #[cfg(target_os = "linux")]
115
+    {
116
+        Box::new(LinuxSystemdUser)
117
+    }
118
+    #[cfg(target_os = "windows")]
119
+    {
120
+        Box::new(WindowsScheduledTask)
121
+    }
122
+    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
123
+    {
124
+        Box::new(UnsupportedInstaller)
125
+    }
126
+}
127
+
128
+trait ServiceInstaller {
129
+    fn install(
130
+        &self,
131
+        config: &ServiceConfig,
132
+        runner: &mut dyn CommandRunner,
133
+    ) -> Result<(), ServiceError>;
134
+    fn uninstall(&self, runner: &mut dyn CommandRunner) -> Result<(), ServiceError>;
135
+    fn status(&self, runner: &mut dyn CommandRunner) -> Result<ServiceStatus, ServiceError>;
136
+}
137
+
138
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139
+pub enum ServiceStatus {
140
+    Installed,
141
+    NotInstalled,
142
+}
143
+
144
+impl fmt::Display for ServiceStatus {
145
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146
+        match self {
147
+            Self::Installed => write!(f, "installed"),
148
+            Self::NotInstalled => write!(f, "not installed"),
149
+        }
150
+    }
151
+}
152
+
153
+#[derive(Debug)]
154
+struct MacLaunchAgent;
155
+
156
+impl MacLaunchAgent {
157
+    fn plist_path() -> Result<PathBuf, ServiceError> {
158
+        Ok(home_dir()?
159
+            .join("Library")
160
+            .join("LaunchAgents")
161
+            .join(format!("{SERVICE_LABEL}.plist")))
162
+    }
163
+}
164
+
165
+impl ServiceInstaller for MacLaunchAgent {
166
+    fn install(
167
+        &self,
168
+        config: &ServiceConfig,
169
+        runner: &mut dyn CommandRunner,
170
+    ) -> Result<(), ServiceError> {
171
+        let path = Self::plist_path()?;
172
+        if let Some(parent) = config.log_file.parent() {
173
+            fs::create_dir_all(parent).map_err(|err| ServiceError::Write {
174
+                path: parent.to_path_buf(),
175
+                reason: err.to_string(),
176
+            })?;
177
+        }
178
+        write_file(&path, &mac_launch_agent_plist(config))?;
179
+        let _ = runner.run("launchctl", &["unload".to_string(), path_string(&path)]);
180
+        runner.run(
181
+            "launchctl",
182
+            &["load".to_string(), "-w".to_string(), path_string(&path)],
183
+        )
184
+    }
185
+
186
+    fn uninstall(&self, runner: &mut dyn CommandRunner) -> Result<(), ServiceError> {
187
+        let path = Self::plist_path()?;
188
+        if path.exists() {
189
+            let _ = runner.run("launchctl", &["unload".to_string(), path_string(&path)]);
190
+            fs::remove_file(&path).map_err(|err| ServiceError::Write {
191
+                path: path.clone(),
192
+                reason: err.to_string(),
193
+            })?;
194
+        }
195
+        Ok(())
196
+    }
197
+
198
+    fn status(&self, runner: &mut dyn CommandRunner) -> Result<ServiceStatus, ServiceError> {
199
+        let path = Self::plist_path()?;
200
+        if !path.exists() {
201
+            return Ok(ServiceStatus::NotInstalled);
202
+        }
203
+        if runner.status(
204
+            "launchctl",
205
+            &["list".to_string(), SERVICE_LABEL.to_string()],
206
+        )? {
207
+            Ok(ServiceStatus::Installed)
208
+        } else {
209
+            Ok(ServiceStatus::NotInstalled)
210
+        }
211
+    }
212
+}
213
+
214
+#[cfg(target_os = "linux")]
215
+#[derive(Debug)]
216
+struct LinuxSystemdUser;
217
+
218
+#[cfg(target_os = "linux")]
219
+impl LinuxSystemdUser {
220
+    fn unit_path() -> Result<PathBuf, ServiceError> {
221
+        let config_home = if let Some(config_home) = std::env::var_os("XDG_CONFIG_HOME") {
222
+            PathBuf::from(config_home)
223
+        } else {
224
+            home_dir()?.join(".config")
225
+        };
226
+        Ok(config_home
227
+            .join("systemd")
228
+            .join("user")
229
+            .join(SYSTEMD_SERVICE_NAME))
230
+    }
231
+}
232
+
233
+#[cfg(target_os = "linux")]
234
+impl ServiceInstaller for LinuxSystemdUser {
235
+    fn install(
236
+        &self,
237
+        config: &ServiceConfig,
238
+        runner: &mut dyn CommandRunner,
239
+    ) -> Result<(), ServiceError> {
240
+        let path = Self::unit_path()?;
241
+        write_file(&path, &linux_systemd_unit(config))?;
242
+        runner.run(
243
+            "systemctl",
244
+            &["--user".to_string(), "daemon-reload".to_string()],
245
+        )?;
246
+        runner.run(
247
+            "systemctl",
248
+            &[
249
+                "--user".to_string(),
250
+                "enable".to_string(),
251
+                "--now".to_string(),
252
+                SYSTEMD_SERVICE_NAME.to_string(),
253
+            ],
254
+        )
255
+    }
256
+
257
+    fn uninstall(&self, runner: &mut dyn CommandRunner) -> Result<(), ServiceError> {
258
+        let path = Self::unit_path()?;
259
+        let _ = runner.run(
260
+            "systemctl",
261
+            &[
262
+                "--user".to_string(),
263
+                "disable".to_string(),
264
+                "--now".to_string(),
265
+                SYSTEMD_SERVICE_NAME.to_string(),
266
+            ],
267
+        );
268
+        if path.exists() {
269
+            fs::remove_file(&path).map_err(|err| ServiceError::Write {
270
+                path: path.clone(),
271
+                reason: err.to_string(),
272
+            })?;
273
+        }
274
+        runner.run(
275
+            "systemctl",
276
+            &["--user".to_string(), "daemon-reload".to_string()],
277
+        )
278
+    }
279
+
280
+    fn status(&self, runner: &mut dyn CommandRunner) -> Result<ServiceStatus, ServiceError> {
281
+        let path = Self::unit_path()?;
282
+        if !path.exists() {
283
+            return Ok(ServiceStatus::NotInstalled);
284
+        }
285
+        if runner.status(
286
+            "systemctl",
287
+            &[
288
+                "--user".to_string(),
289
+                "is-active".to_string(),
290
+                "--quiet".to_string(),
291
+                SYSTEMD_SERVICE_NAME.to_string(),
292
+            ],
293
+        )? {
294
+            Ok(ServiceStatus::Installed)
295
+        } else {
296
+            Ok(ServiceStatus::NotInstalled)
297
+        }
298
+    }
299
+}
300
+
301
+#[cfg(target_os = "windows")]
302
+#[derive(Debug)]
303
+struct WindowsScheduledTask;
304
+
305
+#[cfg(target_os = "windows")]
306
+impl ServiceInstaller for WindowsScheduledTask {
307
+    fn install(
308
+        &self,
309
+        config: &ServiceConfig,
310
+        runner: &mut dyn CommandRunner,
311
+    ) -> Result<(), ServiceError> {
312
+        runner.run("schtasks", &windows_schtasks_create_args(config))?;
313
+        runner.run(
314
+            "schtasks",
315
+            &[
316
+                "/Run".to_string(),
317
+                "/TN".to_string(),
318
+                WINDOWS_TASK_NAME.to_string(),
319
+            ],
320
+        )
321
+    }
322
+
323
+    fn uninstall(&self, runner: &mut dyn CommandRunner) -> Result<(), ServiceError> {
324
+        let _ = runner.run(
325
+            "schtasks",
326
+            &[
327
+                "/End".to_string(),
328
+                "/TN".to_string(),
329
+                WINDOWS_TASK_NAME.to_string(),
330
+            ],
331
+        );
332
+        runner.run(
333
+            "schtasks",
334
+            &[
335
+                "/Delete".to_string(),
336
+                "/TN".to_string(),
337
+                WINDOWS_TASK_NAME.to_string(),
338
+                "/F".to_string(),
339
+            ],
340
+        )
341
+    }
342
+
343
+    fn status(&self, runner: &mut dyn CommandRunner) -> Result<ServiceStatus, ServiceError> {
344
+        if runner.status(
345
+            "schtasks",
346
+            &[
347
+                "/Query".to_string(),
348
+                "/TN".to_string(),
349
+                WINDOWS_TASK_NAME.to_string(),
350
+            ],
351
+        )? {
352
+            Ok(ServiceStatus::Installed)
353
+        } else {
354
+            Ok(ServiceStatus::NotInstalled)
355
+        }
356
+    }
357
+}
358
+
359
+#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
360
+#[derive(Debug)]
361
+struct UnsupportedInstaller;
362
+
363
+#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
364
+impl ServiceInstaller for UnsupportedInstaller {
365
+    fn install(
366
+        &self,
367
+        _config: &ServiceConfig,
368
+        _runner: &mut dyn CommandRunner,
369
+    ) -> Result<(), ServiceError> {
370
+        Err(ServiceError::UnsupportedPlatform)
371
+    }
372
+
373
+    fn uninstall(&self, _runner: &mut dyn CommandRunner) -> Result<(), ServiceError> {
374
+        Err(ServiceError::UnsupportedPlatform)
375
+    }
376
+
377
+    fn status(&self, _runner: &mut dyn CommandRunner) -> Result<ServiceStatus, ServiceError> {
378
+        Err(ServiceError::UnsupportedPlatform)
379
+    }
380
+}
381
+
382
+pub fn mac_launch_agent_plist(config: &ServiceConfig) -> String {
383
+    let stdout = config.log_file.with_extension("out.log");
384
+    let stderr = config.log_file.with_extension("err.log");
385
+    format!(
386
+        r#"<?xml version="1.0" encoding="UTF-8"?>
387
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
388
+<plist version="1.0">
389
+<dict>
390
+  <key>Label</key>
391
+  <string>{}</string>
392
+  <key>ProgramArguments</key>
393
+  <array>
394
+    <string>{}</string>
395
+    <string>reminders</string>
396
+    <string>run</string>
397
+    <string>--events-file</string>
398
+    <string>{}</string>
399
+    <string>--state-file</string>
400
+    <string>{}</string>
401
+  </array>
402
+  <key>RunAtLoad</key>
403
+  <true/>
404
+  <key>KeepAlive</key>
405
+  <true/>
406
+  <key>StandardOutPath</key>
407
+  <string>{}</string>
408
+  <key>StandardErrorPath</key>
409
+  <string>{}</string>
410
+</dict>
411
+</plist>
412
+"#,
413
+        SERVICE_LABEL,
414
+        xml_escape(&path_string(&config.executable)),
415
+        xml_escape(&path_string(&config.events_file)),
416
+        xml_escape(&path_string(&config.state_file)),
417
+        xml_escape(&path_string(&stdout)),
418
+        xml_escape(&path_string(&stderr)),
419
+    )
420
+}
421
+
422
+pub fn linux_systemd_unit(config: &ServiceConfig) -> String {
423
+    format!(
424
+        "[Unit]\nDescription=rcal reminder notifications\n\n[Service]\nExecStart={} reminders run --events-file {} --state-file {}\nRestart=always\nRestartSec=5\n\n[Install]\nWantedBy=default.target\n",
425
+        systemd_escape(&path_string(&config.executable)),
426
+        systemd_escape(&path_string(&config.events_file)),
427
+        systemd_escape(&path_string(&config.state_file)),
428
+    )
429
+}
430
+
431
+pub fn windows_schtasks_create_args(config: &ServiceConfig) -> Vec<String> {
432
+    let command = format!(
433
+        "\"{}\" reminders run --events-file \"{}\" --state-file \"{}\"",
434
+        path_string(&config.executable),
435
+        path_string(&config.events_file),
436
+        path_string(&config.state_file),
437
+    );
438
+    vec![
439
+        "/Create".to_string(),
440
+        "/TN".to_string(),
441
+        WINDOWS_TASK_NAME.to_string(),
442
+        "/SC".to_string(),
443
+        "ONLOGON".to_string(),
444
+        "/TR".to_string(),
445
+        command,
446
+        "/F".to_string(),
447
+    ]
448
+}
449
+
450
+#[derive(Debug)]
451
+pub enum ServiceError {
452
+    CurrentExe { reason: String },
453
+    MissingHome,
454
+    UnsupportedPlatform,
455
+    Write { path: PathBuf, reason: String },
456
+    Command { program: String, reason: String },
457
+}
458
+
459
+impl fmt::Display for ServiceError {
460
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
461
+        match self {
462
+            Self::CurrentExe { reason } => {
463
+                write!(f, "failed to locate current executable: {reason}")
464
+            }
465
+            Self::MissingHome => write!(f, "failed to locate a user home directory"),
466
+            Self::UnsupportedPlatform => {
467
+                write!(f, "reminder services are unsupported on this platform")
468
+            }
469
+            Self::Write { path, reason } => {
470
+                write!(f, "failed to write {}: {reason}", path.display())
471
+            }
472
+            Self::Command { program, reason } => write!(f, "{program} failed: {reason}"),
473
+        }
474
+    }
475
+}
476
+
477
+impl Error for ServiceError {}
478
+
479
+fn write_file(path: &Path, body: &str) -> Result<(), ServiceError> {
480
+    if let Some(parent) = path.parent() {
481
+        fs::create_dir_all(parent).map_err(|err| ServiceError::Write {
482
+            path: parent.to_path_buf(),
483
+            reason: err.to_string(),
484
+        })?;
485
+    }
486
+
487
+    fs::write(path, body).map_err(|err| ServiceError::Write {
488
+        path: path.to_path_buf(),
489
+        reason: err.to_string(),
490
+    })
491
+}
492
+
493
+fn home_dir() -> Result<PathBuf, ServiceError> {
494
+    BaseDirs::new()
495
+        .map(|dirs| dirs.home_dir().to_path_buf())
496
+        .ok_or(ServiceError::MissingHome)
497
+}
498
+
499
+fn path_string(path: &Path) -> String {
500
+    path.to_string_lossy().into_owned()
501
+}
502
+
503
+fn xml_escape(value: &str) -> String {
504
+    value
505
+        .replace('&', "&amp;")
506
+        .replace('<', "&lt;")
507
+        .replace('>', "&gt;")
508
+        .replace('"', "&quot;")
509
+        .replace('\'', "&apos;")
510
+}
511
+
512
+fn systemd_escape(value: &str) -> String {
513
+    if value
514
+        .chars()
515
+        .all(|ch| !ch.is_whitespace() && ch != '\\' && ch != '"')
516
+    {
517
+        value.to_string()
518
+    } else {
519
+        format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
520
+    }
521
+}
522
+
523
+#[cfg(test)]
524
+mod tests {
525
+    use super::*;
526
+
527
+    fn config() -> ServiceConfig {
528
+        ServiceConfig {
529
+            executable: PathBuf::from("/usr/local/bin/rcal"),
530
+            events_file: PathBuf::from("/tmp/rcal/events.json"),
531
+            state_file: PathBuf::from("/tmp/rcal/state.json"),
532
+            log_file: PathBuf::from("/tmp/rcal/reminders.log"),
533
+        }
534
+    }
535
+
536
+    #[test]
537
+    fn mac_launch_agent_contains_reminder_run_command() {
538
+        let plist = mac_launch_agent_plist(&config());
539
+
540
+        assert!(plist.contains("com.tenseleyflow.rcal.reminders"));
541
+        assert!(plist.contains("<string>/usr/local/bin/rcal</string>"));
542
+        assert!(plist.contains("<string>reminders</string>"));
543
+        assert!(plist.contains("<string>--events-file</string>"));
544
+    }
545
+
546
+    #[test]
547
+    fn linux_unit_contains_reminder_run_command() {
548
+        let unit = linux_systemd_unit(&config());
549
+
550
+        assert!(unit.contains("Description=rcal reminder notifications"));
551
+        assert!(unit.contains("ExecStart=/usr/local/bin/rcal reminders run"));
552
+        assert!(unit.contains("Restart=always"));
553
+    }
554
+
555
+    #[test]
556
+    fn windows_task_command_contains_reminder_run_command() {
557
+        let args = windows_schtasks_create_args(&config());
558
+        let joined = args.join(" ");
559
+
560
+        assert!(joined.contains("/Create"));
561
+        assert!(joined.contains("rcal-reminders"));
562
+        assert!(joined.contains("\"/usr/local/bin/rcal\" reminders run"));
563
+    }
564
+}