personal activity index (bluesky, leaflet, substack) pai.desertthunder.dev
rss bluesky
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(cli): restructure CLI with command handling and SQLite storage

+1522 -9
+604
Cargo.lock
··· 1 + # This file is automatically @generated by Cargo. 2 + # It is not intended for manual editing. 3 + version = 4 4 + 5 + [[package]] 6 + name = "android_system_properties" 7 + version = "0.1.5" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 10 + dependencies = [ 11 + "libc", 12 + ] 13 + 14 + [[package]] 15 + name = "anstream" 16 + version = "0.6.21" 17 + source = "registry+https://github.com/rust-lang/crates.io-index" 18 + checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" 19 + dependencies = [ 20 + "anstyle", 21 + "anstyle-parse", 22 + "anstyle-query", 23 + "anstyle-wincon", 24 + "colorchoice", 25 + "is_terminal_polyfill", 26 + "utf8parse", 27 + ] 28 + 29 + [[package]] 30 + name = "anstyle" 31 + version = "1.0.13" 32 + source = "registry+https://github.com/rust-lang/crates.io-index" 33 + checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" 34 + 35 + [[package]] 36 + name = "anstyle-parse" 37 + version = "0.2.7" 38 + source = "registry+https://github.com/rust-lang/crates.io-index" 39 + checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" 40 + dependencies = [ 41 + "utf8parse", 42 + ] 43 + 44 + [[package]] 45 + name = "anstyle-query" 46 + version = "1.1.5" 47 + source = "registry+https://github.com/rust-lang/crates.io-index" 48 + checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" 49 + dependencies = [ 50 + "windows-sys", 51 + ] 52 + 53 + [[package]] 54 + name = "anstyle-wincon" 55 + version = "3.0.11" 56 + source = "registry+https://github.com/rust-lang/crates.io-index" 57 + checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" 58 + dependencies = [ 59 + "anstyle", 60 + "once_cell_polyfill", 61 + "windows-sys", 62 + ] 63 + 64 + [[package]] 65 + name = "autocfg" 66 + version = "1.5.0" 67 + source = "registry+https://github.com/rust-lang/crates.io-index" 68 + checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 69 + 70 + [[package]] 71 + name = "bitflags" 72 + version = "2.10.0" 73 + source = "registry+https://github.com/rust-lang/crates.io-index" 74 + checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" 75 + 76 + [[package]] 77 + name = "bumpalo" 78 + version = "3.19.0" 79 + source = "registry+https://github.com/rust-lang/crates.io-index" 80 + checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 81 + 82 + [[package]] 83 + name = "cc" 84 + version = "1.2.47" 85 + source = "registry+https://github.com/rust-lang/crates.io-index" 86 + checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" 87 + dependencies = [ 88 + "find-msvc-tools", 89 + "shlex", 90 + ] 91 + 92 + [[package]] 93 + name = "cfg-if" 94 + version = "1.0.4" 95 + source = "registry+https://github.com/rust-lang/crates.io-index" 96 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 97 + 98 + [[package]] 99 + name = "chrono" 100 + version = "0.4.42" 101 + source = "registry+https://github.com/rust-lang/crates.io-index" 102 + checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" 103 + dependencies = [ 104 + "iana-time-zone", 105 + "js-sys", 106 + "num-traits", 107 + "wasm-bindgen", 108 + "windows-link", 109 + ] 110 + 111 + [[package]] 112 + name = "clap" 113 + version = "4.5.53" 114 + source = "registry+https://github.com/rust-lang/crates.io-index" 115 + checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" 116 + dependencies = [ 117 + "clap_builder", 118 + "clap_derive", 119 + ] 120 + 121 + [[package]] 122 + name = "clap_builder" 123 + version = "4.5.53" 124 + source = "registry+https://github.com/rust-lang/crates.io-index" 125 + checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" 126 + dependencies = [ 127 + "anstream", 128 + "anstyle", 129 + "clap_lex", 130 + "strsim", 131 + ] 132 + 133 + [[package]] 134 + name = "clap_derive" 135 + version = "4.5.49" 136 + source = "registry+https://github.com/rust-lang/crates.io-index" 137 + checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" 138 + dependencies = [ 139 + "heck", 140 + "proc-macro2", 141 + "quote", 142 + "syn", 143 + ] 144 + 145 + [[package]] 146 + name = "clap_lex" 147 + version = "0.7.6" 148 + source = "registry+https://github.com/rust-lang/crates.io-index" 149 + checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" 150 + 151 + [[package]] 152 + name = "colorchoice" 153 + version = "1.0.4" 154 + source = "registry+https://github.com/rust-lang/crates.io-index" 155 + checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 156 + 157 + [[package]] 158 + name = "core-foundation-sys" 159 + version = "0.8.7" 160 + source = "registry+https://github.com/rust-lang/crates.io-index" 161 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 162 + 163 + [[package]] 164 + name = "dirs" 165 + version = "6.0.0" 166 + source = "registry+https://github.com/rust-lang/crates.io-index" 167 + checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" 168 + dependencies = [ 169 + "dirs-sys", 170 + ] 171 + 172 + [[package]] 173 + name = "dirs-sys" 174 + version = "0.5.0" 175 + source = "registry+https://github.com/rust-lang/crates.io-index" 176 + checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" 177 + dependencies = [ 178 + "libc", 179 + "option-ext", 180 + "redox_users", 181 + "windows-sys", 182 + ] 183 + 184 + [[package]] 185 + name = "fallible-iterator" 186 + version = "0.3.0" 187 + source = "registry+https://github.com/rust-lang/crates.io-index" 188 + checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" 189 + 190 + [[package]] 191 + name = "fallible-streaming-iterator" 192 + version = "0.1.9" 193 + source = "registry+https://github.com/rust-lang/crates.io-index" 194 + checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" 195 + 196 + [[package]] 197 + name = "find-msvc-tools" 198 + version = "0.1.5" 199 + source = "registry+https://github.com/rust-lang/crates.io-index" 200 + checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" 201 + 202 + [[package]] 203 + name = "foldhash" 204 + version = "0.1.5" 205 + source = "registry+https://github.com/rust-lang/crates.io-index" 206 + checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 207 + 208 + [[package]] 209 + name = "getrandom" 210 + version = "0.2.16" 211 + source = "registry+https://github.com/rust-lang/crates.io-index" 212 + checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 213 + dependencies = [ 214 + "cfg-if", 215 + "libc", 216 + "wasi", 217 + ] 218 + 219 + [[package]] 220 + name = "hashbrown" 221 + version = "0.15.5" 222 + source = "registry+https://github.com/rust-lang/crates.io-index" 223 + checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 224 + dependencies = [ 225 + "foldhash", 226 + ] 227 + 228 + [[package]] 229 + name = "hashlink" 230 + version = "0.10.0" 231 + source = "registry+https://github.com/rust-lang/crates.io-index" 232 + checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" 233 + dependencies = [ 234 + "hashbrown", 235 + ] 236 + 237 + [[package]] 238 + name = "heck" 239 + version = "0.5.0" 240 + source = "registry+https://github.com/rust-lang/crates.io-index" 241 + checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 242 + 243 + [[package]] 244 + name = "iana-time-zone" 245 + version = "0.1.64" 246 + source = "registry+https://github.com/rust-lang/crates.io-index" 247 + checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" 248 + dependencies = [ 249 + "android_system_properties", 250 + "core-foundation-sys", 251 + "iana-time-zone-haiku", 252 + "js-sys", 253 + "log", 254 + "wasm-bindgen", 255 + "windows-core", 256 + ] 257 + 258 + [[package]] 259 + name = "iana-time-zone-haiku" 260 + version = "0.1.2" 261 + source = "registry+https://github.com/rust-lang/crates.io-index" 262 + checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 263 + dependencies = [ 264 + "cc", 265 + ] 266 + 267 + [[package]] 268 + name = "is_terminal_polyfill" 269 + version = "1.70.2" 270 + source = "registry+https://github.com/rust-lang/crates.io-index" 271 + checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 272 + 273 + [[package]] 274 + name = "js-sys" 275 + version = "0.3.82" 276 + source = "registry+https://github.com/rust-lang/crates.io-index" 277 + checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" 278 + dependencies = [ 279 + "once_cell", 280 + "wasm-bindgen", 281 + ] 282 + 283 + [[package]] 284 + name = "libc" 285 + version = "0.2.177" 286 + source = "registry+https://github.com/rust-lang/crates.io-index" 287 + checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" 288 + 289 + [[package]] 290 + name = "libredox" 291 + version = "0.1.10" 292 + source = "registry+https://github.com/rust-lang/crates.io-index" 293 + checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" 294 + dependencies = [ 295 + "bitflags", 296 + "libc", 297 + ] 298 + 299 + [[package]] 300 + name = "libsqlite3-sys" 301 + version = "0.35.0" 302 + source = "registry+https://github.com/rust-lang/crates.io-index" 303 + checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" 304 + dependencies = [ 305 + "cc", 306 + "pkg-config", 307 + "vcpkg", 308 + ] 309 + 310 + [[package]] 311 + name = "log" 312 + version = "0.4.28" 313 + source = "registry+https://github.com/rust-lang/crates.io-index" 314 + checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 315 + 316 + [[package]] 317 + name = "num-traits" 318 + version = "0.2.19" 319 + source = "registry+https://github.com/rust-lang/crates.io-index" 320 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 321 + dependencies = [ 322 + "autocfg", 323 + ] 324 + 325 + [[package]] 326 + name = "once_cell" 327 + version = "1.21.3" 328 + source = "registry+https://github.com/rust-lang/crates.io-index" 329 + checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 330 + 331 + [[package]] 332 + name = "once_cell_polyfill" 333 + version = "1.70.2" 334 + source = "registry+https://github.com/rust-lang/crates.io-index" 335 + checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 336 + 337 + [[package]] 338 + name = "option-ext" 339 + version = "0.2.0" 340 + source = "registry+https://github.com/rust-lang/crates.io-index" 341 + checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 342 + 343 + [[package]] 344 + name = "pai" 345 + version = "0.1.0" 346 + dependencies = [ 347 + "chrono", 348 + "clap", 349 + "dirs", 350 + "pai-core", 351 + "rusqlite", 352 + ] 353 + 354 + [[package]] 355 + name = "pai-core" 356 + version = "0.1.0" 357 + dependencies = [ 358 + "thiserror", 359 + ] 360 + 361 + [[package]] 362 + name = "pai-worker" 363 + version = "0.1.0" 364 + 365 + [[package]] 366 + name = "pkg-config" 367 + version = "0.3.32" 368 + source = "registry+https://github.com/rust-lang/crates.io-index" 369 + checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 370 + 371 + [[package]] 372 + name = "proc-macro2" 373 + version = "1.0.103" 374 + source = "registry+https://github.com/rust-lang/crates.io-index" 375 + checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" 376 + dependencies = [ 377 + "unicode-ident", 378 + ] 379 + 380 + [[package]] 381 + name = "quote" 382 + version = "1.0.42" 383 + source = "registry+https://github.com/rust-lang/crates.io-index" 384 + checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" 385 + dependencies = [ 386 + "proc-macro2", 387 + ] 388 + 389 + [[package]] 390 + name = "redox_users" 391 + version = "0.5.2" 392 + source = "registry+https://github.com/rust-lang/crates.io-index" 393 + checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" 394 + dependencies = [ 395 + "getrandom", 396 + "libredox", 397 + "thiserror", 398 + ] 399 + 400 + [[package]] 401 + name = "rusqlite" 402 + version = "0.37.0" 403 + source = "registry+https://github.com/rust-lang/crates.io-index" 404 + checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" 405 + dependencies = [ 406 + "bitflags", 407 + "fallible-iterator", 408 + "fallible-streaming-iterator", 409 + "hashlink", 410 + "libsqlite3-sys", 411 + "smallvec", 412 + ] 413 + 414 + [[package]] 415 + name = "rustversion" 416 + version = "1.0.22" 417 + source = "registry+https://github.com/rust-lang/crates.io-index" 418 + checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 419 + 420 + [[package]] 421 + name = "shlex" 422 + version = "1.3.0" 423 + source = "registry+https://github.com/rust-lang/crates.io-index" 424 + checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 425 + 426 + [[package]] 427 + name = "smallvec" 428 + version = "1.15.1" 429 + source = "registry+https://github.com/rust-lang/crates.io-index" 430 + checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 431 + 432 + [[package]] 433 + name = "strsim" 434 + version = "0.11.1" 435 + source = "registry+https://github.com/rust-lang/crates.io-index" 436 + checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 437 + 438 + [[package]] 439 + name = "syn" 440 + version = "2.0.111" 441 + source = "registry+https://github.com/rust-lang/crates.io-index" 442 + checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" 443 + dependencies = [ 444 + "proc-macro2", 445 + "quote", 446 + "unicode-ident", 447 + ] 448 + 449 + [[package]] 450 + name = "thiserror" 451 + version = "2.0.17" 452 + source = "registry+https://github.com/rust-lang/crates.io-index" 453 + checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" 454 + dependencies = [ 455 + "thiserror-impl", 456 + ] 457 + 458 + [[package]] 459 + name = "thiserror-impl" 460 + version = "2.0.17" 461 + source = "registry+https://github.com/rust-lang/crates.io-index" 462 + checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" 463 + dependencies = [ 464 + "proc-macro2", 465 + "quote", 466 + "syn", 467 + ] 468 + 469 + [[package]] 470 + name = "unicode-ident" 471 + version = "1.0.22" 472 + source = "registry+https://github.com/rust-lang/crates.io-index" 473 + checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" 474 + 475 + [[package]] 476 + name = "utf8parse" 477 + version = "0.2.2" 478 + source = "registry+https://github.com/rust-lang/crates.io-index" 479 + checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 480 + 481 + [[package]] 482 + name = "vcpkg" 483 + version = "0.2.15" 484 + source = "registry+https://github.com/rust-lang/crates.io-index" 485 + checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 486 + 487 + [[package]] 488 + name = "wasi" 489 + version = "0.11.1+wasi-snapshot-preview1" 490 + source = "registry+https://github.com/rust-lang/crates.io-index" 491 + checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 492 + 493 + [[package]] 494 + name = "wasm-bindgen" 495 + version = "0.2.105" 496 + source = "registry+https://github.com/rust-lang/crates.io-index" 497 + checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" 498 + dependencies = [ 499 + "cfg-if", 500 + "once_cell", 501 + "rustversion", 502 + "wasm-bindgen-macro", 503 + "wasm-bindgen-shared", 504 + ] 505 + 506 + [[package]] 507 + name = "wasm-bindgen-macro" 508 + version = "0.2.105" 509 + source = "registry+https://github.com/rust-lang/crates.io-index" 510 + checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" 511 + dependencies = [ 512 + "quote", 513 + "wasm-bindgen-macro-support", 514 + ] 515 + 516 + [[package]] 517 + name = "wasm-bindgen-macro-support" 518 + version = "0.2.105" 519 + source = "registry+https://github.com/rust-lang/crates.io-index" 520 + checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" 521 + dependencies = [ 522 + "bumpalo", 523 + "proc-macro2", 524 + "quote", 525 + "syn", 526 + "wasm-bindgen-shared", 527 + ] 528 + 529 + [[package]] 530 + name = "wasm-bindgen-shared" 531 + version = "0.2.105" 532 + source = "registry+https://github.com/rust-lang/crates.io-index" 533 + checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" 534 + dependencies = [ 535 + "unicode-ident", 536 + ] 537 + 538 + [[package]] 539 + name = "windows-core" 540 + version = "0.62.2" 541 + source = "registry+https://github.com/rust-lang/crates.io-index" 542 + checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" 543 + dependencies = [ 544 + "windows-implement", 545 + "windows-interface", 546 + "windows-link", 547 + "windows-result", 548 + "windows-strings", 549 + ] 550 + 551 + [[package]] 552 + name = "windows-implement" 553 + version = "0.60.2" 554 + source = "registry+https://github.com/rust-lang/crates.io-index" 555 + checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" 556 + dependencies = [ 557 + "proc-macro2", 558 + "quote", 559 + "syn", 560 + ] 561 + 562 + [[package]] 563 + name = "windows-interface" 564 + version = "0.59.3" 565 + source = "registry+https://github.com/rust-lang/crates.io-index" 566 + checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" 567 + dependencies = [ 568 + "proc-macro2", 569 + "quote", 570 + "syn", 571 + ] 572 + 573 + [[package]] 574 + name = "windows-link" 575 + version = "0.2.1" 576 + source = "registry+https://github.com/rust-lang/crates.io-index" 577 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 578 + 579 + [[package]] 580 + name = "windows-result" 581 + version = "0.4.1" 582 + source = "registry+https://github.com/rust-lang/crates.io-index" 583 + checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" 584 + dependencies = [ 585 + "windows-link", 586 + ] 587 + 588 + [[package]] 589 + name = "windows-strings" 590 + version = "0.5.1" 591 + source = "registry+https://github.com/rust-lang/crates.io-index" 592 + checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" 593 + dependencies = [ 594 + "windows-link", 595 + ] 596 + 597 + [[package]] 598 + name = "windows-sys" 599 + version = "0.61.2" 600 + source = "registry+https://github.com/rust-lang/crates.io-index" 601 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 602 + dependencies = [ 603 + "windows-link", 604 + ]
+35
Cargo.toml
··· 1 1 [workspace] 2 2 resolver = "2" 3 3 members = ["cli", "core", "worker"] 4 + 5 + [workspace.lints.clippy] 6 + bool_comparison = "deny" 7 + duplicate_mod = "deny" 8 + inconsistent_struct_constructor = "deny" 9 + invalid_regex = "deny" 10 + mem_forget = "deny" 11 + mixed_case_hex_literals = "deny" 12 + suspicious_arithmetic_impl = "deny" 13 + uninit_assumed_init = "deny" 14 + suspicious_else_formatting = "deny" 15 + suspicious_op_assign_impl = "deny" 16 + suspicious_to_owned = "deny" 17 + cmp_owned = "deny" 18 + cmp_null = "deny" 19 + manual_map = "deny" 20 + 21 + too_many_arguments = "warn" 22 + cognitive_complexity = "warn" 23 + large_enum_variant = "warn" 24 + needless_borrow = "warn" 25 + needless_pass_by_value = "warn" 26 + redundant_clone = "warn" 27 + unnecessary_cast = "warn" 28 + inefficient_to_string = "warn" 29 + or_fun_call = "warn" 30 + unnecessary_to_owned = "warn" 31 + map_clone = "warn" 32 + flat_map_identity = "warn" 33 + needless_collect = "warn" 34 + vec_init_then_push = "warn" 35 + 36 + len_zero = "allow" 37 + range_plus_one = "allow" 38 + manual_range_contains = "allow"
+11 -2
cli/Cargo.toml
··· 1 1 [package] 2 - name = "pai-cli" 2 + name = "pai" 3 3 version = "0.1.0" 4 - edition = "2024" 4 + edition = "2021" 5 + 6 + [[bin]] 7 + name = "pai" 8 + path = "src/main.rs" 5 9 6 10 [dependencies] 11 + pai-core = { path = "../core" } 12 + clap = { version = "4.5", features = ["derive"] } 13 + rusqlite = { version = "0.37", features = ["bundled"] } 14 + chrono = "0.4" 15 + dirs = "6.0"
+219 -1
cli/src/main.rs
··· 1 + mod paths; 2 + mod storage; 3 + 4 + use clap::{Parser, Subcommand}; 5 + use pai_core::{Config, ListFilter, PaiError, SourceKind}; 6 + use std::path::PathBuf; 7 + use storage::SqliteStorage; 8 + 9 + /// Personal Activity Index - POSIX-style CLI for content aggregation 10 + #[derive(Parser, Debug)] 11 + #[command(name = "pai")] 12 + #[command(version, about, long_about = None)] 13 + struct Cli { 14 + /// Set configuration directory 15 + #[arg(short = 'C', value_name = "DIR", global = true)] 16 + config_dir: Option<PathBuf>, 17 + 18 + /// Path to SQLite database file 19 + #[arg(short = 'd', value_name = "PATH", global = true)] 20 + db_path: Option<PathBuf>, 21 + 22 + #[command(subcommand)] 23 + command: Commands, 24 + } 25 + 26 + #[derive(Subcommand, Debug)] 27 + enum Commands { 28 + /// Fetch and store content from configured sources 29 + Sync { 30 + /// Sync all configured sources (default) 31 + #[arg(short = 'a')] 32 + all: bool, 33 + 34 + /// Sync only a particular source kind 35 + #[arg(short = 'k', value_name = "KIND")] 36 + kind: Option<SourceKind>, 37 + 38 + /// Sync only a specific source instance 39 + #[arg(short = 'S', value_name = "ID")] 40 + source_id: Option<String>, 41 + }, 42 + 43 + /// Inspect stored items 44 + List { 45 + /// Filter by source kind 46 + #[arg(short = 'k', value_name = "KIND")] 47 + kind: Option<SourceKind>, 48 + 49 + /// Filter by specific source ID 50 + #[arg(short = 'S', value_name = "ID")] 51 + source_id: Option<String>, 52 + 53 + /// Maximum number of items to display 54 + #[arg(short = 'n', value_name = "NUMBER", default_value = "20")] 55 + limit: usize, 56 + 57 + /// Only show items published at or after this time 58 + #[arg(short = 's', value_name = "TIME")] 59 + since: Option<String>, 60 + 61 + /// Filter items by substring in title/summary 62 + #[arg(short = 'q', value_name = "PATTERN")] 63 + query: Option<String>, 64 + }, 65 + 66 + /// Produce feeds or export files 67 + Export { 68 + /// Filter by source kind 69 + #[arg(short = 'k', value_name = "KIND")] 70 + kind: Option<SourceKind>, 71 + 72 + /// Filter by specific source ID 73 + #[arg(short = 'S', value_name = "ID")] 74 + source_id: Option<String>, 75 + 76 + /// Maximum number of items 77 + #[arg(short = 'n', value_name = "NUMBER")] 78 + limit: Option<usize>, 79 + 80 + /// Only items published at or after this time 81 + #[arg(short = 's', value_name = "TIME")] 82 + since: Option<String>, 83 + 84 + /// Filter items by substring 85 + #[arg(short = 'q', value_name = "PATTERN")] 86 + query: Option<String>, 87 + 88 + /// Output format 89 + #[arg(short = 'f', value_name = "FORMAT", default_value = "json")] 90 + format: String, 91 + 92 + /// Output file (default: stdout) 93 + #[arg(short = 'o', value_name = "FILE")] 94 + output: Option<PathBuf>, 95 + }, 96 + 97 + /// Self-host HTTP API 98 + Serve { 99 + /// Address to bind HTTP server to 100 + #[arg(short = 'a', value_name = "ADDRESS", default_value = "127.0.0.1:8080")] 101 + address: String, 102 + }, 103 + 104 + /// Verify database schema and print statistics 105 + DbCheck, 106 + } 107 + 1 108 fn main() { 2 - println!("Hello, world!"); 109 + let cli = Cli::parse(); 110 + 111 + let result = match cli.command { 112 + Commands::Sync { all, kind, source_id } => handle_sync(cli.config_dir, cli.db_path, all, kind, source_id), 113 + Commands::List { kind, source_id, limit, since, query } => { 114 + handle_list(cli.db_path, kind, source_id, limit, since, query) 115 + } 116 + Commands::Export { kind, source_id, limit, since, query, format, output } => { 117 + handle_export(cli.db_path, kind, source_id, limit, since, query, format, output) 118 + } 119 + Commands::Serve { address } => handle_serve(cli.db_path, address), 120 + Commands::DbCheck => handle_db_check(cli.db_path), 121 + }; 122 + 123 + if let Err(e) = result { 124 + eprintln!("Error: {e}"); 125 + std::process::exit(1); 126 + } 127 + } 128 + 129 + fn handle_sync( 130 + config_dir: Option<PathBuf>, db_path: Option<PathBuf>, _all: bool, _kind: Option<SourceKind>, 131 + _source_id: Option<String>, 132 + ) -> Result<(), PaiError> { 133 + let db_path = paths::resolve_db_path(db_path)?; 134 + let _config_dir = paths::resolve_config_dir(config_dir)?; 135 + 136 + let storage = SqliteStorage::new(db_path)?; 137 + let config = Config::default(); 138 + 139 + let count = pai_core::sync_all_sources(&config, &storage)?; 140 + 141 + println!("Synced {count} items"); 142 + Ok(()) 143 + } 144 + 145 + fn handle_list( 146 + db_path: Option<PathBuf>, kind: Option<SourceKind>, source_id: Option<String>, limit: usize, since: Option<String>, 147 + query: Option<String>, 148 + ) -> Result<(), PaiError> { 149 + let db_path = paths::resolve_db_path(db_path)?; 150 + let storage = SqliteStorage::new(db_path)?; 151 + 152 + let filter = ListFilter { source_kind: kind, source_id, limit: Some(limit), since, query }; 153 + 154 + let items = pai_core::Storage::list_items(&storage, &filter)?; 155 + 156 + if items.is_empty() { 157 + println!("No items found"); 158 + return Ok(()); 159 + } 160 + 161 + println!("Found {} items:\n", items.len()); 162 + for item in items { 163 + println!("ID: {}", item.id); 164 + println!("Source: {} ({})", item.source_kind, item.source_id); 165 + if let Some(title) = &item.title { 166 + println!("Title: {title}"); 167 + } 168 + if let Some(author) = &item.author { 169 + println!("Author: {author}"); 170 + } 171 + println!("URL: {}", item.url); 172 + println!("Published: {}", item.published_at); 173 + println!(); 174 + } 175 + 176 + Ok(()) 177 + } 178 + 179 + fn handle_export( 180 + db_path: Option<PathBuf>, kind: Option<SourceKind>, source_id: Option<String>, limit: Option<usize>, 181 + since: Option<String>, query: Option<String>, format: String, output: Option<PathBuf>, 182 + ) -> Result<(), PaiError> { 183 + let db_path = paths::resolve_db_path(db_path)?; 184 + let _storage = SqliteStorage::new(db_path)?; 185 + 186 + let filter = ListFilter { source_kind: kind, source_id, limit, since, query }; 187 + 188 + println!("export command - format: {format}, output: {output:?}, filter: {filter:?}"); 189 + Ok(()) 190 + } 191 + 192 + fn handle_serve(db_path: Option<PathBuf>, address: String) -> Result<(), PaiError> { 193 + let db_path = paths::resolve_db_path(db_path)?; 194 + let _storage = SqliteStorage::new(db_path)?; 195 + 196 + println!("serve command - address: {address}"); 197 + Ok(()) 198 + } 199 + 200 + fn handle_db_check(db_path: Option<PathBuf>) -> Result<(), PaiError> { 201 + let db_path = paths::resolve_db_path(db_path)?; 202 + let storage = SqliteStorage::new(db_path)?; 203 + 204 + println!("Verifying database schema..."); 205 + storage.verify_schema()?; 206 + println!("Schema verification: OK\n"); 207 + 208 + println!("Database statistics:"); 209 + let total = storage.count_items()?; 210 + println!(" Total items: {total}"); 211 + 212 + let stats = storage.get_stats()?; 213 + if !stats.is_empty() { 214 + println!("\nItems by source:"); 215 + for (source_kind, count) in stats { 216 + println!(" {source_kind}: {count}"); 217 + } 218 + } 219 + 220 + Ok(()) 3 221 }
+80
cli/src/paths.rs
··· 1 + use pai_core::{PaiError, Result}; 2 + use std::path::PathBuf; 3 + 4 + /// Resolves the database file path, with XDG fallback 5 + /// 6 + /// Priority order: 7 + /// 1. Explicit path provided via `-d` flag 8 + /// 2. $XDG_DATA_HOME/pai/pai.db 9 + /// 3. $HOME/.local/share/pai/pai.db 10 + pub fn resolve_db_path(explicit_path: Option<PathBuf>) -> Result<PathBuf> { 11 + if let Some(path) = explicit_path { 12 + return Ok(path); 13 + } 14 + 15 + if let Some(data_home) = dirs::data_dir() { 16 + return Ok(data_home.join("pai").join("pai.db")); 17 + } 18 + 19 + Err(PaiError::Config( 20 + "Unable to determine database path: no XDG_DATA_HOME or HOME set".to_string(), 21 + )) 22 + } 23 + 24 + /// Resolves the config directory path, with XDG fallback 25 + /// 26 + /// Priority order: 27 + /// 1. Explicit directory provided via `-C` flag 28 + /// 2. $XDG_CONFIG_HOME/pai 29 + /// 3. $HOME/.config/pai 30 + pub fn resolve_config_dir(explicit_dir: Option<PathBuf>) -> Result<PathBuf> { 31 + if let Some(dir) = explicit_dir { 32 + return Ok(dir); 33 + } 34 + 35 + if let Some(config_home) = dirs::config_dir() { 36 + return Ok(config_home.join("pai")); 37 + } 38 + 39 + Err(PaiError::Config( 40 + "Unable to determine config directory: no XDG_CONFIG_HOME or HOME set".to_string(), 41 + )) 42 + } 43 + 44 + #[cfg(test)] 45 + mod tests { 46 + use super::*; 47 + use std::path::Path; 48 + 49 + #[test] 50 + fn resolve_db_path_with_explicit() { 51 + let explicit = Some(PathBuf::from("/custom/path/db.sqlite")); 52 + let result = resolve_db_path(explicit).unwrap(); 53 + assert_eq!(result, Path::new("/custom/path/db.sqlite")); 54 + } 55 + 56 + #[test] 57 + fn resolve_db_path_falls_back() { 58 + let result = resolve_db_path(None); 59 + assert!(result.is_ok()); 60 + 61 + let path = result.unwrap(); 62 + assert!(path.ends_with("pai/pai.db")); 63 + } 64 + 65 + #[test] 66 + fn resolve_config_dir_with_explicit() { 67 + let explicit = Some(PathBuf::from("/custom/config")); 68 + let result = resolve_config_dir(explicit).unwrap(); 69 + assert_eq!(result, Path::new("/custom/config")); 70 + } 71 + 72 + #[test] 73 + fn resolve_config_dir_falls_back() { 74 + let result = resolve_config_dir(None); 75 + assert!(result.is_ok()); 76 + 77 + let path = result.unwrap(); 78 + assert!(path.ends_with("pai")); 79 + } 80 + }
+3
cli/src/storage/mod.rs
··· 1 + mod sqlite; 2 + 3 + pub use sqlite::SqliteStorage;
+407
cli/src/storage/sqlite.rs
··· 1 + use pai_core::{Item, ListFilter, PaiError, Result, SourceKind, Storage}; 2 + use rusqlite::{params, Connection, OptionalExtension}; 3 + use std::path::Path; 4 + 5 + const SCHEMA_VERSION: i32 = 1; 6 + 7 + const INIT_SQL: &str = r#" 8 + CREATE TABLE IF NOT EXISTS schema_version ( 9 + version INTEGER PRIMARY KEY 10 + ); 11 + 12 + CREATE TABLE IF NOT EXISTS items ( 13 + id TEXT PRIMARY KEY, 14 + source_kind TEXT NOT NULL, 15 + source_id TEXT NOT NULL, 16 + author TEXT, 17 + title TEXT, 18 + summary TEXT, 19 + url TEXT NOT NULL, 20 + content_html TEXT, 21 + published_at TEXT NOT NULL, 22 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP 23 + ); 24 + 25 + CREATE INDEX IF NOT EXISTS idx_items_source_date 26 + ON items (source_kind, source_id, published_at DESC); 27 + "#; 28 + 29 + /// SQLite implementation of the Storage trait 30 + /// 31 + /// Manages persistent storage of items in a local SQLite database. 32 + /// Handles schema initialization and migrations automatically on first connection. 33 + pub struct SqliteStorage { 34 + conn: Connection, 35 + } 36 + 37 + impl SqliteStorage { 38 + /// Opens or creates a SQLite database at the given path 39 + /// 40 + /// Initializes the schema if the database is new or runs migrations if needed. 41 + pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> { 42 + let path_ref = path.as_ref(); 43 + 44 + if let Some(parent) = path_ref.parent() { 45 + std::fs::create_dir_all(parent) 46 + .map_err(|e| PaiError::Storage(format!("Failed to create database directory: {e}")))?; 47 + } 48 + 49 + let conn = Connection::open(path).map_err(|e| PaiError::Storage(format!("Failed to open database: {e}")))?; 50 + 51 + let mut storage = Self { conn }; 52 + storage.init_schema()?; 53 + Ok(storage) 54 + } 55 + 56 + /// Initializes the database schema 57 + /// 58 + /// Creates tables and indexes if they don't exist, and sets up version tracking. 59 + fn init_schema(&mut self) -> Result<()> { 60 + self.conn 61 + .execute_batch(INIT_SQL) 62 + .map_err(|e| PaiError::Storage(format!("Failed to initialize schema: {e}")))?; 63 + 64 + let version: Option<i32> = self 65 + .conn 66 + .query_row("SELECT version FROM schema_version LIMIT 1", [], |row| row.get(0)) 67 + .optional() 68 + .map_err(|e| PaiError::Storage(format!("Failed to check schema version: {e}")))?; 69 + 70 + match version { 71 + None => { 72 + self.conn 73 + .execute( 74 + "INSERT INTO schema_version (version) VALUES (?1)", 75 + params![SCHEMA_VERSION], 76 + ) 77 + .map_err(|e| PaiError::Storage(format!("Failed to set schema version: {e}")))?; 78 + } 79 + Some(v) if v < SCHEMA_VERSION => { 80 + return Err(PaiError::Storage(format!( 81 + "Database migration needed: current={v}, required={SCHEMA_VERSION}" 82 + ))); 83 + } 84 + _ => {} 85 + } 86 + 87 + Ok(()) 88 + } 89 + 90 + /// Gets basic statistics about stored items 91 + pub fn get_stats(&self) -> Result<Vec<(String, usize)>> { 92 + let mut stmt = self 93 + .conn 94 + .prepare("SELECT source_kind, COUNT(*) FROM items GROUP BY source_kind ORDER BY source_kind") 95 + .map_err(|e| PaiError::Storage(format!("Failed to prepare stats query: {e}")))?; 96 + 97 + let stats = stmt 98 + .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))) 99 + .map_err(|e| PaiError::Storage(format!("Failed to query stats: {e}")))? 100 + .collect::<std::result::Result<Vec<_>, _>>() 101 + .map_err(|e| PaiError::Storage(format!("Failed to collect stats: {e}")))?; 102 + 103 + Ok(stats) 104 + } 105 + 106 + /// Gets total item count 107 + pub fn count_items(&self) -> Result<usize> { 108 + self.conn 109 + .query_row("SELECT COUNT(*) FROM items", [], |row| row.get(0)) 110 + .map_err(|e| PaiError::Storage(format!("Failed to count items: {e}"))) 111 + } 112 + 113 + /// Verifies schema integrity 114 + /// 115 + /// Checks that required tables and indexes exist. 116 + pub fn verify_schema(&self) -> Result<()> { 117 + let tables = vec!["schema_version", "items"]; 118 + for table in tables { 119 + let exists: bool = self 120 + .conn 121 + .query_row( 122 + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", 123 + params![table], 124 + |row| { 125 + let count: i32 = row.get(0)?; 126 + Ok(count > 0) 127 + }, 128 + ) 129 + .map_err(|e| PaiError::Storage(format!("Failed to verify table {table}: {e}")))?; 130 + 131 + if !exists { 132 + return Err(PaiError::Storage(format!("Missing table: {table}"))); 133 + } 134 + } 135 + 136 + Ok(()) 137 + } 138 + } 139 + 140 + impl Storage for SqliteStorage { 141 + fn insert_or_replace_item(&self, item: &Item) -> Result<()> { 142 + self.conn 143 + .execute( 144 + "INSERT OR REPLACE INTO items 145 + (id, source_kind, source_id, author, title, summary, url, content_html, published_at, created_at) 146 + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", 147 + params![ 148 + item.id, 149 + item.source_kind.to_string(), 150 + item.source_id, 151 + item.author, 152 + item.title, 153 + item.summary, 154 + item.url, 155 + item.content_html, 156 + item.published_at, 157 + item.created_at, 158 + ], 159 + ) 160 + .map_err(|e| PaiError::Storage(format!("Failed to insert item: {e}")))?; 161 + 162 + Ok(()) 163 + } 164 + 165 + fn list_items(&self, filter: &ListFilter) -> Result<Vec<Item>> { 166 + let mut sql = String::from("SELECT id, source_kind, source_id, author, title, summary, url, content_html, published_at, created_at FROM items WHERE 1=1"); 167 + let mut conditions = Vec::new(); 168 + 169 + if filter.source_kind.is_some() { 170 + sql.push_str(" AND source_kind = ?"); 171 + conditions.push(filter.source_kind.unwrap().to_string()); 172 + } 173 + 174 + if let Some(ref source_id) = filter.source_id { 175 + sql.push_str(" AND source_id = ?"); 176 + conditions.push(source_id.clone()); 177 + } 178 + 179 + if let Some(ref since) = filter.since { 180 + sql.push_str(" AND published_at >= ?"); 181 + conditions.push(since.clone()); 182 + } 183 + 184 + if let Some(ref query) = filter.query { 185 + sql.push_str(" AND (title LIKE ? OR summary LIKE ?)"); 186 + let pattern = format!("%{query}%"); 187 + conditions.push(pattern.clone()); 188 + conditions.push(pattern); 189 + } 190 + 191 + sql.push_str(" ORDER BY published_at DESC"); 192 + 193 + if let Some(limit) = filter.limit { 194 + sql.push_str(&format!(" LIMIT {limit}")); 195 + } 196 + 197 + let mut stmt = self 198 + .conn 199 + .prepare(&sql) 200 + .map_err(|e| PaiError::Storage(format!("Failed to prepare query: {e}")))?; 201 + 202 + let params_refs: Vec<&dyn rusqlite::ToSql> = conditions.iter().map(|s| s as &dyn rusqlite::ToSql).collect(); 203 + 204 + let items = stmt 205 + .query_map(params_refs.as_slice(), |row| { 206 + let source_kind_str: String = row.get(1)?; 207 + let source_kind = source_kind_str.parse::<SourceKind>().map_err(|e| { 208 + rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e)) 209 + })?; 210 + 211 + Ok(Item { 212 + id: row.get(0)?, 213 + source_kind, 214 + source_id: row.get(2)?, 215 + author: row.get(3)?, 216 + title: row.get(4)?, 217 + summary: row.get(5)?, 218 + url: row.get(6)?, 219 + content_html: row.get(7)?, 220 + published_at: row.get(8)?, 221 + created_at: row.get(9)?, 222 + }) 223 + }) 224 + .map_err(|e| PaiError::Storage(format!("Failed to query items: {e}")))? 225 + .collect::<std::result::Result<Vec<_>, _>>() 226 + .map_err(|e| PaiError::Storage(format!("Failed to collect items: {e}")))?; 227 + 228 + Ok(items) 229 + } 230 + } 231 + 232 + #[cfg(test)] 233 + mod tests { 234 + use super::*; 235 + use chrono::Utc; 236 + 237 + fn create_test_storage() -> SqliteStorage { 238 + SqliteStorage::new(":memory:").expect("Failed to create in-memory database") 239 + } 240 + 241 + fn create_test_item(id: &str, source_kind: SourceKind, source_id: &str) -> Item { 242 + Item { 243 + id: id.to_string(), 244 + source_kind, 245 + source_id: source_id.to_string(), 246 + author: Some("Test Author".to_string()), 247 + title: Some("Test Title".to_string()), 248 + summary: Some("Test summary".to_string()), 249 + url: format!("https://example.com/{id}"), 250 + content_html: Some("<p>Test content</p>".to_string()), 251 + published_at: Utc::now().to_rfc3339(), 252 + created_at: Utc::now().to_rfc3339(), 253 + } 254 + } 255 + 256 + #[test] 257 + fn new_database_initializes_schema() { 258 + let storage = create_test_storage(); 259 + assert!(storage.verify_schema().is_ok()); 260 + } 261 + 262 + #[test] 263 + fn insert_and_retrieve_item() { 264 + let storage = create_test_storage(); 265 + let item = create_test_item("test-1", SourceKind::Substack, "test.substack.com"); 266 + 267 + storage.insert_or_replace_item(&item).expect("Failed to insert item"); 268 + 269 + let filter = ListFilter::default(); 270 + let items = storage.list_items(&filter).expect("Failed to list items"); 271 + 272 + assert_eq!(items.len(), 1); 273 + assert_eq!(items[0].id, "test-1"); 274 + assert_eq!(items[0].source_kind, SourceKind::Substack); 275 + } 276 + 277 + #[test] 278 + fn insert_replaces_existing_item() { 279 + let storage = create_test_storage(); 280 + let mut item = create_test_item("test-1", SourceKind::Substack, "test.substack.com"); 281 + 282 + storage.insert_or_replace_item(&item).expect("Failed to insert item"); 283 + 284 + item.title = Some("Updated Title".to_string()); 285 + storage.insert_or_replace_item(&item).expect("Failed to replace item"); 286 + 287 + let filter = ListFilter::default(); 288 + let items = storage.list_items(&filter).expect("Failed to list items"); 289 + 290 + assert_eq!(items.len(), 1); 291 + assert_eq!(items[0].title, Some("Updated Title".to_string())); 292 + } 293 + 294 + #[test] 295 + fn filter_by_source_kind() { 296 + let storage = create_test_storage(); 297 + 298 + storage 299 + .insert_or_replace_item(&create_test_item("test-1", SourceKind::Substack, "test.substack.com")) 300 + .expect("Failed to insert"); 301 + storage 302 + .insert_or_replace_item(&create_test_item("test-2", SourceKind::Bluesky, "test.bsky.social")) 303 + .expect("Failed to insert"); 304 + 305 + let filter = ListFilter { source_kind: Some(SourceKind::Substack), ..Default::default() }; 306 + let items = storage.list_items(&filter).expect("Failed to list items"); 307 + 308 + assert_eq!(items.len(), 1); 309 + assert_eq!(items[0].source_kind, SourceKind::Substack); 310 + } 311 + 312 + #[test] 313 + fn filter_by_source_id() { 314 + let storage = create_test_storage(); 315 + 316 + storage 317 + .insert_or_replace_item(&create_test_item("test-1", SourceKind::Leaflet, "source1.leaflet.pub")) 318 + .expect("Failed to insert"); 319 + storage 320 + .insert_or_replace_item(&create_test_item("test-2", SourceKind::Leaflet, "source2.leaflet.pub")) 321 + .expect("Failed to insert"); 322 + 323 + let filter = ListFilter { source_id: Some("source1.leaflet.pub".to_string()), ..Default::default() }; 324 + let items = storage.list_items(&filter).expect("Failed to list items"); 325 + 326 + assert_eq!(items.len(), 1); 327 + assert_eq!(items[0].source_id, "source1.leaflet.pub"); 328 + } 329 + 330 + #[test] 331 + fn filter_with_limit() { 332 + let storage = create_test_storage(); 333 + 334 + for i in 0..5 { 335 + storage 336 + .insert_or_replace_item(&create_test_item( 337 + &format!("test-{i}"), 338 + SourceKind::Substack, 339 + "test.substack.com", 340 + )) 341 + .expect("Failed to insert"); 342 + } 343 + 344 + let filter = ListFilter { limit: Some(3), ..Default::default() }; 345 + let items = storage.list_items(&filter).expect("Failed to list items"); 346 + 347 + assert_eq!(items.len(), 3); 348 + } 349 + 350 + #[test] 351 + fn filter_by_query() { 352 + let storage = create_test_storage(); 353 + 354 + let mut item1 = create_test_item("test-1", SourceKind::Substack, "test.substack.com"); 355 + item1.title = Some("Rust Programming".to_string()); 356 + storage.insert_or_replace_item(&item1).expect("Failed to insert"); 357 + 358 + let mut item2 = create_test_item("test-2", SourceKind::Substack, "test.substack.com"); 359 + item2.title = Some("Python Tutorial".to_string()); 360 + storage.insert_or_replace_item(&item2).expect("Failed to insert"); 361 + 362 + let filter = ListFilter { query: Some("Rust".to_string()), ..Default::default() }; 363 + let items = storage.list_items(&filter).expect("Failed to list items"); 364 + 365 + assert_eq!(items.len(), 1); 366 + assert_eq!(items[0].id, "test-1"); 367 + } 368 + 369 + #[test] 370 + fn get_stats_returns_counts_by_source() { 371 + let storage = create_test_storage(); 372 + 373 + storage 374 + .insert_or_replace_item(&create_test_item("test-1", SourceKind::Substack, "test.substack.com")) 375 + .expect("Failed to insert"); 376 + storage 377 + .insert_or_replace_item(&create_test_item("test-2", SourceKind::Substack, "test.substack.com")) 378 + .expect("Failed to insert"); 379 + storage 380 + .insert_or_replace_item(&create_test_item("test-3", SourceKind::Bluesky, "test.bsky.social")) 381 + .expect("Failed to insert"); 382 + 383 + let stats = storage.get_stats().expect("Failed to get stats"); 384 + 385 + assert_eq!(stats.len(), 2); 386 + assert!(stats.iter().any(|(k, v)| k == "bluesky" && *v == 1)); 387 + assert!(stats.iter().any(|(k, v)| k == "substack" && *v == 2)); 388 + } 389 + 390 + #[test] 391 + fn count_items_returns_total() { 392 + let storage = create_test_storage(); 393 + 394 + for i in 0..3 { 395 + storage 396 + .insert_or_replace_item(&create_test_item( 397 + &format!("test-{i}"), 398 + SourceKind::Substack, 399 + "test.substack.com", 400 + )) 401 + .expect("Failed to insert"); 402 + } 403 + 404 + let count = storage.count_items().expect("Failed to count items"); 405 + assert_eq!(count, 3); 406 + } 407 + }
+2 -1
core/Cargo.toml
··· 1 1 [package] 2 2 name = "pai-core" 3 3 version = "0.1.0" 4 - edition = "2024" 4 + edition = "2021" 5 5 6 6 [dependencies] 7 + thiserror = "2.0.17"
+151 -5
core/src/lib.rs
··· 1 - pub fn add(left: u64, right: u64) -> u64 { 2 - left + right 1 + use std::fmt; 2 + use thiserror::Error; 3 + 4 + /// Errors that can occur in the Personal Activity Index 5 + #[derive(Error, Debug)] 6 + pub enum PaiError { 7 + #[error("Unknown source kind: {0}")] 8 + UnknownSourceKind(String), 9 + 10 + #[error("Storage error: {0}")] 11 + Storage(String), 12 + 13 + #[error("Fetch error: {0}")] 14 + Fetch(String), 15 + 16 + #[error("Parse error: {0}")] 17 + Parse(String), 18 + 19 + #[error("Configuration error: {0}")] 20 + Config(String), 21 + 22 + #[error("IO error: {0}")] 23 + Io(#[from] std::io::Error), 24 + } 25 + 26 + pub type Result<T> = std::result::Result<T, PaiError>; 27 + 28 + /// Represents the different source types supported by the indexer 29 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 30 + pub enum SourceKind { 31 + Substack, 32 + Bluesky, 33 + Leaflet, 34 + } 35 + 36 + impl fmt::Display for SourceKind { 37 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 38 + match self { 39 + SourceKind::Substack => write!(f, "substack"), 40 + SourceKind::Bluesky => write!(f, "bluesky"), 41 + SourceKind::Leaflet => write!(f, "leaflet"), 42 + } 43 + } 44 + } 45 + 46 + impl std::str::FromStr for SourceKind { 47 + type Err = PaiError; 48 + 49 + fn from_str(s: &str) -> Result<Self> { 50 + match s.to_lowercase().as_str() { 51 + "substack" => Ok(SourceKind::Substack), 52 + "bluesky" => Ok(SourceKind::Bluesky), 53 + "leaflet" => Ok(SourceKind::Leaflet), 54 + _ => Err(PaiError::UnknownSourceKind(s.to_string())), 55 + } 56 + } 57 + } 58 + 59 + /// Represents a single content item from any source 60 + #[derive(Debug, Clone)] 61 + pub struct Item { 62 + /// Unique identifier for the item 63 + pub id: String, 64 + /// The source type this item came from 65 + pub source_kind: SourceKind, 66 + /// The specific source instance identifier (e.g., domain or handle) 67 + pub source_id: String, 68 + /// Author of the content 69 + pub author: Option<String>, 70 + /// Title of the content 71 + pub title: Option<String>, 72 + /// Summary or excerpt of the content 73 + pub summary: Option<String>, 74 + /// Canonical URL for the content 75 + pub url: String, 76 + /// Full HTML content 77 + pub content_html: Option<String>, 78 + /// When the content was published (ISO 8601) 79 + pub published_at: String, 80 + /// When this item was created in our database (ISO 8601) 81 + pub created_at: String, 82 + } 83 + 84 + /// Filter criteria for listing items 85 + #[derive(Debug, Default, Clone)] 86 + pub struct ListFilter { 87 + /// Filter by source kind 88 + pub source_kind: Option<SourceKind>, 89 + /// Filter by specific source ID 90 + pub source_id: Option<String>, 91 + /// Maximum number of items to return 92 + pub limit: Option<usize>, 93 + /// Only items published at or after this time (ISO 8601) 94 + pub since: Option<String>, 95 + /// Substring search on title/summary 96 + pub query: Option<String>, 97 + } 98 + 99 + /// Storage trait for persisting and retrieving items 100 + pub trait Storage { 101 + /// Insert or replace an item in storage 102 + fn insert_or_replace_item(&self, item: &Item) -> Result<()>; 103 + 104 + /// List items matching the given filter 105 + fn list_items(&self, filter: &ListFilter) -> Result<Vec<Item>>; 106 + } 107 + 108 + /// Trait for fetching content from a specific source 109 + pub trait SourceFetcher { 110 + /// Synchronize content from this source into storage 111 + fn sync(&self, storage: &dyn Storage) -> Result<()>; 112 + } 113 + 114 + /// Configuration for all sources 115 + #[derive(Debug, Default)] 116 + pub struct Config {} 117 + 118 + /// Synchronize all enabled sources 119 + /// 120 + /// Calls each configured source fetcher to retrieve and store content. 121 + pub fn sync_all_sources(_config: &Config, _storage: &dyn Storage) -> Result<usize> { 122 + Ok(0) 3 123 } 4 124 5 125 #[cfg(test)] ··· 7 127 use super::*; 8 128 9 129 #[test] 10 - fn it_works() { 11 - let result = add(2, 2); 12 - assert_eq!(result, 4); 130 + fn source_kind_display() { 131 + assert_eq!(SourceKind::Substack.to_string(), "substack"); 132 + assert_eq!(SourceKind::Bluesky.to_string(), "bluesky"); 133 + assert_eq!(SourceKind::Leaflet.to_string(), "leaflet"); 134 + } 135 + 136 + #[test] 137 + fn source_kind_parse() { 138 + assert_eq!("substack".parse::<SourceKind>().unwrap(), SourceKind::Substack); 139 + assert_eq!("BLUESKY".parse::<SourceKind>().unwrap(), SourceKind::Bluesky); 140 + assert_eq!("Leaflet".parse::<SourceKind>().unwrap(), SourceKind::Leaflet); 141 + assert!("invalid".parse::<SourceKind>().is_err()); 142 + } 143 + 144 + #[test] 145 + fn error_unknown_source_kind() { 146 + let err = "unknown".parse::<SourceKind>().unwrap_err(); 147 + assert!(matches!(err, PaiError::UnknownSourceKind(_))); 148 + assert_eq!(err.to_string(), "Unknown source kind: unknown"); 149 + } 150 + 151 + #[test] 152 + fn list_filter_default() { 153 + let filter = ListFilter::default(); 154 + assert!(filter.source_kind.is_none()); 155 + assert!(filter.source_id.is_none()); 156 + assert!(filter.limit.is_none()); 157 + assert!(filter.since.is_none()); 158 + assert!(filter.query.is_none()); 13 159 } 14 160 }
+10
rustfmt.toml
··· 1 + max_width = 120 2 + fn_params_layout = "Compressed" 3 + fn_single_line = true 4 + fn_args_layout = "Compressed" 5 + format_strings = true 6 + single_line_if_else_max_width = 100 7 + single_line_let_else_max_width = 100 8 + struct_field_align_threshold = 20 9 + use_field_init_shorthand = true 10 + struct_lit_width=100