rust reimplentation of sequoia.pub
0
fork

Configure Feed

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

working e2e tests

notplants 009fae80

+5701
+4
.gitignore
··· 1 + target 2 + testuser.toml 3 + __pycache__ 4 + .venv
+2224
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 = "aho-corasick" 7 + version = "1.1.4" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" 10 + dependencies = [ 11 + "memchr", 12 + ] 13 + 14 + [[package]] 15 + name = "android_system_properties" 16 + version = "0.1.5" 17 + source = "registry+https://github.com/rust-lang/crates.io-index" 18 + checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 19 + dependencies = [ 20 + "libc", 21 + ] 22 + 23 + [[package]] 24 + name = "anstream" 25 + version = "1.0.0" 26 + source = "registry+https://github.com/rust-lang/crates.io-index" 27 + checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" 28 + dependencies = [ 29 + "anstyle", 30 + "anstyle-parse", 31 + "anstyle-query", 32 + "anstyle-wincon", 33 + "colorchoice", 34 + "is_terminal_polyfill", 35 + "utf8parse", 36 + ] 37 + 38 + [[package]] 39 + name = "anstyle" 40 + version = "1.0.14" 41 + source = "registry+https://github.com/rust-lang/crates.io-index" 42 + checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" 43 + 44 + [[package]] 45 + name = "anstyle-parse" 46 + version = "1.0.0" 47 + source = "registry+https://github.com/rust-lang/crates.io-index" 48 + checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" 49 + dependencies = [ 50 + "utf8parse", 51 + ] 52 + 53 + [[package]] 54 + name = "anstyle-query" 55 + version = "1.1.5" 56 + source = "registry+https://github.com/rust-lang/crates.io-index" 57 + checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" 58 + dependencies = [ 59 + "windows-sys 0.61.2", 60 + ] 61 + 62 + [[package]] 63 + name = "anstyle-wincon" 64 + version = "3.0.11" 65 + source = "registry+https://github.com/rust-lang/crates.io-index" 66 + checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" 67 + dependencies = [ 68 + "anstyle", 69 + "once_cell_polyfill", 70 + "windows-sys 0.61.2", 71 + ] 72 + 73 + [[package]] 74 + name = "anyhow" 75 + version = "1.0.102" 76 + source = "registry+https://github.com/rust-lang/crates.io-index" 77 + checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 78 + 79 + [[package]] 80 + name = "atomic-waker" 81 + version = "1.1.2" 82 + source = "registry+https://github.com/rust-lang/crates.io-index" 83 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 84 + 85 + [[package]] 86 + name = "autocfg" 87 + version = "1.5.0" 88 + source = "registry+https://github.com/rust-lang/crates.io-index" 89 + checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 90 + 91 + [[package]] 92 + name = "base64" 93 + version = "0.22.1" 94 + source = "registry+https://github.com/rust-lang/crates.io-index" 95 + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 96 + 97 + [[package]] 98 + name = "bitflags" 99 + version = "2.11.1" 100 + source = "registry+https://github.com/rust-lang/crates.io-index" 101 + checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" 102 + 103 + [[package]] 104 + name = "block-buffer" 105 + version = "0.10.4" 106 + source = "registry+https://github.com/rust-lang/crates.io-index" 107 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 108 + dependencies = [ 109 + "generic-array", 110 + ] 111 + 112 + [[package]] 113 + name = "bstr" 114 + version = "1.12.1" 115 + source = "registry+https://github.com/rust-lang/crates.io-index" 116 + checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" 117 + dependencies = [ 118 + "memchr", 119 + "serde", 120 + ] 121 + 122 + [[package]] 123 + name = "bumpalo" 124 + version = "3.20.2" 125 + source = "registry+https://github.com/rust-lang/crates.io-index" 126 + checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" 127 + 128 + [[package]] 129 + name = "bytes" 130 + version = "1.11.1" 131 + source = "registry+https://github.com/rust-lang/crates.io-index" 132 + checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" 133 + 134 + [[package]] 135 + name = "cc" 136 + version = "1.2.61" 137 + source = "registry+https://github.com/rust-lang/crates.io-index" 138 + checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" 139 + dependencies = [ 140 + "find-msvc-tools", 141 + "shlex", 142 + ] 143 + 144 + [[package]] 145 + name = "cfg-if" 146 + version = "1.0.4" 147 + source = "registry+https://github.com/rust-lang/crates.io-index" 148 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 149 + 150 + [[package]] 151 + name = "cfg_aliases" 152 + version = "0.2.1" 153 + source = "registry+https://github.com/rust-lang/crates.io-index" 154 + checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 155 + 156 + [[package]] 157 + name = "chrono" 158 + version = "0.4.44" 159 + source = "registry+https://github.com/rust-lang/crates.io-index" 160 + checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" 161 + dependencies = [ 162 + "iana-time-zone", 163 + "js-sys", 164 + "num-traits", 165 + "wasm-bindgen", 166 + "windows-link", 167 + ] 168 + 169 + [[package]] 170 + name = "clap" 171 + version = "4.6.1" 172 + source = "registry+https://github.com/rust-lang/crates.io-index" 173 + checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" 174 + dependencies = [ 175 + "clap_builder", 176 + "clap_derive", 177 + ] 178 + 179 + [[package]] 180 + name = "clap_builder" 181 + version = "4.6.0" 182 + source = "registry+https://github.com/rust-lang/crates.io-index" 183 + checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" 184 + dependencies = [ 185 + "anstream", 186 + "anstyle", 187 + "clap_lex", 188 + "strsim", 189 + ] 190 + 191 + [[package]] 192 + name = "clap_derive" 193 + version = "4.6.1" 194 + source = "registry+https://github.com/rust-lang/crates.io-index" 195 + checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" 196 + dependencies = [ 197 + "heck", 198 + "proc-macro2", 199 + "quote", 200 + "syn", 201 + ] 202 + 203 + [[package]] 204 + name = "clap_lex" 205 + version = "1.1.0" 206 + source = "registry+https://github.com/rust-lang/crates.io-index" 207 + checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" 208 + 209 + [[package]] 210 + name = "colorchoice" 211 + version = "1.0.5" 212 + source = "registry+https://github.com/rust-lang/crates.io-index" 213 + checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" 214 + 215 + [[package]] 216 + name = "core-foundation-sys" 217 + version = "0.8.7" 218 + source = "registry+https://github.com/rust-lang/crates.io-index" 219 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 220 + 221 + [[package]] 222 + name = "cpufeatures" 223 + version = "0.2.17" 224 + source = "registry+https://github.com/rust-lang/crates.io-index" 225 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 226 + dependencies = [ 227 + "libc", 228 + ] 229 + 230 + [[package]] 231 + name = "crypto-common" 232 + version = "0.1.7" 233 + source = "registry+https://github.com/rust-lang/crates.io-index" 234 + checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" 235 + dependencies = [ 236 + "generic-array", 237 + "typenum", 238 + ] 239 + 240 + [[package]] 241 + name = "digest" 242 + version = "0.10.7" 243 + source = "registry+https://github.com/rust-lang/crates.io-index" 244 + checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 245 + dependencies = [ 246 + "block-buffer", 247 + "crypto-common", 248 + ] 249 + 250 + [[package]] 251 + name = "displaydoc" 252 + version = "0.2.5" 253 + source = "registry+https://github.com/rust-lang/crates.io-index" 254 + checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" 255 + dependencies = [ 256 + "proc-macro2", 257 + "quote", 258 + "syn", 259 + ] 260 + 261 + [[package]] 262 + name = "equivalent" 263 + version = "1.0.2" 264 + source = "registry+https://github.com/rust-lang/crates.io-index" 265 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 266 + 267 + [[package]] 268 + name = "errno" 269 + version = "0.3.14" 270 + source = "registry+https://github.com/rust-lang/crates.io-index" 271 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 272 + dependencies = [ 273 + "libc", 274 + "windows-sys 0.61.2", 275 + ] 276 + 277 + [[package]] 278 + name = "fastrand" 279 + version = "2.4.1" 280 + source = "registry+https://github.com/rust-lang/crates.io-index" 281 + checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" 282 + 283 + [[package]] 284 + name = "find-msvc-tools" 285 + version = "0.1.9" 286 + source = "registry+https://github.com/rust-lang/crates.io-index" 287 + checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" 288 + 289 + [[package]] 290 + name = "foldhash" 291 + version = "0.1.5" 292 + source = "registry+https://github.com/rust-lang/crates.io-index" 293 + checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 294 + 295 + [[package]] 296 + name = "form_urlencoded" 297 + version = "1.2.2" 298 + source = "registry+https://github.com/rust-lang/crates.io-index" 299 + checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" 300 + dependencies = [ 301 + "percent-encoding", 302 + ] 303 + 304 + [[package]] 305 + name = "futures-channel" 306 + version = "0.3.32" 307 + source = "registry+https://github.com/rust-lang/crates.io-index" 308 + checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" 309 + dependencies = [ 310 + "futures-core", 311 + ] 312 + 313 + [[package]] 314 + name = "futures-core" 315 + version = "0.3.32" 316 + source = "registry+https://github.com/rust-lang/crates.io-index" 317 + checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" 318 + 319 + [[package]] 320 + name = "futures-task" 321 + version = "0.3.32" 322 + source = "registry+https://github.com/rust-lang/crates.io-index" 323 + checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" 324 + 325 + [[package]] 326 + name = "futures-util" 327 + version = "0.3.32" 328 + source = "registry+https://github.com/rust-lang/crates.io-index" 329 + checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" 330 + dependencies = [ 331 + "futures-core", 332 + "futures-task", 333 + "pin-project-lite", 334 + "slab", 335 + ] 336 + 337 + [[package]] 338 + name = "generic-array" 339 + version = "0.14.7" 340 + source = "registry+https://github.com/rust-lang/crates.io-index" 341 + checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" 342 + dependencies = [ 343 + "typenum", 344 + "version_check", 345 + ] 346 + 347 + [[package]] 348 + name = "getrandom" 349 + version = "0.2.17" 350 + source = "registry+https://github.com/rust-lang/crates.io-index" 351 + checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" 352 + dependencies = [ 353 + "cfg-if", 354 + "js-sys", 355 + "libc", 356 + "wasi", 357 + "wasm-bindgen", 358 + ] 359 + 360 + [[package]] 361 + name = "getrandom" 362 + version = "0.3.4" 363 + source = "registry+https://github.com/rust-lang/crates.io-index" 364 + checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 365 + dependencies = [ 366 + "cfg-if", 367 + "js-sys", 368 + "libc", 369 + "r-efi 5.3.0", 370 + "wasip2", 371 + "wasm-bindgen", 372 + ] 373 + 374 + [[package]] 375 + name = "getrandom" 376 + version = "0.4.2" 377 + source = "registry+https://github.com/rust-lang/crates.io-index" 378 + checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" 379 + dependencies = [ 380 + "cfg-if", 381 + "libc", 382 + "r-efi 6.0.0", 383 + "wasip2", 384 + "wasip3", 385 + ] 386 + 387 + [[package]] 388 + name = "globset" 389 + version = "0.4.18" 390 + source = "registry+https://github.com/rust-lang/crates.io-index" 391 + checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" 392 + dependencies = [ 393 + "aho-corasick", 394 + "bstr", 395 + "log", 396 + "regex-automata", 397 + "regex-syntax", 398 + ] 399 + 400 + [[package]] 401 + name = "hashbrown" 402 + version = "0.15.5" 403 + source = "registry+https://github.com/rust-lang/crates.io-index" 404 + checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 405 + dependencies = [ 406 + "foldhash", 407 + ] 408 + 409 + [[package]] 410 + name = "hashbrown" 411 + version = "0.17.0" 412 + source = "registry+https://github.com/rust-lang/crates.io-index" 413 + checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" 414 + 415 + [[package]] 416 + name = "heck" 417 + version = "0.5.0" 418 + source = "registry+https://github.com/rust-lang/crates.io-index" 419 + checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 420 + 421 + [[package]] 422 + name = "http" 423 + version = "1.4.0" 424 + source = "registry+https://github.com/rust-lang/crates.io-index" 425 + checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" 426 + dependencies = [ 427 + "bytes", 428 + "itoa", 429 + ] 430 + 431 + [[package]] 432 + name = "http-body" 433 + version = "1.0.1" 434 + source = "registry+https://github.com/rust-lang/crates.io-index" 435 + checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" 436 + dependencies = [ 437 + "bytes", 438 + "http", 439 + ] 440 + 441 + [[package]] 442 + name = "http-body-util" 443 + version = "0.1.3" 444 + source = "registry+https://github.com/rust-lang/crates.io-index" 445 + checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" 446 + dependencies = [ 447 + "bytes", 448 + "futures-core", 449 + "http", 450 + "http-body", 451 + "pin-project-lite", 452 + ] 453 + 454 + [[package]] 455 + name = "httparse" 456 + version = "1.10.1" 457 + source = "registry+https://github.com/rust-lang/crates.io-index" 458 + checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 459 + 460 + [[package]] 461 + name = "hyper" 462 + version = "1.9.0" 463 + source = "registry+https://github.com/rust-lang/crates.io-index" 464 + checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" 465 + dependencies = [ 466 + "atomic-waker", 467 + "bytes", 468 + "futures-channel", 469 + "futures-core", 470 + "http", 471 + "http-body", 472 + "httparse", 473 + "itoa", 474 + "pin-project-lite", 475 + "smallvec", 476 + "tokio", 477 + "want", 478 + ] 479 + 480 + [[package]] 481 + name = "hyper-rustls" 482 + version = "0.27.9" 483 + source = "registry+https://github.com/rust-lang/crates.io-index" 484 + checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" 485 + dependencies = [ 486 + "http", 487 + "hyper", 488 + "hyper-util", 489 + "rustls", 490 + "tokio", 491 + "tokio-rustls", 492 + "tower-service", 493 + "webpki-roots", 494 + ] 495 + 496 + [[package]] 497 + name = "hyper-util" 498 + version = "0.1.20" 499 + source = "registry+https://github.com/rust-lang/crates.io-index" 500 + checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" 501 + dependencies = [ 502 + "base64", 503 + "bytes", 504 + "futures-channel", 505 + "futures-util", 506 + "http", 507 + "http-body", 508 + "hyper", 509 + "ipnet", 510 + "libc", 511 + "percent-encoding", 512 + "pin-project-lite", 513 + "socket2", 514 + "tokio", 515 + "tower-service", 516 + "tracing", 517 + ] 518 + 519 + [[package]] 520 + name = "iana-time-zone" 521 + version = "0.1.65" 522 + source = "registry+https://github.com/rust-lang/crates.io-index" 523 + checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" 524 + dependencies = [ 525 + "android_system_properties", 526 + "core-foundation-sys", 527 + "iana-time-zone-haiku", 528 + "js-sys", 529 + "log", 530 + "wasm-bindgen", 531 + "windows-core", 532 + ] 533 + 534 + [[package]] 535 + name = "iana-time-zone-haiku" 536 + version = "0.1.2" 537 + source = "registry+https://github.com/rust-lang/crates.io-index" 538 + checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 539 + dependencies = [ 540 + "cc", 541 + ] 542 + 543 + [[package]] 544 + name = "icu_collections" 545 + version = "2.2.0" 546 + source = "registry+https://github.com/rust-lang/crates.io-index" 547 + checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" 548 + dependencies = [ 549 + "displaydoc", 550 + "potential_utf", 551 + "utf8_iter", 552 + "yoke", 553 + "zerofrom", 554 + "zerovec", 555 + ] 556 + 557 + [[package]] 558 + name = "icu_locale_core" 559 + version = "2.2.0" 560 + source = "registry+https://github.com/rust-lang/crates.io-index" 561 + checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" 562 + dependencies = [ 563 + "displaydoc", 564 + "litemap", 565 + "tinystr", 566 + "writeable", 567 + "zerovec", 568 + ] 569 + 570 + [[package]] 571 + name = "icu_normalizer" 572 + version = "2.2.0" 573 + source = "registry+https://github.com/rust-lang/crates.io-index" 574 + checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" 575 + dependencies = [ 576 + "icu_collections", 577 + "icu_normalizer_data", 578 + "icu_properties", 579 + "icu_provider", 580 + "smallvec", 581 + "zerovec", 582 + ] 583 + 584 + [[package]] 585 + name = "icu_normalizer_data" 586 + version = "2.2.0" 587 + source = "registry+https://github.com/rust-lang/crates.io-index" 588 + checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" 589 + 590 + [[package]] 591 + name = "icu_properties" 592 + version = "2.2.0" 593 + source = "registry+https://github.com/rust-lang/crates.io-index" 594 + checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" 595 + dependencies = [ 596 + "icu_collections", 597 + "icu_locale_core", 598 + "icu_properties_data", 599 + "icu_provider", 600 + "zerotrie", 601 + "zerovec", 602 + ] 603 + 604 + [[package]] 605 + name = "icu_properties_data" 606 + version = "2.2.0" 607 + source = "registry+https://github.com/rust-lang/crates.io-index" 608 + checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" 609 + 610 + [[package]] 611 + name = "icu_provider" 612 + version = "2.2.0" 613 + source = "registry+https://github.com/rust-lang/crates.io-index" 614 + checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" 615 + dependencies = [ 616 + "displaydoc", 617 + "icu_locale_core", 618 + "writeable", 619 + "yoke", 620 + "zerofrom", 621 + "zerotrie", 622 + "zerovec", 623 + ] 624 + 625 + [[package]] 626 + name = "id-arena" 627 + version = "2.3.0" 628 + source = "registry+https://github.com/rust-lang/crates.io-index" 629 + checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" 630 + 631 + [[package]] 632 + name = "idna" 633 + version = "1.1.0" 634 + source = "registry+https://github.com/rust-lang/crates.io-index" 635 + checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" 636 + dependencies = [ 637 + "idna_adapter", 638 + "smallvec", 639 + "utf8_iter", 640 + ] 641 + 642 + [[package]] 643 + name = "idna_adapter" 644 + version = "1.2.2" 645 + source = "registry+https://github.com/rust-lang/crates.io-index" 646 + checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" 647 + dependencies = [ 648 + "icu_normalizer", 649 + "icu_properties", 650 + ] 651 + 652 + [[package]] 653 + name = "indexmap" 654 + version = "2.14.0" 655 + source = "registry+https://github.com/rust-lang/crates.io-index" 656 + checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" 657 + dependencies = [ 658 + "equivalent", 659 + "hashbrown 0.17.0", 660 + "serde", 661 + "serde_core", 662 + ] 663 + 664 + [[package]] 665 + name = "ipnet" 666 + version = "2.12.0" 667 + source = "registry+https://github.com/rust-lang/crates.io-index" 668 + checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" 669 + 670 + [[package]] 671 + name = "iri-string" 672 + version = "0.7.12" 673 + source = "registry+https://github.com/rust-lang/crates.io-index" 674 + checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" 675 + dependencies = [ 676 + "memchr", 677 + "serde", 678 + ] 679 + 680 + [[package]] 681 + name = "is_terminal_polyfill" 682 + version = "1.70.2" 683 + source = "registry+https://github.com/rust-lang/crates.io-index" 684 + checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 685 + 686 + [[package]] 687 + name = "itoa" 688 + version = "1.0.18" 689 + source = "registry+https://github.com/rust-lang/crates.io-index" 690 + checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" 691 + 692 + [[package]] 693 + name = "js-sys" 694 + version = "0.3.97" 695 + source = "registry+https://github.com/rust-lang/crates.io-index" 696 + checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" 697 + dependencies = [ 698 + "cfg-if", 699 + "futures-util", 700 + "once_cell", 701 + "wasm-bindgen", 702 + ] 703 + 704 + [[package]] 705 + name = "lazy_static" 706 + version = "1.5.0" 707 + source = "registry+https://github.com/rust-lang/crates.io-index" 708 + checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 709 + 710 + [[package]] 711 + name = "leb128fmt" 712 + version = "0.1.0" 713 + source = "registry+https://github.com/rust-lang/crates.io-index" 714 + checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" 715 + 716 + [[package]] 717 + name = "libc" 718 + version = "0.2.186" 719 + source = "registry+https://github.com/rust-lang/crates.io-index" 720 + checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" 721 + 722 + [[package]] 723 + name = "linux-raw-sys" 724 + version = "0.12.1" 725 + source = "registry+https://github.com/rust-lang/crates.io-index" 726 + checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" 727 + 728 + [[package]] 729 + name = "litemap" 730 + version = "0.8.2" 731 + source = "registry+https://github.com/rust-lang/crates.io-index" 732 + checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" 733 + 734 + [[package]] 735 + name = "lock_api" 736 + version = "0.4.14" 737 + source = "registry+https://github.com/rust-lang/crates.io-index" 738 + checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" 739 + dependencies = [ 740 + "scopeguard", 741 + ] 742 + 743 + [[package]] 744 + name = "log" 745 + version = "0.4.29" 746 + source = "registry+https://github.com/rust-lang/crates.io-index" 747 + checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 748 + 749 + [[package]] 750 + name = "lru-slab" 751 + version = "0.1.2" 752 + source = "registry+https://github.com/rust-lang/crates.io-index" 753 + checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" 754 + 755 + [[package]] 756 + name = "matchers" 757 + version = "0.2.0" 758 + source = "registry+https://github.com/rust-lang/crates.io-index" 759 + checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" 760 + dependencies = [ 761 + "regex-automata", 762 + ] 763 + 764 + [[package]] 765 + name = "memchr" 766 + version = "2.8.0" 767 + source = "registry+https://github.com/rust-lang/crates.io-index" 768 + checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" 769 + 770 + [[package]] 771 + name = "mio" 772 + version = "1.2.0" 773 + source = "registry+https://github.com/rust-lang/crates.io-index" 774 + checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" 775 + dependencies = [ 776 + "libc", 777 + "wasi", 778 + "windows-sys 0.61.2", 779 + ] 780 + 781 + [[package]] 782 + name = "nu-ansi-term" 783 + version = "0.50.3" 784 + source = "registry+https://github.com/rust-lang/crates.io-index" 785 + checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 786 + dependencies = [ 787 + "windows-sys 0.61.2", 788 + ] 789 + 790 + [[package]] 791 + name = "num-traits" 792 + version = "0.2.19" 793 + source = "registry+https://github.com/rust-lang/crates.io-index" 794 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 795 + dependencies = [ 796 + "autocfg", 797 + ] 798 + 799 + [[package]] 800 + name = "once_cell" 801 + version = "1.21.4" 802 + source = "registry+https://github.com/rust-lang/crates.io-index" 803 + checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" 804 + 805 + [[package]] 806 + name = "once_cell_polyfill" 807 + version = "1.70.2" 808 + source = "registry+https://github.com/rust-lang/crates.io-index" 809 + checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 810 + 811 + [[package]] 812 + name = "parking_lot" 813 + version = "0.12.5" 814 + source = "registry+https://github.com/rust-lang/crates.io-index" 815 + checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" 816 + dependencies = [ 817 + "lock_api", 818 + "parking_lot_core", 819 + ] 820 + 821 + [[package]] 822 + name = "parking_lot_core" 823 + version = "0.9.12" 824 + source = "registry+https://github.com/rust-lang/crates.io-index" 825 + checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" 826 + dependencies = [ 827 + "cfg-if", 828 + "libc", 829 + "redox_syscall", 830 + "smallvec", 831 + "windows-link", 832 + ] 833 + 834 + [[package]] 835 + name = "percent-encoding" 836 + version = "2.3.2" 837 + source = "registry+https://github.com/rust-lang/crates.io-index" 838 + checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 839 + 840 + [[package]] 841 + name = "pin-project-lite" 842 + version = "0.2.17" 843 + source = "registry+https://github.com/rust-lang/crates.io-index" 844 + checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" 845 + 846 + [[package]] 847 + name = "potential_utf" 848 + version = "0.1.5" 849 + source = "registry+https://github.com/rust-lang/crates.io-index" 850 + checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" 851 + dependencies = [ 852 + "zerovec", 853 + ] 854 + 855 + [[package]] 856 + name = "ppv-lite86" 857 + version = "0.2.21" 858 + source = "registry+https://github.com/rust-lang/crates.io-index" 859 + checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 860 + dependencies = [ 861 + "zerocopy", 862 + ] 863 + 864 + [[package]] 865 + name = "prettyplease" 866 + version = "0.2.37" 867 + source = "registry+https://github.com/rust-lang/crates.io-index" 868 + checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" 869 + dependencies = [ 870 + "proc-macro2", 871 + "syn", 872 + ] 873 + 874 + [[package]] 875 + name = "proc-macro2" 876 + version = "1.0.106" 877 + source = "registry+https://github.com/rust-lang/crates.io-index" 878 + checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" 879 + dependencies = [ 880 + "unicode-ident", 881 + ] 882 + 883 + [[package]] 884 + name = "quinn" 885 + version = "0.11.9" 886 + source = "registry+https://github.com/rust-lang/crates.io-index" 887 + checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" 888 + dependencies = [ 889 + "bytes", 890 + "cfg_aliases", 891 + "pin-project-lite", 892 + "quinn-proto", 893 + "quinn-udp", 894 + "rustc-hash", 895 + "rustls", 896 + "socket2", 897 + "thiserror", 898 + "tokio", 899 + "tracing", 900 + "web-time", 901 + ] 902 + 903 + [[package]] 904 + name = "quinn-proto" 905 + version = "0.11.14" 906 + source = "registry+https://github.com/rust-lang/crates.io-index" 907 + checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" 908 + dependencies = [ 909 + "bytes", 910 + "getrandom 0.3.4", 911 + "lru-slab", 912 + "rand", 913 + "ring", 914 + "rustc-hash", 915 + "rustls", 916 + "rustls-pki-types", 917 + "slab", 918 + "thiserror", 919 + "tinyvec", 920 + "tracing", 921 + "web-time", 922 + ] 923 + 924 + [[package]] 925 + name = "quinn-udp" 926 + version = "0.5.14" 927 + source = "registry+https://github.com/rust-lang/crates.io-index" 928 + checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" 929 + dependencies = [ 930 + "cfg_aliases", 931 + "libc", 932 + "once_cell", 933 + "socket2", 934 + "tracing", 935 + "windows-sys 0.60.2", 936 + ] 937 + 938 + [[package]] 939 + name = "quote" 940 + version = "1.0.45" 941 + source = "registry+https://github.com/rust-lang/crates.io-index" 942 + checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" 943 + dependencies = [ 944 + "proc-macro2", 945 + ] 946 + 947 + [[package]] 948 + name = "r-efi" 949 + version = "5.3.0" 950 + source = "registry+https://github.com/rust-lang/crates.io-index" 951 + checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 952 + 953 + [[package]] 954 + name = "r-efi" 955 + version = "6.0.0" 956 + source = "registry+https://github.com/rust-lang/crates.io-index" 957 + checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" 958 + 959 + [[package]] 960 + name = "rand" 961 + version = "0.9.4" 962 + source = "registry+https://github.com/rust-lang/crates.io-index" 963 + checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" 964 + dependencies = [ 965 + "rand_chacha", 966 + "rand_core", 967 + ] 968 + 969 + [[package]] 970 + name = "rand_chacha" 971 + version = "0.9.0" 972 + source = "registry+https://github.com/rust-lang/crates.io-index" 973 + checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 974 + dependencies = [ 975 + "ppv-lite86", 976 + "rand_core", 977 + ] 978 + 979 + [[package]] 980 + name = "rand_core" 981 + version = "0.9.5" 982 + source = "registry+https://github.com/rust-lang/crates.io-index" 983 + checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" 984 + dependencies = [ 985 + "getrandom 0.3.4", 986 + ] 987 + 988 + [[package]] 989 + name = "redox_syscall" 990 + version = "0.5.18" 991 + source = "registry+https://github.com/rust-lang/crates.io-index" 992 + checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 993 + dependencies = [ 994 + "bitflags", 995 + ] 996 + 997 + [[package]] 998 + name = "regex-automata" 999 + version = "0.4.14" 1000 + source = "registry+https://github.com/rust-lang/crates.io-index" 1001 + checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" 1002 + dependencies = [ 1003 + "aho-corasick", 1004 + "memchr", 1005 + "regex-syntax", 1006 + ] 1007 + 1008 + [[package]] 1009 + name = "regex-syntax" 1010 + version = "0.8.10" 1011 + source = "registry+https://github.com/rust-lang/crates.io-index" 1012 + checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" 1013 + 1014 + [[package]] 1015 + name = "reqwest" 1016 + version = "0.12.28" 1017 + source = "registry+https://github.com/rust-lang/crates.io-index" 1018 + checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" 1019 + dependencies = [ 1020 + "base64", 1021 + "bytes", 1022 + "futures-core", 1023 + "http", 1024 + "http-body", 1025 + "http-body-util", 1026 + "hyper", 1027 + "hyper-rustls", 1028 + "hyper-util", 1029 + "js-sys", 1030 + "log", 1031 + "percent-encoding", 1032 + "pin-project-lite", 1033 + "quinn", 1034 + "rustls", 1035 + "rustls-pki-types", 1036 + "serde", 1037 + "serde_json", 1038 + "serde_urlencoded", 1039 + "sync_wrapper", 1040 + "tokio", 1041 + "tokio-rustls", 1042 + "tower", 1043 + "tower-http", 1044 + "tower-service", 1045 + "url", 1046 + "wasm-bindgen", 1047 + "wasm-bindgen-futures", 1048 + "web-sys", 1049 + "webpki-roots", 1050 + ] 1051 + 1052 + [[package]] 1053 + name = "ring" 1054 + version = "0.17.14" 1055 + source = "registry+https://github.com/rust-lang/crates.io-index" 1056 + checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" 1057 + dependencies = [ 1058 + "cc", 1059 + "cfg-if", 1060 + "getrandom 0.2.17", 1061 + "libc", 1062 + "untrusted", 1063 + "windows-sys 0.52.0", 1064 + ] 1065 + 1066 + [[package]] 1067 + name = "rsequoia" 1068 + version = "0.1.0" 1069 + dependencies = [ 1070 + "anyhow", 1071 + "chrono", 1072 + "clap", 1073 + "globset", 1074 + "reqwest", 1075 + "serde", 1076 + "serde_json", 1077 + "serde_yaml", 1078 + "sha2", 1079 + "tempfile", 1080 + "tokio", 1081 + "tracing", 1082 + "tracing-subscriber", 1083 + "walkdir", 1084 + ] 1085 + 1086 + [[package]] 1087 + name = "rustc-hash" 1088 + version = "2.1.2" 1089 + source = "registry+https://github.com/rust-lang/crates.io-index" 1090 + checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" 1091 + 1092 + [[package]] 1093 + name = "rustix" 1094 + version = "1.1.4" 1095 + source = "registry+https://github.com/rust-lang/crates.io-index" 1096 + checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" 1097 + dependencies = [ 1098 + "bitflags", 1099 + "errno", 1100 + "libc", 1101 + "linux-raw-sys", 1102 + "windows-sys 0.61.2", 1103 + ] 1104 + 1105 + [[package]] 1106 + name = "rustls" 1107 + version = "0.23.40" 1108 + source = "registry+https://github.com/rust-lang/crates.io-index" 1109 + checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" 1110 + dependencies = [ 1111 + "once_cell", 1112 + "ring", 1113 + "rustls-pki-types", 1114 + "rustls-webpki", 1115 + "subtle", 1116 + "zeroize", 1117 + ] 1118 + 1119 + [[package]] 1120 + name = "rustls-pki-types" 1121 + version = "1.14.1" 1122 + source = "registry+https://github.com/rust-lang/crates.io-index" 1123 + checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" 1124 + dependencies = [ 1125 + "web-time", 1126 + "zeroize", 1127 + ] 1128 + 1129 + [[package]] 1130 + name = "rustls-webpki" 1131 + version = "0.103.13" 1132 + source = "registry+https://github.com/rust-lang/crates.io-index" 1133 + checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" 1134 + dependencies = [ 1135 + "ring", 1136 + "rustls-pki-types", 1137 + "untrusted", 1138 + ] 1139 + 1140 + [[package]] 1141 + name = "rustversion" 1142 + version = "1.0.22" 1143 + source = "registry+https://github.com/rust-lang/crates.io-index" 1144 + checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 1145 + 1146 + [[package]] 1147 + name = "ryu" 1148 + version = "1.0.23" 1149 + source = "registry+https://github.com/rust-lang/crates.io-index" 1150 + checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" 1151 + 1152 + [[package]] 1153 + name = "same-file" 1154 + version = "1.0.6" 1155 + source = "registry+https://github.com/rust-lang/crates.io-index" 1156 + checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 1157 + dependencies = [ 1158 + "winapi-util", 1159 + ] 1160 + 1161 + [[package]] 1162 + name = "scopeguard" 1163 + version = "1.2.0" 1164 + source = "registry+https://github.com/rust-lang/crates.io-index" 1165 + checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1166 + 1167 + [[package]] 1168 + name = "semver" 1169 + version = "1.0.28" 1170 + source = "registry+https://github.com/rust-lang/crates.io-index" 1171 + checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" 1172 + 1173 + [[package]] 1174 + name = "serde" 1175 + version = "1.0.228" 1176 + source = "registry+https://github.com/rust-lang/crates.io-index" 1177 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 1178 + dependencies = [ 1179 + "serde_core", 1180 + "serde_derive", 1181 + ] 1182 + 1183 + [[package]] 1184 + name = "serde_core" 1185 + version = "1.0.228" 1186 + source = "registry+https://github.com/rust-lang/crates.io-index" 1187 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 1188 + dependencies = [ 1189 + "serde_derive", 1190 + ] 1191 + 1192 + [[package]] 1193 + name = "serde_derive" 1194 + version = "1.0.228" 1195 + source = "registry+https://github.com/rust-lang/crates.io-index" 1196 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 1197 + dependencies = [ 1198 + "proc-macro2", 1199 + "quote", 1200 + "syn", 1201 + ] 1202 + 1203 + [[package]] 1204 + name = "serde_json" 1205 + version = "1.0.149" 1206 + source = "registry+https://github.com/rust-lang/crates.io-index" 1207 + checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" 1208 + dependencies = [ 1209 + "itoa", 1210 + "memchr", 1211 + "serde", 1212 + "serde_core", 1213 + "zmij", 1214 + ] 1215 + 1216 + [[package]] 1217 + name = "serde_urlencoded" 1218 + version = "0.7.1" 1219 + source = "registry+https://github.com/rust-lang/crates.io-index" 1220 + checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 1221 + dependencies = [ 1222 + "form_urlencoded", 1223 + "itoa", 1224 + "ryu", 1225 + "serde", 1226 + ] 1227 + 1228 + [[package]] 1229 + name = "serde_yaml" 1230 + version = "0.9.34+deprecated" 1231 + source = "registry+https://github.com/rust-lang/crates.io-index" 1232 + checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" 1233 + dependencies = [ 1234 + "indexmap", 1235 + "itoa", 1236 + "ryu", 1237 + "serde", 1238 + "unsafe-libyaml", 1239 + ] 1240 + 1241 + [[package]] 1242 + name = "sha2" 1243 + version = "0.10.9" 1244 + source = "registry+https://github.com/rust-lang/crates.io-index" 1245 + checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" 1246 + dependencies = [ 1247 + "cfg-if", 1248 + "cpufeatures", 1249 + "digest", 1250 + ] 1251 + 1252 + [[package]] 1253 + name = "sharded-slab" 1254 + version = "0.1.7" 1255 + source = "registry+https://github.com/rust-lang/crates.io-index" 1256 + checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 1257 + dependencies = [ 1258 + "lazy_static", 1259 + ] 1260 + 1261 + [[package]] 1262 + name = "shlex" 1263 + version = "1.3.0" 1264 + source = "registry+https://github.com/rust-lang/crates.io-index" 1265 + checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 1266 + 1267 + [[package]] 1268 + name = "signal-hook-registry" 1269 + version = "1.4.8" 1270 + source = "registry+https://github.com/rust-lang/crates.io-index" 1271 + checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" 1272 + dependencies = [ 1273 + "errno", 1274 + "libc", 1275 + ] 1276 + 1277 + [[package]] 1278 + name = "slab" 1279 + version = "0.4.12" 1280 + source = "registry+https://github.com/rust-lang/crates.io-index" 1281 + checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" 1282 + 1283 + [[package]] 1284 + name = "smallvec" 1285 + version = "1.15.1" 1286 + source = "registry+https://github.com/rust-lang/crates.io-index" 1287 + checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 1288 + 1289 + [[package]] 1290 + name = "socket2" 1291 + version = "0.6.3" 1292 + source = "registry+https://github.com/rust-lang/crates.io-index" 1293 + checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" 1294 + dependencies = [ 1295 + "libc", 1296 + "windows-sys 0.61.2", 1297 + ] 1298 + 1299 + [[package]] 1300 + name = "stable_deref_trait" 1301 + version = "1.2.1" 1302 + source = "registry+https://github.com/rust-lang/crates.io-index" 1303 + checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 1304 + 1305 + [[package]] 1306 + name = "strsim" 1307 + version = "0.11.1" 1308 + source = "registry+https://github.com/rust-lang/crates.io-index" 1309 + checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 1310 + 1311 + [[package]] 1312 + name = "subtle" 1313 + version = "2.6.1" 1314 + source = "registry+https://github.com/rust-lang/crates.io-index" 1315 + checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 1316 + 1317 + [[package]] 1318 + name = "syn" 1319 + version = "2.0.117" 1320 + source = "registry+https://github.com/rust-lang/crates.io-index" 1321 + checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" 1322 + dependencies = [ 1323 + "proc-macro2", 1324 + "quote", 1325 + "unicode-ident", 1326 + ] 1327 + 1328 + [[package]] 1329 + name = "sync_wrapper" 1330 + version = "1.0.2" 1331 + source = "registry+https://github.com/rust-lang/crates.io-index" 1332 + checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" 1333 + dependencies = [ 1334 + "futures-core", 1335 + ] 1336 + 1337 + [[package]] 1338 + name = "synstructure" 1339 + version = "0.13.2" 1340 + source = "registry+https://github.com/rust-lang/crates.io-index" 1341 + checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" 1342 + dependencies = [ 1343 + "proc-macro2", 1344 + "quote", 1345 + "syn", 1346 + ] 1347 + 1348 + [[package]] 1349 + name = "tempfile" 1350 + version = "3.27.0" 1351 + source = "registry+https://github.com/rust-lang/crates.io-index" 1352 + checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" 1353 + dependencies = [ 1354 + "fastrand", 1355 + "getrandom 0.4.2", 1356 + "once_cell", 1357 + "rustix", 1358 + "windows-sys 0.61.2", 1359 + ] 1360 + 1361 + [[package]] 1362 + name = "thiserror" 1363 + version = "2.0.18" 1364 + source = "registry+https://github.com/rust-lang/crates.io-index" 1365 + checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" 1366 + dependencies = [ 1367 + "thiserror-impl", 1368 + ] 1369 + 1370 + [[package]] 1371 + name = "thiserror-impl" 1372 + version = "2.0.18" 1373 + source = "registry+https://github.com/rust-lang/crates.io-index" 1374 + checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" 1375 + dependencies = [ 1376 + "proc-macro2", 1377 + "quote", 1378 + "syn", 1379 + ] 1380 + 1381 + [[package]] 1382 + name = "thread_local" 1383 + version = "1.1.9" 1384 + source = "registry+https://github.com/rust-lang/crates.io-index" 1385 + checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 1386 + dependencies = [ 1387 + "cfg-if", 1388 + ] 1389 + 1390 + [[package]] 1391 + name = "tinystr" 1392 + version = "0.8.3" 1393 + source = "registry+https://github.com/rust-lang/crates.io-index" 1394 + checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" 1395 + dependencies = [ 1396 + "displaydoc", 1397 + "zerovec", 1398 + ] 1399 + 1400 + [[package]] 1401 + name = "tinyvec" 1402 + version = "1.11.0" 1403 + source = "registry+https://github.com/rust-lang/crates.io-index" 1404 + checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" 1405 + dependencies = [ 1406 + "tinyvec_macros", 1407 + ] 1408 + 1409 + [[package]] 1410 + name = "tinyvec_macros" 1411 + version = "0.1.1" 1412 + source = "registry+https://github.com/rust-lang/crates.io-index" 1413 + checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" 1414 + 1415 + [[package]] 1416 + name = "tokio" 1417 + version = "1.52.1" 1418 + source = "registry+https://github.com/rust-lang/crates.io-index" 1419 + checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" 1420 + dependencies = [ 1421 + "bytes", 1422 + "libc", 1423 + "mio", 1424 + "parking_lot", 1425 + "pin-project-lite", 1426 + "signal-hook-registry", 1427 + "socket2", 1428 + "tokio-macros", 1429 + "windows-sys 0.61.2", 1430 + ] 1431 + 1432 + [[package]] 1433 + name = "tokio-macros" 1434 + version = "2.7.0" 1435 + source = "registry+https://github.com/rust-lang/crates.io-index" 1436 + checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" 1437 + dependencies = [ 1438 + "proc-macro2", 1439 + "quote", 1440 + "syn", 1441 + ] 1442 + 1443 + [[package]] 1444 + name = "tokio-rustls" 1445 + version = "0.26.4" 1446 + source = "registry+https://github.com/rust-lang/crates.io-index" 1447 + checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" 1448 + dependencies = [ 1449 + "rustls", 1450 + "tokio", 1451 + ] 1452 + 1453 + [[package]] 1454 + name = "tower" 1455 + version = "0.5.3" 1456 + source = "registry+https://github.com/rust-lang/crates.io-index" 1457 + checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" 1458 + dependencies = [ 1459 + "futures-core", 1460 + "futures-util", 1461 + "pin-project-lite", 1462 + "sync_wrapper", 1463 + "tokio", 1464 + "tower-layer", 1465 + "tower-service", 1466 + ] 1467 + 1468 + [[package]] 1469 + name = "tower-http" 1470 + version = "0.6.8" 1471 + source = "registry+https://github.com/rust-lang/crates.io-index" 1472 + checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" 1473 + dependencies = [ 1474 + "bitflags", 1475 + "bytes", 1476 + "futures-util", 1477 + "http", 1478 + "http-body", 1479 + "iri-string", 1480 + "pin-project-lite", 1481 + "tower", 1482 + "tower-layer", 1483 + "tower-service", 1484 + ] 1485 + 1486 + [[package]] 1487 + name = "tower-layer" 1488 + version = "0.3.3" 1489 + source = "registry+https://github.com/rust-lang/crates.io-index" 1490 + checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" 1491 + 1492 + [[package]] 1493 + name = "tower-service" 1494 + version = "0.3.3" 1495 + source = "registry+https://github.com/rust-lang/crates.io-index" 1496 + checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 1497 + 1498 + [[package]] 1499 + name = "tracing" 1500 + version = "0.1.44" 1501 + source = "registry+https://github.com/rust-lang/crates.io-index" 1502 + checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" 1503 + dependencies = [ 1504 + "log", 1505 + "pin-project-lite", 1506 + "tracing-attributes", 1507 + "tracing-core", 1508 + ] 1509 + 1510 + [[package]] 1511 + name = "tracing-attributes" 1512 + version = "0.1.31" 1513 + source = "registry+https://github.com/rust-lang/crates.io-index" 1514 + checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" 1515 + dependencies = [ 1516 + "proc-macro2", 1517 + "quote", 1518 + "syn", 1519 + ] 1520 + 1521 + [[package]] 1522 + name = "tracing-core" 1523 + version = "0.1.36" 1524 + source = "registry+https://github.com/rust-lang/crates.io-index" 1525 + checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" 1526 + dependencies = [ 1527 + "once_cell", 1528 + "valuable", 1529 + ] 1530 + 1531 + [[package]] 1532 + name = "tracing-log" 1533 + version = "0.2.0" 1534 + source = "registry+https://github.com/rust-lang/crates.io-index" 1535 + checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 1536 + dependencies = [ 1537 + "log", 1538 + "once_cell", 1539 + "tracing-core", 1540 + ] 1541 + 1542 + [[package]] 1543 + name = "tracing-subscriber" 1544 + version = "0.3.23" 1545 + source = "registry+https://github.com/rust-lang/crates.io-index" 1546 + checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" 1547 + dependencies = [ 1548 + "matchers", 1549 + "nu-ansi-term", 1550 + "once_cell", 1551 + "regex-automata", 1552 + "sharded-slab", 1553 + "smallvec", 1554 + "thread_local", 1555 + "tracing", 1556 + "tracing-core", 1557 + "tracing-log", 1558 + ] 1559 + 1560 + [[package]] 1561 + name = "try-lock" 1562 + version = "0.2.5" 1563 + source = "registry+https://github.com/rust-lang/crates.io-index" 1564 + checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 1565 + 1566 + [[package]] 1567 + name = "typenum" 1568 + version = "1.20.0" 1569 + source = "registry+https://github.com/rust-lang/crates.io-index" 1570 + checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" 1571 + 1572 + [[package]] 1573 + name = "unicode-ident" 1574 + version = "1.0.24" 1575 + source = "registry+https://github.com/rust-lang/crates.io-index" 1576 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 1577 + 1578 + [[package]] 1579 + name = "unicode-xid" 1580 + version = "0.2.6" 1581 + source = "registry+https://github.com/rust-lang/crates.io-index" 1582 + checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 1583 + 1584 + [[package]] 1585 + name = "unsafe-libyaml" 1586 + version = "0.2.11" 1587 + source = "registry+https://github.com/rust-lang/crates.io-index" 1588 + checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" 1589 + 1590 + [[package]] 1591 + name = "untrusted" 1592 + version = "0.9.0" 1593 + source = "registry+https://github.com/rust-lang/crates.io-index" 1594 + checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 1595 + 1596 + [[package]] 1597 + name = "url" 1598 + version = "2.5.8" 1599 + source = "registry+https://github.com/rust-lang/crates.io-index" 1600 + checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" 1601 + dependencies = [ 1602 + "form_urlencoded", 1603 + "idna", 1604 + "percent-encoding", 1605 + "serde", 1606 + ] 1607 + 1608 + [[package]] 1609 + name = "utf8_iter" 1610 + version = "1.0.4" 1611 + source = "registry+https://github.com/rust-lang/crates.io-index" 1612 + checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 1613 + 1614 + [[package]] 1615 + name = "utf8parse" 1616 + version = "0.2.2" 1617 + source = "registry+https://github.com/rust-lang/crates.io-index" 1618 + checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 1619 + 1620 + [[package]] 1621 + name = "valuable" 1622 + version = "0.1.1" 1623 + source = "registry+https://github.com/rust-lang/crates.io-index" 1624 + checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 1625 + 1626 + [[package]] 1627 + name = "version_check" 1628 + version = "0.9.5" 1629 + source = "registry+https://github.com/rust-lang/crates.io-index" 1630 + checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 1631 + 1632 + [[package]] 1633 + name = "walkdir" 1634 + version = "2.5.0" 1635 + source = "registry+https://github.com/rust-lang/crates.io-index" 1636 + checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 1637 + dependencies = [ 1638 + "same-file", 1639 + "winapi-util", 1640 + ] 1641 + 1642 + [[package]] 1643 + name = "want" 1644 + version = "0.3.1" 1645 + source = "registry+https://github.com/rust-lang/crates.io-index" 1646 + checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" 1647 + dependencies = [ 1648 + "try-lock", 1649 + ] 1650 + 1651 + [[package]] 1652 + name = "wasi" 1653 + version = "0.11.1+wasi-snapshot-preview1" 1654 + source = "registry+https://github.com/rust-lang/crates.io-index" 1655 + checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 1656 + 1657 + [[package]] 1658 + name = "wasip2" 1659 + version = "1.0.3+wasi-0.2.9" 1660 + source = "registry+https://github.com/rust-lang/crates.io-index" 1661 + checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" 1662 + dependencies = [ 1663 + "wit-bindgen 0.57.1", 1664 + ] 1665 + 1666 + [[package]] 1667 + name = "wasip3" 1668 + version = "0.4.0+wasi-0.3.0-rc-2026-01-06" 1669 + source = "registry+https://github.com/rust-lang/crates.io-index" 1670 + checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" 1671 + dependencies = [ 1672 + "wit-bindgen 0.51.0", 1673 + ] 1674 + 1675 + [[package]] 1676 + name = "wasm-bindgen" 1677 + version = "0.2.120" 1678 + source = "registry+https://github.com/rust-lang/crates.io-index" 1679 + checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" 1680 + dependencies = [ 1681 + "cfg-if", 1682 + "once_cell", 1683 + "rustversion", 1684 + "wasm-bindgen-macro", 1685 + "wasm-bindgen-shared", 1686 + ] 1687 + 1688 + [[package]] 1689 + name = "wasm-bindgen-futures" 1690 + version = "0.4.70" 1691 + source = "registry+https://github.com/rust-lang/crates.io-index" 1692 + checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" 1693 + dependencies = [ 1694 + "js-sys", 1695 + "wasm-bindgen", 1696 + ] 1697 + 1698 + [[package]] 1699 + name = "wasm-bindgen-macro" 1700 + version = "0.2.120" 1701 + source = "registry+https://github.com/rust-lang/crates.io-index" 1702 + checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" 1703 + dependencies = [ 1704 + "quote", 1705 + "wasm-bindgen-macro-support", 1706 + ] 1707 + 1708 + [[package]] 1709 + name = "wasm-bindgen-macro-support" 1710 + version = "0.2.120" 1711 + source = "registry+https://github.com/rust-lang/crates.io-index" 1712 + checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" 1713 + dependencies = [ 1714 + "bumpalo", 1715 + "proc-macro2", 1716 + "quote", 1717 + "syn", 1718 + "wasm-bindgen-shared", 1719 + ] 1720 + 1721 + [[package]] 1722 + name = "wasm-bindgen-shared" 1723 + version = "0.2.120" 1724 + source = "registry+https://github.com/rust-lang/crates.io-index" 1725 + checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" 1726 + dependencies = [ 1727 + "unicode-ident", 1728 + ] 1729 + 1730 + [[package]] 1731 + name = "wasm-encoder" 1732 + version = "0.244.0" 1733 + source = "registry+https://github.com/rust-lang/crates.io-index" 1734 + checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" 1735 + dependencies = [ 1736 + "leb128fmt", 1737 + "wasmparser", 1738 + ] 1739 + 1740 + [[package]] 1741 + name = "wasm-metadata" 1742 + version = "0.244.0" 1743 + source = "registry+https://github.com/rust-lang/crates.io-index" 1744 + checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" 1745 + dependencies = [ 1746 + "anyhow", 1747 + "indexmap", 1748 + "wasm-encoder", 1749 + "wasmparser", 1750 + ] 1751 + 1752 + [[package]] 1753 + name = "wasmparser" 1754 + version = "0.244.0" 1755 + source = "registry+https://github.com/rust-lang/crates.io-index" 1756 + checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" 1757 + dependencies = [ 1758 + "bitflags", 1759 + "hashbrown 0.15.5", 1760 + "indexmap", 1761 + "semver", 1762 + ] 1763 + 1764 + [[package]] 1765 + name = "web-sys" 1766 + version = "0.3.97" 1767 + source = "registry+https://github.com/rust-lang/crates.io-index" 1768 + checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" 1769 + dependencies = [ 1770 + "js-sys", 1771 + "wasm-bindgen", 1772 + ] 1773 + 1774 + [[package]] 1775 + name = "web-time" 1776 + version = "1.1.0" 1777 + source = "registry+https://github.com/rust-lang/crates.io-index" 1778 + checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" 1779 + dependencies = [ 1780 + "js-sys", 1781 + "wasm-bindgen", 1782 + ] 1783 + 1784 + [[package]] 1785 + name = "webpki-roots" 1786 + version = "1.0.7" 1787 + source = "registry+https://github.com/rust-lang/crates.io-index" 1788 + checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" 1789 + dependencies = [ 1790 + "rustls-pki-types", 1791 + ] 1792 + 1793 + [[package]] 1794 + name = "winapi-util" 1795 + version = "0.1.11" 1796 + source = "registry+https://github.com/rust-lang/crates.io-index" 1797 + checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" 1798 + dependencies = [ 1799 + "windows-sys 0.61.2", 1800 + ] 1801 + 1802 + [[package]] 1803 + name = "windows-core" 1804 + version = "0.62.2" 1805 + source = "registry+https://github.com/rust-lang/crates.io-index" 1806 + checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" 1807 + dependencies = [ 1808 + "windows-implement", 1809 + "windows-interface", 1810 + "windows-link", 1811 + "windows-result", 1812 + "windows-strings", 1813 + ] 1814 + 1815 + [[package]] 1816 + name = "windows-implement" 1817 + version = "0.60.2" 1818 + source = "registry+https://github.com/rust-lang/crates.io-index" 1819 + checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" 1820 + dependencies = [ 1821 + "proc-macro2", 1822 + "quote", 1823 + "syn", 1824 + ] 1825 + 1826 + [[package]] 1827 + name = "windows-interface" 1828 + version = "0.59.3" 1829 + source = "registry+https://github.com/rust-lang/crates.io-index" 1830 + checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" 1831 + dependencies = [ 1832 + "proc-macro2", 1833 + "quote", 1834 + "syn", 1835 + ] 1836 + 1837 + [[package]] 1838 + name = "windows-link" 1839 + version = "0.2.1" 1840 + source = "registry+https://github.com/rust-lang/crates.io-index" 1841 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 1842 + 1843 + [[package]] 1844 + name = "windows-result" 1845 + version = "0.4.1" 1846 + source = "registry+https://github.com/rust-lang/crates.io-index" 1847 + checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" 1848 + dependencies = [ 1849 + "windows-link", 1850 + ] 1851 + 1852 + [[package]] 1853 + name = "windows-strings" 1854 + version = "0.5.1" 1855 + source = "registry+https://github.com/rust-lang/crates.io-index" 1856 + checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" 1857 + dependencies = [ 1858 + "windows-link", 1859 + ] 1860 + 1861 + [[package]] 1862 + name = "windows-sys" 1863 + version = "0.52.0" 1864 + source = "registry+https://github.com/rust-lang/crates.io-index" 1865 + checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 1866 + dependencies = [ 1867 + "windows-targets 0.52.6", 1868 + ] 1869 + 1870 + [[package]] 1871 + name = "windows-sys" 1872 + version = "0.60.2" 1873 + source = "registry+https://github.com/rust-lang/crates.io-index" 1874 + checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 1875 + dependencies = [ 1876 + "windows-targets 0.53.5", 1877 + ] 1878 + 1879 + [[package]] 1880 + name = "windows-sys" 1881 + version = "0.61.2" 1882 + source = "registry+https://github.com/rust-lang/crates.io-index" 1883 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 1884 + dependencies = [ 1885 + "windows-link", 1886 + ] 1887 + 1888 + [[package]] 1889 + name = "windows-targets" 1890 + version = "0.52.6" 1891 + source = "registry+https://github.com/rust-lang/crates.io-index" 1892 + checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 1893 + dependencies = [ 1894 + "windows_aarch64_gnullvm 0.52.6", 1895 + "windows_aarch64_msvc 0.52.6", 1896 + "windows_i686_gnu 0.52.6", 1897 + "windows_i686_gnullvm 0.52.6", 1898 + "windows_i686_msvc 0.52.6", 1899 + "windows_x86_64_gnu 0.52.6", 1900 + "windows_x86_64_gnullvm 0.52.6", 1901 + "windows_x86_64_msvc 0.52.6", 1902 + ] 1903 + 1904 + [[package]] 1905 + name = "windows-targets" 1906 + version = "0.53.5" 1907 + source = "registry+https://github.com/rust-lang/crates.io-index" 1908 + checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" 1909 + dependencies = [ 1910 + "windows-link", 1911 + "windows_aarch64_gnullvm 0.53.1", 1912 + "windows_aarch64_msvc 0.53.1", 1913 + "windows_i686_gnu 0.53.1", 1914 + "windows_i686_gnullvm 0.53.1", 1915 + "windows_i686_msvc 0.53.1", 1916 + "windows_x86_64_gnu 0.53.1", 1917 + "windows_x86_64_gnullvm 0.53.1", 1918 + "windows_x86_64_msvc 0.53.1", 1919 + ] 1920 + 1921 + [[package]] 1922 + name = "windows_aarch64_gnullvm" 1923 + version = "0.52.6" 1924 + source = "registry+https://github.com/rust-lang/crates.io-index" 1925 + checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1926 + 1927 + [[package]] 1928 + name = "windows_aarch64_gnullvm" 1929 + version = "0.53.1" 1930 + source = "registry+https://github.com/rust-lang/crates.io-index" 1931 + checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" 1932 + 1933 + [[package]] 1934 + name = "windows_aarch64_msvc" 1935 + version = "0.52.6" 1936 + source = "registry+https://github.com/rust-lang/crates.io-index" 1937 + checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1938 + 1939 + [[package]] 1940 + name = "windows_aarch64_msvc" 1941 + version = "0.53.1" 1942 + source = "registry+https://github.com/rust-lang/crates.io-index" 1943 + checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" 1944 + 1945 + [[package]] 1946 + name = "windows_i686_gnu" 1947 + version = "0.52.6" 1948 + source = "registry+https://github.com/rust-lang/crates.io-index" 1949 + checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 1950 + 1951 + [[package]] 1952 + name = "windows_i686_gnu" 1953 + version = "0.53.1" 1954 + source = "registry+https://github.com/rust-lang/crates.io-index" 1955 + checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" 1956 + 1957 + [[package]] 1958 + name = "windows_i686_gnullvm" 1959 + version = "0.52.6" 1960 + source = "registry+https://github.com/rust-lang/crates.io-index" 1961 + checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1962 + 1963 + [[package]] 1964 + name = "windows_i686_gnullvm" 1965 + version = "0.53.1" 1966 + source = "registry+https://github.com/rust-lang/crates.io-index" 1967 + checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" 1968 + 1969 + [[package]] 1970 + name = "windows_i686_msvc" 1971 + version = "0.52.6" 1972 + source = "registry+https://github.com/rust-lang/crates.io-index" 1973 + checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1974 + 1975 + [[package]] 1976 + name = "windows_i686_msvc" 1977 + version = "0.53.1" 1978 + source = "registry+https://github.com/rust-lang/crates.io-index" 1979 + checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" 1980 + 1981 + [[package]] 1982 + name = "windows_x86_64_gnu" 1983 + version = "0.52.6" 1984 + source = "registry+https://github.com/rust-lang/crates.io-index" 1985 + checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1986 + 1987 + [[package]] 1988 + name = "windows_x86_64_gnu" 1989 + version = "0.53.1" 1990 + source = "registry+https://github.com/rust-lang/crates.io-index" 1991 + checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" 1992 + 1993 + [[package]] 1994 + name = "windows_x86_64_gnullvm" 1995 + version = "0.52.6" 1996 + source = "registry+https://github.com/rust-lang/crates.io-index" 1997 + checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1998 + 1999 + [[package]] 2000 + name = "windows_x86_64_gnullvm" 2001 + version = "0.53.1" 2002 + source = "registry+https://github.com/rust-lang/crates.io-index" 2003 + checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 2004 + 2005 + [[package]] 2006 + name = "windows_x86_64_msvc" 2007 + version = "0.52.6" 2008 + source = "registry+https://github.com/rust-lang/crates.io-index" 2009 + checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 2010 + 2011 + [[package]] 2012 + name = "windows_x86_64_msvc" 2013 + version = "0.53.1" 2014 + source = "registry+https://github.com/rust-lang/crates.io-index" 2015 + checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" 2016 + 2017 + [[package]] 2018 + name = "wit-bindgen" 2019 + version = "0.51.0" 2020 + source = "registry+https://github.com/rust-lang/crates.io-index" 2021 + checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" 2022 + dependencies = [ 2023 + "wit-bindgen-rust-macro", 2024 + ] 2025 + 2026 + [[package]] 2027 + name = "wit-bindgen" 2028 + version = "0.57.1" 2029 + source = "registry+https://github.com/rust-lang/crates.io-index" 2030 + checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" 2031 + 2032 + [[package]] 2033 + name = "wit-bindgen-core" 2034 + version = "0.51.0" 2035 + source = "registry+https://github.com/rust-lang/crates.io-index" 2036 + checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" 2037 + dependencies = [ 2038 + "anyhow", 2039 + "heck", 2040 + "wit-parser", 2041 + ] 2042 + 2043 + [[package]] 2044 + name = "wit-bindgen-rust" 2045 + version = "0.51.0" 2046 + source = "registry+https://github.com/rust-lang/crates.io-index" 2047 + checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" 2048 + dependencies = [ 2049 + "anyhow", 2050 + "heck", 2051 + "indexmap", 2052 + "prettyplease", 2053 + "syn", 2054 + "wasm-metadata", 2055 + "wit-bindgen-core", 2056 + "wit-component", 2057 + ] 2058 + 2059 + [[package]] 2060 + name = "wit-bindgen-rust-macro" 2061 + version = "0.51.0" 2062 + source = "registry+https://github.com/rust-lang/crates.io-index" 2063 + checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" 2064 + dependencies = [ 2065 + "anyhow", 2066 + "prettyplease", 2067 + "proc-macro2", 2068 + "quote", 2069 + "syn", 2070 + "wit-bindgen-core", 2071 + "wit-bindgen-rust", 2072 + ] 2073 + 2074 + [[package]] 2075 + name = "wit-component" 2076 + version = "0.244.0" 2077 + source = "registry+https://github.com/rust-lang/crates.io-index" 2078 + checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" 2079 + dependencies = [ 2080 + "anyhow", 2081 + "bitflags", 2082 + "indexmap", 2083 + "log", 2084 + "serde", 2085 + "serde_derive", 2086 + "serde_json", 2087 + "wasm-encoder", 2088 + "wasm-metadata", 2089 + "wasmparser", 2090 + "wit-parser", 2091 + ] 2092 + 2093 + [[package]] 2094 + name = "wit-parser" 2095 + version = "0.244.0" 2096 + source = "registry+https://github.com/rust-lang/crates.io-index" 2097 + checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" 2098 + dependencies = [ 2099 + "anyhow", 2100 + "id-arena", 2101 + "indexmap", 2102 + "log", 2103 + "semver", 2104 + "serde", 2105 + "serde_derive", 2106 + "serde_json", 2107 + "unicode-xid", 2108 + "wasmparser", 2109 + ] 2110 + 2111 + [[package]] 2112 + name = "writeable" 2113 + version = "0.6.3" 2114 + source = "registry+https://github.com/rust-lang/crates.io-index" 2115 + checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" 2116 + 2117 + [[package]] 2118 + name = "yoke" 2119 + version = "0.8.2" 2120 + source = "registry+https://github.com/rust-lang/crates.io-index" 2121 + checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" 2122 + dependencies = [ 2123 + "stable_deref_trait", 2124 + "yoke-derive", 2125 + "zerofrom", 2126 + ] 2127 + 2128 + [[package]] 2129 + name = "yoke-derive" 2130 + version = "0.8.2" 2131 + source = "registry+https://github.com/rust-lang/crates.io-index" 2132 + checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" 2133 + dependencies = [ 2134 + "proc-macro2", 2135 + "quote", 2136 + "syn", 2137 + "synstructure", 2138 + ] 2139 + 2140 + [[package]] 2141 + name = "zerocopy" 2142 + version = "0.8.48" 2143 + source = "registry+https://github.com/rust-lang/crates.io-index" 2144 + checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" 2145 + dependencies = [ 2146 + "zerocopy-derive", 2147 + ] 2148 + 2149 + [[package]] 2150 + name = "zerocopy-derive" 2151 + version = "0.8.48" 2152 + source = "registry+https://github.com/rust-lang/crates.io-index" 2153 + checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" 2154 + dependencies = [ 2155 + "proc-macro2", 2156 + "quote", 2157 + "syn", 2158 + ] 2159 + 2160 + [[package]] 2161 + name = "zerofrom" 2162 + version = "0.1.7" 2163 + source = "registry+https://github.com/rust-lang/crates.io-index" 2164 + checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" 2165 + dependencies = [ 2166 + "zerofrom-derive", 2167 + ] 2168 + 2169 + [[package]] 2170 + name = "zerofrom-derive" 2171 + version = "0.1.7" 2172 + source = "registry+https://github.com/rust-lang/crates.io-index" 2173 + checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" 2174 + dependencies = [ 2175 + "proc-macro2", 2176 + "quote", 2177 + "syn", 2178 + "synstructure", 2179 + ] 2180 + 2181 + [[package]] 2182 + name = "zeroize" 2183 + version = "1.8.2" 2184 + source = "registry+https://github.com/rust-lang/crates.io-index" 2185 + checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 2186 + 2187 + [[package]] 2188 + name = "zerotrie" 2189 + version = "0.2.4" 2190 + source = "registry+https://github.com/rust-lang/crates.io-index" 2191 + checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" 2192 + dependencies = [ 2193 + "displaydoc", 2194 + "yoke", 2195 + "zerofrom", 2196 + ] 2197 + 2198 + [[package]] 2199 + name = "zerovec" 2200 + version = "0.11.6" 2201 + source = "registry+https://github.com/rust-lang/crates.io-index" 2202 + checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" 2203 + dependencies = [ 2204 + "yoke", 2205 + "zerofrom", 2206 + "zerovec-derive", 2207 + ] 2208 + 2209 + [[package]] 2210 + name = "zerovec-derive" 2211 + version = "0.11.3" 2212 + source = "registry+https://github.com/rust-lang/crates.io-index" 2213 + checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" 2214 + dependencies = [ 2215 + "proc-macro2", 2216 + "quote", 2217 + "syn", 2218 + ] 2219 + 2220 + [[package]] 2221 + name = "zmij" 2222 + version = "1.0.21" 2223 + source = "registry+https://github.com/rust-lang/crates.io-index" 2224 + checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+29
Cargo.toml
··· 1 + [package] 2 + name = "rsequoia" 3 + version = "0.1.0" 4 + edition = "2024" 5 + description = "A Rust CLI for publishing standard.site documents to AT Protocol" 6 + 7 + [workspace] 8 + 9 + [[bin]] 10 + name = "rsequoia" 11 + path = "src/main.rs" 12 + 13 + [dependencies] 14 + clap = { version = "4.5", features = ["derive"] } 15 + reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } 16 + serde = { version = "1.0", features = ["derive"] } 17 + serde_json = "1.0" 18 + serde_yaml = "0.9" 19 + sha2 = "0.10" 20 + tokio = { version = "1", features = ["full"] } 21 + tracing = { version = "0.1", features = ["log"] } 22 + tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } 23 + walkdir = "2" 24 + globset = "0.4" 25 + chrono = "0.4" 26 + 27 + [dev-dependencies] 28 + tempfile = "3.13.0" 29 + anyhow = "1.0"
+99
README.md
··· 1 + # rsequoia 2 + 3 + A Rust reimplementation of [Sequoia](https://sequoia.pub) (`sequoia-cli`) for publishing [standard.site](https://standard.site) documents to AT Protocol. 4 + 5 + Built in Rust so it can be directly imported as a library into other Rust projects (like [lichen](https://github.com/nicobao/lichen)) without requiring Node.js or Bun as a runtime dependency. 6 + 7 + ## Features 8 + 9 + - **Config-compatible** with Sequoia's `sequoia.json` format 10 + - **State-compatible** with Sequoia's `.sequoia-state.json` format 11 + - **OAuth token pass-through** via environment variables (for integration with tools that already have AT Protocol auth) 12 + - **App password auth** via `ATP_IDENTIFIER` + `ATP_APP_PASSWORD` env vars (Sequoia-compatible) 13 + - **Publication icon upload** via `--icon` flag on init 14 + - **Cover image upload** from frontmatter field mapping 15 + - **Configurable frontmatter mapping** for different SSGs (Hugo, Astro, etc.) 16 + - **Draft filtering** via configurable frontmatter field 17 + 18 + ## Usage 19 + 20 + ```bash 21 + # authenticate 22 + rsequoia auth --handle alice.bsky.social --password xxxx-xxxx 23 + 24 + # create a publication record 25 + rsequoia init --name "My Blog" --url https://myblog.example.com 26 + 27 + # publish documents 28 + rsequoia publish 29 + 30 + # inject verification links into HTML 31 + rsequoia inject --output-dir ./dist 32 + ``` 33 + 34 + ### OAuth token auth (for integration with lichen) 35 + 36 + ```bash 37 + RSEQUOIA_ACCESS_TOKEN=eyJ... \ 38 + RSEQUOIA_DID=did:plc:abc123 \ 39 + RSEQUOIA_PDS_URL=https://bsky.social \ 40 + rsequoia publish 41 + ``` 42 + 43 + ## Configuration 44 + 45 + Create a `sequoia.json` in your project root: 46 + 47 + ```json 48 + { 49 + "siteUrl": "https://myblog.example.com", 50 + "contentDir": "./content", 51 + "publicationUri": "at://did:plc:abc123/site.standard.publication/self", 52 + "pathPrefix": "/", 53 + "ignore": ["header.md", "footer.md"], 54 + "publishContent": true, 55 + "frontmatter": { 56 + "title": "title", 57 + "description": "description", 58 + "publishDate": "date", 59 + "updatedAt": "updated", 60 + "draft": "draft", 61 + "tags": "tags", 62 + "coverImage": "cover_image" 63 + } 64 + } 65 + ``` 66 + 67 + ## As a library 68 + 69 + Add to your `Cargo.toml`: 70 + 71 + ```toml 72 + rsequoia = { path = "../references/rsequoia" } 73 + ``` 74 + 75 + Then use the modules directly: 76 + 77 + ```rust 78 + use rsequoia::config; 79 + use rsequoia::publish; 80 + use rsequoia::pds_client::PdsClient; 81 + 82 + let cfg = config::load_config(Path::new("sequoia.json"))?; 83 + let client = PdsClient::with_token(&pds_url, &access_token); 84 + let result = publish::publish(&client, &did, &cfg, base_dir, false).await?; 85 + ``` 86 + 87 + ## Running tests 88 + 89 + ```bash 90 + # unit tests 91 + cargo test 92 + 93 + # E2E tests (requires access to t1cc PDS) 94 + cd e2e-tests && ./run.sh 95 + ``` 96 + 97 + ## Credits 98 + 99 + This project is entirely based on [Sequoia](https://sequoia.pub) by [Steve Simkins](https://stevedylan.dev), reimplemented in Rust. The config format, state tracking, CLI commands, and publishing behavior are designed to be compatible with the original.
+71
diff.md
··· 1 + # rsequoia vs Sequoia — differences 2 + 3 + rsequoia is a Rust reimplementation of [sequoia-cli](https://sequoia.pub). This document notes the differences. 4 + 5 + ## Additions (not in original Sequoia) 6 + 7 + ### OAuth token authentication 8 + rsequoia accepts pre-authenticated OAuth tokens via environment variables: 9 + - `RSEQUOIA_ACCESS_TOKEN` — Bearer token 10 + - `RSEQUOIA_DID` — AT Protocol DID 11 + - `RSEQUOIA_PDS_URL` — PDS base URL 12 + 13 + This is the highest-priority auth method, above app passwords and stored credentials. It enables integration with tools like lichen that already manage their own AT Protocol OAuth flow. 14 + 15 + ### Library usage 16 + rsequoia is structured as a Rust library (`lib.rs`) with a binary entry point. All modules (`config`, `credentials`, `discovery`, `publish`, `inject`, `pds_client`, `markdown`, `types`) are public and can be imported directly into other Rust projects. 17 + 18 + ## Missing features (in original Sequoia but not here) 19 + 20 + ### Bluesky post creation 21 + Sequoia can optionally create a Bluesky post (`app.bsky.feed.post`) with an external embed linking to each published document. rsequoia does not create Bluesky posts. 22 + 23 + ### `bskyPostRef` field 24 + Sequoia tracks the Bluesky post reference in the document record's `bskyPostRef` field. rsequoia does not set this field. 25 + 26 + ### `content` union field 27 + The standard.site document schema has a `content` field defined as an open union. Neither Sequoia nor rsequoia populate this — both use `textContent` (plain text) instead. 28 + 29 + ### `basicTheme` field 30 + The standard.site publication schema has a `basicTheme` field for styling hints. Neither Sequoia nor rsequoia set this field. 31 + 32 + ### `sync` command 33 + Sequoia has a `sequoia sync` command that fetches remote state and reconciles with local files. rsequoia does not have a sync command — it only pushes local state to the PDS. 34 + 35 + ### `autoSync` config 36 + Sequoia's config supports `autoSync` for automatic frontmatter updates with AT URIs. rsequoia does not modify source files. 37 + 38 + ### `pathTemplate` config 39 + Sequoia supports `pathTemplate` with token replacement (`{slug}`, `{year}`, etc.) for URL generation. rsequoia uses `pathPrefix` + slug only. 40 + 41 + ### `removeIndexFromSlug` config 42 + Sequoia can strip `/index` suffixes from slugs. rsequoia handles this but does not expose it as a config option. 43 + 44 + ### UI config 45 + Sequoia has a `ui` config section. rsequoia does not (it is CLI-only). 46 + 47 + ## Behavioral differences 48 + 49 + ### Credential storage location 50 + - Sequoia: `~/.config/sequoia/credentials.json` 51 + - rsequoia: `~/.config/rsequoia/credentials.json` 52 + 53 + ### Environment variable names 54 + - Sequoia: `SEQUOIA_PROFILE` for profile selection 55 + - rsequoia: `RSEQUOIA_ACCESS_TOKEN`, `RSEQUOIA_DID`, `RSEQUOIA_PDS_URL` for OAuth 56 + 57 + Both support `ATP_IDENTIFIER`, `ATP_APP_PASSWORD`, and `PDS_URL`. 58 + 59 + ### `canonicalUrl` field 60 + Both Sequoia and rsequoia add a `canonicalUrl` field to document records. This field is NOT in the standard.site lexicon schema but is used for linking back to the original site. 61 + 62 + ### Text content extraction 63 + Both strip markdown to plain text for `textContent`, capped at 10,000 characters. The exact stripping logic may differ slightly between implementations. 64 + 65 + ## standard.site lexicon conformance 66 + 67 + Both rsequoia and Sequoia implement the same subset of the standard.site lexicons: 68 + 69 + **`site.standard.document`** — all required fields (`site`, `title`, `publishedAt`) plus optional `path`, `description`, `coverImage`, `textContent`, `tags`, `updatedAt`. 70 + 71 + **`site.standard.publication`** — all required fields (`url`, `name`) plus optional `icon`, `description`, `preferences`.
+130
e2e-tests/conftest.py
··· 1 + """Pytest fixtures for rsequoia E2E tests. 2 + 3 + Provides test configuration, binary location, PDS session tokens, 4 + and helper functions for setting up test sites. 5 + """ 6 + 7 + import json 8 + import os 9 + import subprocess 10 + import tempfile 11 + import shutil 12 + import time 13 + 14 + import pytest 15 + import requests 16 + import toml 17 + 18 + 19 + @pytest.fixture(scope="session") 20 + def test_config(): 21 + """Load test user configuration from testuser.toml.""" 22 + config_path = os.path.join(os.path.dirname(__file__), "testuser.toml") 23 + with open(config_path) as f: 24 + return toml.load(f) 25 + 26 + 27 + @pytest.fixture(scope="session") 28 + def cargo_binary(): 29 + """Locate the built rsequoia binary.""" 30 + repo_root = os.path.dirname(os.path.dirname(__file__)) 31 + result = subprocess.run( 32 + ["cargo", "metadata", "--format-version", "1", "--no-deps"], 33 + cwd=repo_root, 34 + capture_output=True, 35 + text=True, 36 + ) 37 + metadata = json.loads(result.stdout) 38 + target_dir = metadata["target_directory"] 39 + binary_path = os.path.join(target_dir, "debug", "rsequoia") 40 + 41 + assert os.path.isfile(binary_path), ( 42 + f"rsequoia binary not found at {binary_path}. Run `cargo build` first." 43 + ) 44 + 45 + return binary_path 46 + 47 + 48 + @pytest.fixture(scope="session") 49 + def pds_tokens(test_config): 50 + """Get createSession tokens for verification (listRecords, getRecord).""" 51 + resp = requests.post( 52 + f"{test_config['pds']}/xrpc/com.atproto.server.createSession", 53 + json={ 54 + "identifier": test_config["handle"], 55 + "password": test_config["password"], 56 + }, 57 + ) 58 + assert resp.status_code == 200, f"createSession failed: {resp.text}" 59 + data = resp.json() 60 + return {"access_jwt": data["accessJwt"], "did": data["did"]} 61 + 62 + 63 + # -- helpers (importable by tests) ------------------------------------------- 64 + 65 + def list_records(pds_url, access_jwt, did, collection): 66 + """List all records in a collection via XRPC.""" 67 + resp = requests.get( 68 + f"{pds_url}/xrpc/com.atproto.repo.listRecords", 69 + params={"repo": did, "collection": collection, "limit": 100}, 70 + headers={"Authorization": f"Bearer {access_jwt}"}, 71 + ) 72 + assert resp.status_code == 200, f"listRecords failed: {resp.text}" 73 + return resp.json().get("records", []) 74 + 75 + 76 + def delete_record(pds_url, access_jwt, did, collection, rkey): 77 + """Delete a single record via XRPC.""" 78 + resp = requests.post( 79 + f"{pds_url}/xrpc/com.atproto.repo.deleteRecord", 80 + json={"repo": did, "collection": collection, "rkey": rkey}, 81 + headers={"Authorization": f"Bearer {access_jwt}"}, 82 + ) 83 + return resp.status_code == 200 84 + 85 + 86 + def write_post(dir, filename, title, body, extra_fm=""): 87 + """Write a markdown file with YAML frontmatter.""" 88 + content_dir = os.path.join(dir, "content") 89 + os.makedirs(content_dir, exist_ok=True) 90 + with open(os.path.join(content_dir, filename), "w") as f: 91 + f.write(f"---\ntitle: \"{title}\"\ndate: \"2026-05-01\"\n{extra_fm}---\n\n{body}\n") 92 + 93 + 94 + def write_sequoia_json(dir, site_url, publication_uri="", extra=None): 95 + """Write a sequoia.json config file.""" 96 + config = { 97 + "siteUrl": site_url, 98 + "contentDir": "./content", 99 + "pathPrefix": "/", 100 + "publishContent": True, 101 + } 102 + if publication_uri: 103 + config["publicationUri"] = publication_uri 104 + if extra: 105 + config.update(extra) 106 + with open(os.path.join(dir, "sequoia.json"), "w") as f: 107 + json.dump(config, f, indent=2) 108 + 109 + 110 + def run_rsequoia(binary, args, cwd, env_overrides=None): 111 + """Run the rsequoia binary and return the result.""" 112 + env = os.environ.copy() 113 + env["RUST_LOG"] = "info" 114 + if env_overrides: 115 + env.update(env_overrides) 116 + 117 + result = subprocess.run( 118 + [binary] + args, 119 + cwd=cwd, 120 + env=env, 121 + capture_output=True, 122 + text=True, 123 + timeout=30, 124 + ) 125 + return result 126 + 127 + 128 + def unique_suffix(): 129 + """Generate a unique suffix for test isolation.""" 130 + return str(int(time.time() * 1000))
+3
e2e-tests/requirements.txt
··· 1 + pytest 2 + requests 3 + toml
+14
e2e-tests/run.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 5 + REPO_DIR="$(dirname "$SCRIPT_DIR")" 6 + 7 + echo "=== Installing Python dependencies ===" 8 + pip install -q -r "$SCRIPT_DIR/requirements.txt" 9 + 10 + echo "=== Building rsequoia ===" 11 + (cd "$REPO_DIR" && cargo build --quiet) 12 + 13 + echo "=== Running rsequoia E2E tests ===" 14 + (cd "$SCRIPT_DIR" && pytest -v test_publish.py "$@")
+953
e2e-tests/test_publish.py
··· 1 + """End-to-end tests for rsequoia standard.site publishing. 2 + 3 + Tests run against the t1cc PDS and invoke the rsequoia binary as a subprocess. 4 + Results are verified via XRPC API calls using the requests library. 5 + """ 6 + 7 + import json 8 + import os 9 + import tempfile 10 + import shutil 11 + 12 + import pytest 13 + 14 + from conftest import ( 15 + list_records, 16 + delete_record, 17 + write_post, 18 + write_sequoia_json, 19 + run_rsequoia, 20 + unique_suffix, 21 + ) 22 + 23 + 24 + PUBLICATION_COLLECTION = "site.standard.publication" 25 + DOCUMENT_COLLECTION = "site.standard.document" 26 + 27 + 28 + # -- shared state across tests ------------------------------------------------ 29 + # tests run in order and build on each other's state 30 + 31 + class SharedState: 32 + """Mutable state shared across test functions in this module.""" 33 + publication_uri = None 34 + site_dir = None 35 + suffix = None 36 + 37 + 38 + @pytest.fixture(scope="module", autouse=True) 39 + def setup_shared(test_config, pds_tokens): 40 + """Set up shared test state and clean up on exit.""" 41 + SharedState.suffix = unique_suffix() 42 + SharedState.site_dir = tempfile.mkdtemp(prefix="rsequoia-e2e-") 43 + yield 44 + # cleanup temp dir 45 + if SharedState.site_dir: 46 + shutil.rmtree(SharedState.site_dir, ignore_errors=True) 47 + # cleanup records on PDS 48 + pds_url = test_config["pds"] 49 + token = pds_tokens["access_jwt"] 50 + did = pds_tokens["did"] 51 + # delete all document records 52 + docs = list_records(pds_url, token, did, DOCUMENT_COLLECTION) 53 + for doc in docs: 54 + rkey = doc["uri"].rsplit("/", 1)[-1] 55 + delete_record(pds_url, token, did, DOCUMENT_COLLECTION, rkey) 56 + 57 + 58 + def test_init_publication(test_config, cargo_binary, pds_tokens): 59 + """Test: rsequoia init creates a publication record on the PDS.""" 60 + site_dir = SharedState.site_dir 61 + site_url = f"https://test-{SharedState.suffix}.example.com" 62 + 63 + # write a minimal sequoia.json (no publicationUri yet) 64 + write_sequoia_json(site_dir, site_url) 65 + 66 + # run init 67 + result = run_rsequoia( 68 + cargo_binary, 69 + ["init", "--name", f"Test Blog {SharedState.suffix}", "--url", site_url], 70 + cwd=site_dir, 71 + env_overrides={ 72 + "ATP_IDENTIFIER": test_config["handle"], 73 + "ATP_APP_PASSWORD": test_config["password"], 74 + "PDS_URL": test_config["pds"], 75 + }, 76 + ) 77 + assert result.returncode == 0, ( 78 + f"rsequoia init failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" 79 + ) 80 + 81 + # verify sequoia.json was updated with publicationUri 82 + with open(os.path.join(site_dir, "sequoia.json")) as f: 83 + config = json.load(f) 84 + assert "publicationUri" in config, "publicationUri not written to sequoia.json" 85 + assert config["publicationUri"].startswith("at://"), ( 86 + f"invalid publicationUri: {config['publicationUri']}" 87 + ) 88 + SharedState.publication_uri = config["publicationUri"] 89 + 90 + # verify record exists on PDS 91 + records = list_records( 92 + test_config["pds"], pds_tokens["access_jwt"], 93 + pds_tokens["did"], PUBLICATION_COLLECTION, 94 + ) 95 + pub_records = [r for r in records if r["uri"] == SharedState.publication_uri] 96 + assert len(pub_records) == 1, f"publication record not found on PDS" 97 + assert pub_records[0]["value"]["name"] == f"Test Blog {SharedState.suffix}" 98 + assert pub_records[0]["value"]["url"] == site_url 99 + print(f"Publication created: {SharedState.publication_uri}") 100 + 101 + 102 + def test_publish_creates_documents(test_config, cargo_binary, pds_tokens): 103 + """Test: rsequoia publish creates document records for markdown files.""" 104 + site_dir = SharedState.site_dir 105 + 106 + # create content files 107 + write_post(site_dir, "hello.md", "Hello World", "This is my first post.") 108 + write_post(site_dir, "second.md", "Second Post", "Another great post.", 109 + extra_fm='description: "A second post"\ntags:\n - test\n - rsequoia\n') 110 + 111 + # run publish 112 + result = run_rsequoia( 113 + cargo_binary, 114 + ["publish"], 115 + cwd=site_dir, 116 + env_overrides={ 117 + "ATP_IDENTIFIER": test_config["handle"], 118 + "ATP_APP_PASSWORD": test_config["password"], 119 + "PDS_URL": test_config["pds"], 120 + }, 121 + ) 122 + assert result.returncode == 0, ( 123 + f"rsequoia publish failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" 124 + ) 125 + 126 + # verify document records on PDS 127 + records = list_records( 128 + test_config["pds"], pds_tokens["access_jwt"], 129 + pds_tokens["did"], DOCUMENT_COLLECTION, 130 + ) 131 + titles = {r["value"]["title"] for r in records} 132 + assert "Hello World" in titles, f"Hello World not found in records: {titles}" 133 + assert "Second Post" in titles, f"Second Post not found in records: {titles}" 134 + 135 + # verify record fields 136 + hello_record = next(r for r in records if r["value"]["title"] == "Hello World") 137 + assert hello_record["value"]["site"] == SharedState.publication_uri 138 + assert "/hello" in hello_record["value"].get("path", "") 139 + assert "first post" in hello_record["value"].get("textContent", "").lower() 140 + 141 + second_record = next(r for r in records if r["value"]["title"] == "Second Post") 142 + assert second_record["value"].get("description") == "A second post" 143 + assert second_record["value"].get("tags") == ["test", "rsequoia"] 144 + 145 + # verify state file 146 + state_path = os.path.join(site_dir, ".sequoia-state.json") 147 + assert os.path.exists(state_path), ".sequoia-state.json not created" 148 + with open(state_path) as f: 149 + state = json.load(f) 150 + assert len(state) == 2, f"expected 2 entries in state, got {len(state)}" 151 + for path, entry in state.items(): 152 + assert "atUri" in entry 153 + assert "cid" in entry 154 + assert "contentHash" in entry 155 + assert entry["atUri"].startswith("at://") 156 + print(f"Published {len(state)} documents") 157 + 158 + 159 + def test_publish_incremental(test_config, cargo_binary, pds_tokens): 160 + """Test: update, add, and delete documents on subsequent publish.""" 161 + site_dir = SharedState.site_dir 162 + 163 + # modify hello.md (change title) 164 + write_post(site_dir, "hello.md", "Hello Updated", "Updated content.") 165 + 166 + # add a new file 167 + write_post(site_dir, "third.md", "Third Post", "A brand new post.") 168 + 169 + # delete second.md 170 + os.remove(os.path.join(site_dir, "content", "second.md")) 171 + 172 + # run publish 173 + result = run_rsequoia( 174 + cargo_binary, 175 + ["publish"], 176 + cwd=site_dir, 177 + env_overrides={ 178 + "ATP_IDENTIFIER": test_config["handle"], 179 + "ATP_APP_PASSWORD": test_config["password"], 180 + "PDS_URL": test_config["pds"], 181 + }, 182 + ) 183 + assert result.returncode == 0, ( 184 + f"rsequoia publish failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" 185 + ) 186 + 187 + # verify records on PDS 188 + records = list_records( 189 + test_config["pds"], pds_tokens["access_jwt"], 190 + pds_tokens["did"], DOCUMENT_COLLECTION, 191 + ) 192 + titles = {r["value"]["title"] for r in records} 193 + assert "Hello Updated" in titles, f"updated title not found: {titles}" 194 + assert "Third Post" in titles, f"new post not found: {titles}" 195 + assert "Second Post" not in titles, f"deleted post still exists: {titles}" 196 + 197 + # verify state file 198 + with open(os.path.join(site_dir, ".sequoia-state.json")) as f: 199 + state = json.load(f) 200 + assert len(state) == 2 201 + paths = set(state.keys()) 202 + assert "hello.md" in paths 203 + assert "third.md" in paths 204 + assert "second.md" not in paths 205 + print("Incremental publish verified") 206 + 207 + 208 + def test_publish_draft_skipped(test_config, cargo_binary, pds_tokens): 209 + """Test: files with draft: true are not published.""" 210 + site_dir = SharedState.site_dir 211 + 212 + # add a draft post 213 + write_post(site_dir, "draft-post.md", "Draft Post", "Not ready yet.", 214 + extra_fm="draft: true\n") 215 + 216 + # run publish 217 + result = run_rsequoia( 218 + cargo_binary, 219 + ["publish"], 220 + cwd=site_dir, 221 + env_overrides={ 222 + "ATP_IDENTIFIER": test_config["handle"], 223 + "ATP_APP_PASSWORD": test_config["password"], 224 + "PDS_URL": test_config["pds"], 225 + }, 226 + ) 227 + assert result.returncode == 0 228 + 229 + # verify draft not published 230 + records = list_records( 231 + test_config["pds"], pds_tokens["access_jwt"], 232 + pds_tokens["did"], DOCUMENT_COLLECTION, 233 + ) 234 + titles = {r["value"]["title"] for r in records} 235 + assert "Draft Post" not in titles, f"draft was published: {titles}" 236 + 237 + # verify not in state 238 + with open(os.path.join(site_dir, ".sequoia-state.json")) as f: 239 + state = json.load(f) 240 + draft_entries = [p for p in state if "draft" in p] 241 + assert len(draft_entries) == 0, f"draft in state: {draft_entries}" 242 + 243 + # cleanup draft file 244 + os.remove(os.path.join(site_dir, "content", "draft-post.md")) 245 + print("Draft skipping verified") 246 + 247 + 248 + def test_publish_idempotent(test_config, cargo_binary, pds_tokens): 249 + """Test: publishing again with no changes is a no-op.""" 250 + site_dir = SharedState.site_dir 251 + 252 + # read current state 253 + with open(os.path.join(site_dir, ".sequoia-state.json")) as f: 254 + state_before = json.load(f) 255 + 256 + # run publish again 257 + result = run_rsequoia( 258 + cargo_binary, 259 + ["publish"], 260 + cwd=site_dir, 261 + env_overrides={ 262 + "ATP_IDENTIFIER": test_config["handle"], 263 + "ATP_APP_PASSWORD": test_config["password"], 264 + "PDS_URL": test_config["pds"], 265 + }, 266 + ) 267 + assert result.returncode == 0 268 + 269 + # verify state unchanged 270 + with open(os.path.join(site_dir, ".sequoia-state.json")) as f: 271 + state_after = json.load(f) 272 + 273 + assert state_before == state_after, "state changed despite no file changes" 274 + combined_output = result.stdout + result.stderr 275 + assert "0 created, 0 updated, 0 deleted" in combined_output, ( 276 + f"expected no-op publish, got:\nstdout: {result.stdout}\nstderr: {result.stderr}" 277 + ) 278 + print("Idempotent publish verified") 279 + 280 + 281 + def test_inject_links(test_config, cargo_binary, pds_tokens): 282 + """Test: rsequoia inject adds verification link tags to HTML files.""" 283 + site_dir = SharedState.site_dir 284 + 285 + # create a dist directory with sample HTML 286 + dist_dir = os.path.join(site_dir, "dist") 287 + os.makedirs(os.path.join(dist_dir, "hello"), exist_ok=True) 288 + os.makedirs(os.path.join(dist_dir, "third"), exist_ok=True) 289 + 290 + for name in ["hello", "third"]: 291 + html_path = os.path.join(dist_dir, name, "index.html") 292 + with open(html_path, "w") as f: 293 + f.write(f"<html><head><title>{name}</title></head><body>{name}</body></html>") 294 + 295 + # run inject 296 + result = run_rsequoia( 297 + cargo_binary, 298 + ["inject", "--output-dir", dist_dir], 299 + cwd=site_dir, 300 + ) 301 + assert result.returncode == 0, ( 302 + f"rsequoia inject failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" 303 + ) 304 + 305 + # verify link tags were added 306 + for name in ["hello", "third"]: 307 + html_path = os.path.join(dist_dir, name, "index.html") 308 + with open(html_path) as f: 309 + content = f.read() 310 + assert 'rel="site.standard.document"' in content, ( 311 + f"link tag not found in {name}/index.html" 312 + ) 313 + assert "at://" in content 314 + 315 + # verify idempotent — run again 316 + result2 = run_rsequoia( 317 + cargo_binary, 318 + ["inject", "--output-dir", dist_dir], 319 + cwd=site_dir, 320 + ) 321 + assert result2.returncode == 0 322 + 323 + # check no duplicate tags 324 + for name in ["hello", "third"]: 325 + html_path = os.path.join(dist_dir, name, "index.html") 326 + with open(html_path) as f: 327 + content = f.read() 328 + count = content.count('rel="site.standard.document"') 329 + assert count == 1, f"found {count} link tags in {name}/index.html (expected 1)" 330 + 331 + print("Inject verified") 332 + 333 + 334 + def test_oauth_token_auth(test_config, cargo_binary, pds_tokens): 335 + """Test: publishing with OAuth token env vars (no app password). 336 + 337 + Verifies: 338 + - Publish works with RSEQUOIA_ACCESS_TOKEN + RSEQUOIA_DID + RSEQUOIA_PDS_URL 339 + - No ATP_IDENTIFIER / ATP_APP_PASSWORD needed 340 + - Init also works with OAuth tokens 341 + - Records created have correct fields 342 + """ 343 + pds_url = test_config["pds"] 344 + token = pds_tokens["access_jwt"] 345 + did = pds_tokens["did"] 346 + oauth_env = { 347 + "RSEQUOIA_ACCESS_TOKEN": token, 348 + "RSEQUOIA_DID": did, 349 + "RSEQUOIA_PDS_URL": pds_url, 350 + } 351 + 352 + # -- sub-test 1: init with OAuth tokens ----------------------------------- 353 + site_dir = tempfile.mkdtemp(prefix="rsequoia-oauth-init-") 354 + try: 355 + write_sequoia_json(site_dir, "https://oauth-init.example.com") 356 + 357 + result = run_rsequoia( 358 + cargo_binary, 359 + ["init", "--name", "OAuth Init Blog", "--url", "https://oauth-init.example.com"], 360 + cwd=site_dir, 361 + env_overrides=oauth_env, 362 + ) 363 + assert result.returncode == 0, ( 364 + f"OAuth init failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" 365 + ) 366 + 367 + with open(os.path.join(site_dir, "sequoia.json")) as f: 368 + config = json.load(f) 369 + assert config.get("publicationUri", "").startswith("at://"), ( 370 + f"init via OAuth did not set publicationUri" 371 + ) 372 + print(" OAuth init: OK") 373 + finally: 374 + shutil.rmtree(site_dir, ignore_errors=True) 375 + 376 + # -- sub-test 2: publish with OAuth tokens -------------------------------- 377 + # use the publication created by init above, or the shared one from earlier tests 378 + pub_uri = SharedState.publication_uri or f"at://{did}/site.standard.publication/self" 379 + site_dir = tempfile.mkdtemp(prefix="rsequoia-oauth-publish-") 380 + try: 381 + write_sequoia_json( 382 + site_dir, 383 + "https://oauth-publish.example.com", 384 + publication_uri=pub_uri, 385 + ) 386 + write_post(site_dir, "oauth-post.md", "OAuth Post", "Published via OAuth token.") 387 + 388 + result = run_rsequoia( 389 + cargo_binary, 390 + ["publish"], 391 + cwd=site_dir, 392 + env_overrides=oauth_env, 393 + ) 394 + assert result.returncode == 0, ( 395 + f"OAuth publish failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" 396 + ) 397 + 398 + # verify record was created with correct fields 399 + records = list_records(pds_url, token, did, DOCUMENT_COLLECTION) 400 + oauth_records = [r for r in records if r["value"]["title"] == "OAuth Post"] 401 + assert len(oauth_records) == 1, f"OAuth post not found: {[r['value']['title'] for r in records]}" 402 + 403 + val = oauth_records[0]["value"] 404 + assert val["site"] == pub_uri 405 + assert "textContent" in val 406 + assert "oauth-publish.example.com" in val.get("canonicalUrl", "") 407 + print(" OAuth publish: OK") 408 + 409 + # -- sub-test 3: OAuth takes priority over app password --------------- 410 + # set both OAuth AND app password env vars — OAuth should win 411 + result2 = run_rsequoia( 412 + cargo_binary, 413 + ["publish"], 414 + cwd=site_dir, 415 + env_overrides={ 416 + **oauth_env, 417 + "ATP_IDENTIFIER": "should-not-be-used", 418 + "ATP_APP_PASSWORD": "should-not-be-used", 419 + }, 420 + ) 421 + assert result2.returncode == 0, ( 422 + f"OAuth priority test failed (should ignore bad app password):\n" 423 + f"stdout: {result2.stdout}\nstderr: {result2.stderr}" 424 + ) 425 + print(" OAuth priority over app password: OK") 426 + 427 + # cleanup 428 + for r in oauth_records: 429 + rkey = r["uri"].rsplit("/", 1)[-1] 430 + delete_record(pds_url, token, did, DOCUMENT_COLLECTION, rkey) 431 + 432 + finally: 433 + shutil.rmtree(site_dir, ignore_errors=True) 434 + 435 + print("OAuth token auth verified") 436 + 437 + 438 + def test_publication_icon(test_config, cargo_binary, pds_tokens): 439 + """Test: rsequoia init --icon uploads an icon blob for the publication.""" 440 + pds_url = test_config["pds"] 441 + token = pds_tokens["access_jwt"] 442 + did = pds_tokens["did"] 443 + auth_env = { 444 + "ATP_IDENTIFIER": test_config["handle"], 445 + "ATP_APP_PASSWORD": test_config["password"], 446 + "PDS_URL": pds_url, 447 + } 448 + 449 + site_dir = tempfile.mkdtemp(prefix="rsequoia-icon-") 450 + try: 451 + write_sequoia_json(site_dir, "https://icon-test.example.com") 452 + 453 + # create a small PNG icon (1x1 pixel red PNG) 454 + icon_path = os.path.join(site_dir, "icon.png") 455 + # minimal valid PNG: 1x1 red pixel 456 + import struct 457 + import zlib 458 + def make_png(): 459 + sig = b'\x89PNG\r\n\x1a\n' 460 + # IHDR 461 + ihdr_data = struct.pack('>IIBBBBB', 1, 1, 8, 2, 0, 0, 0) 462 + ihdr_crc = zlib.crc32(b'IHDR' + ihdr_data) & 0xffffffff 463 + ihdr = struct.pack('>I', 13) + b'IHDR' + ihdr_data + struct.pack('>I', ihdr_crc) 464 + # IDAT 465 + raw_row = b'\x00\xff\x00\x00' # filter=none, R=255, G=0, B=0 466 + compressed = zlib.compress(raw_row) 467 + idat_crc = zlib.crc32(b'IDAT' + compressed) & 0xffffffff 468 + idat = struct.pack('>I', len(compressed)) + b'IDAT' + compressed + struct.pack('>I', idat_crc) 469 + # IEND 470 + iend_crc = zlib.crc32(b'IEND') & 0xffffffff 471 + iend = struct.pack('>I', 0) + b'IEND' + struct.pack('>I', iend_crc) 472 + return sig + ihdr + idat + iend 473 + with open(icon_path, 'wb') as f: 474 + f.write(make_png()) 475 + 476 + # run init with --icon 477 + result = run_rsequoia( 478 + cargo_binary, 479 + ["init", "--name", "Icon Blog", "--url", "https://icon-test.example.com", 480 + "--icon", icon_path], 481 + cwd=site_dir, 482 + env_overrides=auth_env, 483 + ) 484 + assert result.returncode == 0, ( 485 + f"init with icon failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" 486 + ) 487 + 488 + # verify publication record has icon blob 489 + records = list_records(pds_url, token, did, PUBLICATION_COLLECTION) 490 + # find the one we just created 491 + icon_pubs = [r for r in records if r["value"].get("name") == "Icon Blog"] 492 + assert len(icon_pubs) >= 1, ( 493 + f"Icon publication not found: {[r['value'].get('name') for r in records]}" 494 + ) 495 + val = icon_pubs[0]["value"] 496 + assert "icon" in val, "publication should have an icon field" 497 + icon = val["icon"] 498 + assert icon.get("$type") == "blob", f"icon should be a blob, got: {icon}" 499 + assert icon.get("mimeType") == "image/png", f"icon mime type wrong: {icon.get('mimeType')}" 500 + assert icon.get("size", 0) > 0, "icon size should be > 0" 501 + assert "ref" in icon, "icon should have a ref (CID link)" 502 + 503 + print("Publication icon upload verified") 504 + 505 + finally: 506 + shutil.rmtree(site_dir, ignore_errors=True) 507 + 508 + 509 + def test_cover_image(test_config, cargo_binary, pds_tokens): 510 + """Test: coverImage from frontmatter is uploaded and attached to document records.""" 511 + pds_url = test_config["pds"] 512 + token = pds_tokens["access_jwt"] 513 + did = pds_tokens["did"] 514 + auth_env = { 515 + "ATP_IDENTIFIER": test_config["handle"], 516 + "ATP_APP_PASSWORD": test_config["password"], 517 + "PDS_URL": pds_url, 518 + } 519 + 520 + site_dir = tempfile.mkdtemp(prefix="rsequoia-cover-") 521 + try: 522 + write_sequoia_json( 523 + site_dir, 524 + "https://cover-test.example.com", 525 + publication_uri=SharedState.publication_uri, 526 + extra={ 527 + "frontmatter": { 528 + "title": "title", 529 + "coverImage": "cover", 530 + "publishDate": "date", 531 + }, 532 + }, 533 + ) 534 + 535 + # create a small JPEG image for the cover 536 + content_dir = os.path.join(site_dir, "content") 537 + os.makedirs(content_dir, exist_ok=True) 538 + cover_path = os.path.join(content_dir, "my-cover.jpg") 539 + # minimal valid JPEG: SOI + APP0 + minimal data + EOI 540 + # simplest approach: write JFIF header bytes 541 + jpeg_data = bytes([ 542 + 0xFF, 0xD8, # SOI 543 + 0xFF, 0xE0, # APP0 544 + 0x00, 0x10, # length 16 545 + 0x4A, 0x46, 0x49, 0x46, 0x00, # JFIF\0 546 + 0x01, 0x01, # version 1.1 547 + 0x00, # aspect ratio units 548 + 0x00, 0x01, # x density 549 + 0x00, 0x01, # y density 550 + 0x00, 0x00, # no thumbnail 551 + 0xFF, 0xD9, # EOI 552 + ]) 553 + with open(cover_path, 'wb') as f: 554 + f.write(jpeg_data) 555 + 556 + # write a post with cover field pointing to the image 557 + with open(os.path.join(content_dir, "with-cover.md"), "w") as f: 558 + f.write( 559 + '---\n' 560 + 'title: "Post With Cover"\n' 561 + 'date: "2026-05-01"\n' 562 + 'cover: "my-cover.jpg"\n' 563 + '---\n\n' 564 + 'This post has a cover image.\n' 565 + ) 566 + 567 + # also write a post WITHOUT a cover image 568 + with open(os.path.join(content_dir, "no-cover.md"), "w") as f: 569 + f.write( 570 + '---\n' 571 + 'title: "Post Without Cover"\n' 572 + 'date: "2026-05-01"\n' 573 + '---\n\n' 574 + 'No cover here.\n' 575 + ) 576 + 577 + result = run_rsequoia( 578 + cargo_binary, 579 + ["publish"], 580 + cwd=site_dir, 581 + env_overrides=auth_env, 582 + ) 583 + assert result.returncode == 0, ( 584 + f"Cover image publish failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" 585 + ) 586 + 587 + # verify records 588 + records = list_records(pds_url, token, did, DOCUMENT_COLLECTION) 589 + 590 + # post with cover should have coverImage blob 591 + with_cover = [r for r in records if r["value"]["title"] == "Post With Cover"] 592 + assert len(with_cover) == 1, f"Cover post not found" 593 + val = with_cover[0]["value"] 594 + assert "coverImage" in val, "post should have coverImage field" 595 + cover = val["coverImage"] 596 + assert cover.get("$type") == "blob", f"coverImage should be a blob, got: {cover}" 597 + assert cover.get("mimeType") == "image/jpeg", f"coverImage mime wrong: {cover.get('mimeType')}" 598 + assert cover.get("size", 0) > 0, "coverImage size should be > 0" 599 + print(" Post with coverImage: OK") 600 + 601 + # post without cover should NOT have coverImage 602 + no_cover = [r for r in records if r["value"]["title"] == "Post Without Cover"] 603 + assert len(no_cover) == 1, f"No-cover post not found" 604 + assert "coverImage" not in no_cover[0]["value"], ( 605 + "post without cover should not have coverImage field" 606 + ) 607 + print(" Post without coverImage: OK") 608 + 609 + # cleanup 610 + for r in with_cover + no_cover: 611 + rkey = r["uri"].rsplit("/", 1)[-1] 612 + delete_record(pds_url, token, did, DOCUMENT_COLLECTION, rkey) 613 + 614 + print("Cover image verified") 615 + 616 + finally: 617 + shutil.rmtree(site_dir, ignore_errors=True) 618 + 619 + 620 + def test_frontmatter_mapping(test_config, cargo_binary, pds_tokens): 621 + """Test: custom frontmatter field mappings work correctly. 622 + 623 + Covers: 624 + - Hugo-style field names (summary, lastmod, featured_image) 625 + - Astro-style publishDate (pubDate) 626 + - Custom tags field name 627 + - Custom draft field name 628 + - updatedAt mapping 629 + - Missing optional fields produce no record field (not null) 630 + - Title fallback to first heading when title field is missing 631 + - Default mapping still works for unmapped fields 632 + """ 633 + pds_url = test_config["pds"] 634 + token = pds_tokens["access_jwt"] 635 + did = pds_tokens["did"] 636 + auth_env = { 637 + "ATP_IDENTIFIER": test_config["handle"], 638 + "ATP_APP_PASSWORD": test_config["password"], 639 + "PDS_URL": pds_url, 640 + } 641 + 642 + # -- sub-test 1: Hugo-style mapping (summary, lastmod, tags as categories) -- 643 + site_dir = tempfile.mkdtemp(prefix="rsequoia-hugo-mapping-") 644 + try: 645 + write_sequoia_json( 646 + site_dir, 647 + "https://hugo-mapping.example.com", 648 + publication_uri=SharedState.publication_uri, 649 + extra={ 650 + "frontmatter": { 651 + "title": "title", 652 + "description": "summary", 653 + "publishDate": "date", 654 + "updatedAt": "lastmod", 655 + "draft": "draft", 656 + "tags": "categories", 657 + }, 658 + }, 659 + ) 660 + 661 + content_dir = os.path.join(site_dir, "content") 662 + os.makedirs(content_dir, exist_ok=True) 663 + with open(os.path.join(content_dir, "hugo-post.md"), "w") as f: 664 + f.write( 665 + '---\n' 666 + 'title: "Hugo Style Post"\n' 667 + 'summary: "A post using Hugo conventions"\n' 668 + 'date: "2026-01-10"\n' 669 + 'lastmod: "2026-02-20"\n' 670 + 'categories:\n' 671 + ' - web\n' 672 + ' - hugo\n' 673 + ' - ssg\n' 674 + '---\n\n' 675 + 'Hugo body content here.\n' 676 + ) 677 + 678 + result = run_rsequoia(cargo_binary, ["publish"], cwd=site_dir, env_overrides=auth_env) 679 + assert result.returncode == 0, ( 680 + f"Hugo mapping publish failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" 681 + ) 682 + 683 + records = list_records(pds_url, token, did, DOCUMENT_COLLECTION) 684 + hugo = [r for r in records if r["value"]["title"] == "Hugo Style Post"] 685 + assert len(hugo) == 1, f"Hugo post not found: {[r['value']['title'] for r in records]}" 686 + 687 + val = hugo[0]["value"] 688 + assert val["description"] == "A post using Hugo conventions", ( 689 + f"description mismatch: {val.get('description')}" 690 + ) 691 + assert val["publishedAt"] == "2026-01-10", ( 692 + f"publishedAt mismatch: {val.get('publishedAt')}" 693 + ) 694 + assert val.get("updatedAt") == "2026-02-20", ( 695 + f"updatedAt mismatch: {val.get('updatedAt')}" 696 + ) 697 + assert val.get("tags") == ["web", "hugo", "ssg"], ( 698 + f"tags mismatch: {val.get('tags')}" 699 + ) 700 + print(" Hugo-style mapping: OK") 701 + 702 + # cleanup 703 + rkey = hugo[0]["uri"].rsplit("/", 1)[-1] 704 + delete_record(pds_url, token, did, DOCUMENT_COLLECTION, rkey) 705 + finally: 706 + shutil.rmtree(site_dir, ignore_errors=True) 707 + 708 + # -- sub-test 2: Astro-style pubDate mapping ------------------------------ 709 + site_dir = tempfile.mkdtemp(prefix="rsequoia-astro-mapping-") 710 + try: 711 + write_sequoia_json( 712 + site_dir, 713 + "https://astro-mapping.example.com", 714 + publication_uri=SharedState.publication_uri, 715 + extra={ 716 + "frontmatter": { 717 + "title": "title", 718 + "description": "description", 719 + "publishDate": "pubDate", 720 + "draft": "draft", 721 + "tags": "tags", 722 + }, 723 + }, 724 + ) 725 + 726 + content_dir = os.path.join(site_dir, "content") 727 + os.makedirs(content_dir, exist_ok=True) 728 + with open(os.path.join(content_dir, "astro-post.md"), "w") as f: 729 + f.write( 730 + '---\n' 731 + 'title: "Astro Style Post"\n' 732 + 'pubDate: "2026-04-01"\n' 733 + 'tags:\n' 734 + ' - astro\n' 735 + '---\n\n' 736 + 'Astro body.\n' 737 + ) 738 + 739 + result = run_rsequoia(cargo_binary, ["publish"], cwd=site_dir, env_overrides=auth_env) 740 + assert result.returncode == 0, ( 741 + f"Astro mapping publish failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" 742 + ) 743 + 744 + records = list_records(pds_url, token, did, DOCUMENT_COLLECTION) 745 + astro = [r for r in records if r["value"]["title"] == "Astro Style Post"] 746 + assert len(astro) == 1, f"Astro post not found" 747 + 748 + val = astro[0]["value"] 749 + assert val["publishedAt"] == "2026-04-01", ( 750 + f"pubDate not mapped to publishedAt: {val.get('publishedAt')}" 751 + ) 752 + assert val.get("tags") == ["astro"] 753 + print(" Astro-style pubDate mapping: OK") 754 + 755 + # cleanup 756 + rkey = astro[0]["uri"].rsplit("/", 1)[-1] 757 + delete_record(pds_url, token, did, DOCUMENT_COLLECTION, rkey) 758 + finally: 759 + shutil.rmtree(site_dir, ignore_errors=True) 760 + 761 + # -- sub-test 3: missing optional fields don't produce null --------------- 762 + site_dir = tempfile.mkdtemp(prefix="rsequoia-sparse-mapping-") 763 + try: 764 + write_sequoia_json( 765 + site_dir, 766 + "https://sparse-mapping.example.com", 767 + publication_uri=SharedState.publication_uri, 768 + ) 769 + 770 + content_dir = os.path.join(site_dir, "content") 771 + os.makedirs(content_dir, exist_ok=True) 772 + # post with only title — no description, no date, no tags, no updatedAt 773 + with open(os.path.join(content_dir, "sparse.md"), "w") as f: 774 + f.write( 775 + '---\n' 776 + 'title: "Sparse Post"\n' 777 + '---\n\n' 778 + 'Just a title and body, nothing else.\n' 779 + ) 780 + 781 + result = run_rsequoia(cargo_binary, ["publish"], cwd=site_dir, env_overrides=auth_env) 782 + assert result.returncode == 0, ( 783 + f"Sparse publish failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" 784 + ) 785 + 786 + records = list_records(pds_url, token, did, DOCUMENT_COLLECTION) 787 + sparse = [r for r in records if r["value"]["title"] == "Sparse Post"] 788 + assert len(sparse) == 1, f"Sparse post not found" 789 + 790 + val = sparse[0]["value"] 791 + # description should be absent, not null 792 + assert val.get("description") is None, ( 793 + f"description should be absent, got: {val.get('description')}" 794 + ) 795 + # updatedAt should be absent 796 + assert val.get("updatedAt") is None, ( 797 + f"updatedAt should be absent, got: {val.get('updatedAt')}" 798 + ) 799 + # tags should be absent 800 + assert val.get("tags") is None, ( 801 + f"tags should be absent, got: {val.get('tags')}" 802 + ) 803 + # publishedAt should still be set (falls back to current time) 804 + assert "publishedAt" in val, "publishedAt should always be present" 805 + print(" Missing optional fields: OK") 806 + 807 + # cleanup 808 + rkey = sparse[0]["uri"].rsplit("/", 1)[-1] 809 + delete_record(pds_url, token, did, DOCUMENT_COLLECTION, rkey) 810 + finally: 811 + shutil.rmtree(site_dir, ignore_errors=True) 812 + 813 + # -- sub-test 4: title fallback to first heading -------------------------- 814 + site_dir = tempfile.mkdtemp(prefix="rsequoia-heading-fallback-") 815 + try: 816 + write_sequoia_json( 817 + site_dir, 818 + "https://heading-fallback.example.com", 819 + publication_uri=SharedState.publication_uri, 820 + ) 821 + 822 + content_dir = os.path.join(site_dir, "content") 823 + os.makedirs(content_dir, exist_ok=True) 824 + # no title in frontmatter — should fall back to first # heading 825 + with open(os.path.join(content_dir, "no-title-fm.md"), "w") as f: 826 + f.write( 827 + '---\n' 828 + 'date: "2026-05-01"\n' 829 + '---\n\n' 830 + '# Heading as Title\n\n' 831 + 'Body after heading.\n' 832 + ) 833 + 834 + result = run_rsequoia(cargo_binary, ["publish"], cwd=site_dir, env_overrides=auth_env) 835 + assert result.returncode == 0, ( 836 + f"Heading fallback publish failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" 837 + ) 838 + 839 + records = list_records(pds_url, token, did, DOCUMENT_COLLECTION) 840 + heading = [r for r in records if r["value"]["title"] == "Heading as Title"] 841 + assert len(heading) == 1, ( 842 + f"Heading title not found. Titles: {[r['value']['title'] for r in records]}" 843 + ) 844 + print(" Title fallback to heading: OK") 845 + 846 + # cleanup 847 + rkey = heading[0]["uri"].rsplit("/", 1)[-1] 848 + delete_record(pds_url, token, did, DOCUMENT_COLLECTION, rkey) 849 + finally: 850 + shutil.rmtree(site_dir, ignore_errors=True) 851 + 852 + # -- sub-test 5: custom draft field name ---------------------------------- 853 + site_dir = tempfile.mkdtemp(prefix="rsequoia-draft-field-") 854 + try: 855 + write_sequoia_json( 856 + site_dir, 857 + "https://draft-field.example.com", 858 + publication_uri=SharedState.publication_uri, 859 + extra={ 860 + "frontmatter": { 861 + "title": "title", 862 + "draft": "wip", 863 + "publishDate": "date", 864 + }, 865 + }, 866 + ) 867 + 868 + content_dir = os.path.join(site_dir, "content") 869 + os.makedirs(content_dir, exist_ok=True) 870 + 871 + # published post (wip: false) 872 + with open(os.path.join(content_dir, "published.md"), "w") as f: 873 + f.write( 874 + '---\n' 875 + 'title: "Published via WIP"\n' 876 + 'date: "2026-05-01"\n' 877 + 'wip: false\n' 878 + '---\n\n' 879 + 'Not a draft.\n' 880 + ) 881 + # draft post (wip: true) 882 + with open(os.path.join(content_dir, "wip-draft.md"), "w") as f: 883 + f.write( 884 + '---\n' 885 + 'title: "WIP Draft"\n' 886 + 'date: "2026-05-01"\n' 887 + 'wip: true\n' 888 + '---\n\n' 889 + 'Still working on this.\n' 890 + ) 891 + 892 + result = run_rsequoia(cargo_binary, ["publish"], cwd=site_dir, env_overrides=auth_env) 893 + assert result.returncode == 0, ( 894 + f"Draft field publish failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" 895 + ) 896 + 897 + records = list_records(pds_url, token, did, DOCUMENT_COLLECTION) 898 + titles = {r["value"]["title"] for r in records} 899 + assert "Published via WIP" in titles, ( 900 + f"published post not found: {titles}" 901 + ) 902 + assert "WIP Draft" not in titles, ( 903 + f"wip draft should not be published: {titles}" 904 + ) 905 + print(" Custom draft field name: OK") 906 + 907 + # cleanup 908 + for r in records: 909 + if r["value"]["title"] == "Published via WIP": 910 + rkey = r["uri"].rsplit("/", 1)[-1] 911 + delete_record(pds_url, token, did, DOCUMENT_COLLECTION, rkey) 912 + finally: 913 + shutil.rmtree(site_dir, ignore_errors=True) 914 + 915 + # -- sub-test 6: title fallback to filename when no heading --------------- 916 + site_dir = tempfile.mkdtemp(prefix="rsequoia-filename-fallback-") 917 + try: 918 + write_sequoia_json( 919 + site_dir, 920 + "https://filename-fallback.example.com", 921 + publication_uri=SharedState.publication_uri, 922 + ) 923 + 924 + content_dir = os.path.join(site_dir, "content") 925 + os.makedirs(content_dir, exist_ok=True) 926 + # no title in frontmatter and no heading — should use filename 927 + with open(os.path.join(content_dir, "my-cool-article.md"), "w") as f: 928 + f.write( 929 + '---\n' 930 + 'date: "2026-05-01"\n' 931 + '---\n\n' 932 + 'Just body text, no heading.\n' 933 + ) 934 + 935 + result = run_rsequoia(cargo_binary, ["publish"], cwd=site_dir, env_overrides=auth_env) 936 + assert result.returncode == 0, ( 937 + f"Filename fallback publish failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" 938 + ) 939 + 940 + records = list_records(pds_url, token, did, DOCUMENT_COLLECTION) 941 + filename_titled = [r for r in records if r["value"]["title"] == "my-cool-article"] 942 + assert len(filename_titled) == 1, ( 943 + f"Filename title not found. Titles: {[r['value']['title'] for r in records]}" 944 + ) 945 + print(" Title fallback to filename: OK") 946 + 947 + # cleanup 948 + rkey = filename_titled[0]["uri"].rsplit("/", 1)[-1] 949 + delete_record(pds_url, token, did, DOCUMENT_COLLECTION, rkey) 950 + finally: 951 + shutil.rmtree(site_dir, ignore_errors=True) 952 + 953 + print("All frontmatter mapping tests passed")
+157
src/config.rs
··· 1 + //! Parse `sequoia.json` configuration files. 2 + 3 + use std::path::Path; 4 + 5 + use serde::{Deserialize, Serialize}; 6 + 7 + /// Frontmatter field mapping — maps standard.site fields to the user's 8 + /// frontmatter field names. Values are the frontmatter key to look up. 9 + #[derive(Debug, Clone, Serialize, Deserialize)] 10 + pub struct FrontmatterMapping { 11 + #[serde(default = "default_title")] 12 + pub title: String, 13 + #[serde(default = "default_description")] 14 + pub description: String, 15 + #[serde(rename = "publishDate", default = "default_publish_date")] 16 + pub publish_date: String, 17 + #[serde(rename = "updatedAt", default = "default_updated_at")] 18 + pub updated_at: String, 19 + #[serde(rename = "coverImage", default)] 20 + pub cover_image: String, 21 + #[serde(default = "default_tags")] 22 + pub tags: String, 23 + #[serde(default = "default_draft")] 24 + pub draft: String, 25 + #[serde(rename = "slugField", default)] 26 + pub slug_field: String, 27 + } 28 + 29 + impl Default for FrontmatterMapping { 30 + fn default() -> Self { 31 + Self { 32 + title: default_title(), 33 + description: default_description(), 34 + publish_date: default_publish_date(), 35 + updated_at: default_updated_at(), 36 + cover_image: String::new(), 37 + tags: default_tags(), 38 + draft: default_draft(), 39 + slug_field: String::new(), 40 + } 41 + } 42 + } 43 + 44 + fn default_title() -> String { 45 + "title".to_string() 46 + } 47 + fn default_description() -> String { 48 + "description".to_string() 49 + } 50 + fn default_publish_date() -> String { 51 + "date".to_string() 52 + } 53 + fn default_updated_at() -> String { 54 + "updated".to_string() 55 + } 56 + fn default_tags() -> String { 57 + "tags".to_string() 58 + } 59 + fn default_draft() -> String { 60 + "draft".to_string() 61 + } 62 + 63 + /// Top-level `sequoia.json` configuration. 64 + #[derive(Debug, Clone, Serialize, Deserialize)] 65 + pub struct SequoiaConfig { 66 + #[serde(rename = "siteUrl")] 67 + pub site_url: String, 68 + #[serde(rename = "contentDir")] 69 + pub content_dir: String, 70 + #[serde(rename = "publicationUri", default)] 71 + pub publication_uri: String, 72 + #[serde(rename = "pathPrefix", default)] 73 + pub path_prefix: String, 74 + #[serde(default)] 75 + pub ignore: Vec<String>, 76 + #[serde(rename = "publishContent", default = "default_true")] 77 + pub publish_content: bool, 78 + #[serde(rename = "stripDatePrefix", default)] 79 + pub strip_date_prefix: bool, 80 + #[serde(rename = "outputDir", default)] 81 + pub output_dir: String, 82 + #[serde(default)] 83 + pub frontmatter: FrontmatterMapping, 84 + #[serde(default)] 85 + pub identity: String, 86 + #[serde(rename = "pdsUrl", default)] 87 + pub pds_url: String, 88 + } 89 + 90 + fn default_true() -> bool { 91 + true 92 + } 93 + 94 + /// Loads and parses a `sequoia.json` config file. 95 + pub fn load_config(path: &Path) -> Result<SequoiaConfig, String> { 96 + let content = std::fs::read_to_string(path) 97 + .map_err(|e| format!("failed to read {}: {}", path.display(), e))?; 98 + let config: SequoiaConfig = serde_json::from_str(&content) 99 + .map_err(|e| format!("failed to parse {}: {}", path.display(), e))?; 100 + Ok(config) 101 + } 102 + 103 + /// Writes a `sequoia.json` config file (used by `init` to save publicationUri). 104 + pub fn save_config(path: &Path, config: &SequoiaConfig) -> Result<(), String> { 105 + let content = serde_json::to_string_pretty(config) 106 + .map_err(|e| format!("failed to serialize config: {}", e))?; 107 + std::fs::write(path, content) 108 + .map_err(|e| format!("failed to write {}: {}", path.display(), e))?; 109 + Ok(()) 110 + } 111 + 112 + #[cfg(test)] 113 + mod tests { 114 + use super::*; 115 + 116 + #[test] 117 + fn parse_minimal_config() { 118 + let json = r#"{ 119 + "siteUrl": "https://example.com", 120 + "contentDir": "./content", 121 + "publicationUri": "at://did:plc:abc/site.standard.publication/self" 122 + }"#; 123 + let config: SequoiaConfig = serde_json::from_str(json).unwrap(); 124 + assert_eq!(config.site_url, "https://example.com"); 125 + assert_eq!(config.content_dir, "./content"); 126 + assert!(config.publish_content); 127 + assert_eq!(config.frontmatter.title, "title"); 128 + assert_eq!(config.frontmatter.publish_date, "date"); 129 + } 130 + 131 + #[test] 132 + fn parse_full_config() { 133 + let json = r#"{ 134 + "siteUrl": "https://example.com", 135 + "contentDir": "./content", 136 + "publicationUri": "at://did:plc:abc/site.standard.publication/self", 137 + "pathPrefix": "/posts", 138 + "ignore": ["_index.md"], 139 + "stripDatePrefix": true, 140 + "frontmatter": { 141 + "title": "title", 142 + "description": "summary", 143 + "publishDate": "date", 144 + "updatedAt": "lastmod", 145 + "draft": "draft", 146 + "tags": "tags", 147 + "coverImage": "featured_image" 148 + } 149 + }"#; 150 + let config: SequoiaConfig = serde_json::from_str(json).unwrap(); 151 + assert_eq!(config.frontmatter.description, "summary"); 152 + assert_eq!(config.frontmatter.updated_at, "lastmod"); 153 + assert_eq!(config.frontmatter.cover_image, "featured_image"); 154 + assert!(config.strip_date_prefix); 155 + assert_eq!(config.ignore, vec!["_index.md"]); 156 + } 157 + }
+178
src/credentials.rs
··· 1 + //! Credential loading with priority chain: 2 + //! 3 + //! 1. `RSEQUOIA_ACCESS_TOKEN` + `RSEQUOIA_DID` + `RSEQUOIA_PDS_URL` (OAuth from lichen) 4 + //! 2. `ATP_IDENTIFIER` + `ATP_APP_PASSWORD` + optional `PDS_URL` (app password) 5 + //! 3. Stored credentials from `~/.config/rsequoia/credentials.json` 6 + //! 4. `identity` field in `sequoia.json` 7 + 8 + use std::path::PathBuf; 9 + 10 + use serde::{Deserialize, Serialize}; 11 + 12 + /// Resolved credentials ready for use. 13 + #[derive(Debug, Clone)] 14 + pub enum Credential { 15 + /// Pre-authenticated OAuth token (from lichen). 16 + OAuthToken { 17 + access_token: String, 18 + did: String, 19 + pds_url: String, 20 + }, 21 + /// App password — needs createSession to get a token. 22 + AppPassword { 23 + identifier: String, 24 + password: String, 25 + pds_url: String, 26 + }, 27 + } 28 + 29 + /// Stored credential entry in the credentials file. 30 + #[derive(Debug, Serialize, Deserialize)] 31 + struct StoredCredential { 32 + #[serde(rename = "type")] 33 + cred_type: String, 34 + #[serde(rename = "pdsUrl")] 35 + pds_url: String, 36 + identifier: String, 37 + password: String, 38 + } 39 + 40 + /// Stored credentials file format. 41 + #[derive(Debug, Serialize, Deserialize, Default)] 42 + struct CredentialsFile { 43 + #[serde(flatten)] 44 + credentials: std::collections::HashMap<String, StoredCredential>, 45 + } 46 + 47 + /// Returns the path to the credentials file. 48 + fn credentials_path() -> PathBuf { 49 + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); 50 + PathBuf::from(home) 51 + .join(".config") 52 + .join("rsequoia") 53 + .join("credentials.json") 54 + } 55 + 56 + /// Resolves credentials using the priority chain. 57 + /// 58 + /// `config_identity` is the `identity` field from sequoia.json (lowest priority). 59 + /// `config_pds_url` is the `pdsUrl` field from sequoia.json. 60 + pub fn resolve_credentials( 61 + config_identity: &str, 62 + _config_pds_url: &str, 63 + ) -> Result<Credential, String> { 64 + // priority 1: OAuth token env vars 65 + if let (Ok(token), Ok(did), Ok(pds)) = ( 66 + std::env::var("RSEQUOIA_ACCESS_TOKEN"), 67 + std::env::var("RSEQUOIA_DID"), 68 + std::env::var("RSEQUOIA_PDS_URL"), 69 + ) { 70 + return Ok(Credential::OAuthToken { 71 + access_token: token, 72 + did, 73 + pds_url: pds, 74 + }); 75 + } 76 + 77 + // priority 2: app password env vars 78 + if let (Ok(identifier), Ok(password)) = ( 79 + std::env::var("ATP_IDENTIFIER"), 80 + std::env::var("ATP_APP_PASSWORD"), 81 + ) { 82 + let pds_url = 83 + std::env::var("PDS_URL").unwrap_or_else(|_| "https://bsky.social".to_string()); 84 + return Ok(Credential::AppPassword { 85 + identifier, 86 + password, 87 + pds_url, 88 + }); 89 + } 90 + 91 + // priority 3: stored credentials file 92 + let cred_path = credentials_path(); 93 + if cred_path.exists() { 94 + if let Ok(content) = std::fs::read_to_string(&cred_path) { 95 + if let Ok(file) = serde_json::from_str::<CredentialsFile>(&content) { 96 + // try config identity first, then first available 97 + let key = if !config_identity.is_empty() { 98 + config_identity 99 + } else { 100 + "" 101 + }; 102 + let entry = if !key.is_empty() { 103 + file.credentials.get(key) 104 + } else { 105 + file.credentials.values().next() 106 + }; 107 + if let Some(cred) = entry { 108 + return Ok(Credential::AppPassword { 109 + identifier: cred.identifier.clone(), 110 + password: cred.password.clone(), 111 + pds_url: cred.pds_url.clone(), 112 + }); 113 + } 114 + } 115 + } 116 + } 117 + 118 + // priority 4: identity from config (needs password from somewhere) 119 + if !config_identity.is_empty() { 120 + return Err(format!( 121 + "found identity '{}' in config but no password. \ 122 + Set ATP_APP_PASSWORD or run `rsequoia auth`.", 123 + config_identity 124 + )); 125 + } 126 + 127 + Err( 128 + "no credentials found. Set RSEQUOIA_ACCESS_TOKEN + RSEQUOIA_DID + RSEQUOIA_PDS_URL, \ 129 + or ATP_IDENTIFIER + ATP_APP_PASSWORD, or run `rsequoia auth`." 130 + .to_string(), 131 + ) 132 + } 133 + 134 + /// Saves app password credentials to the credentials file. 135 + pub fn save_credentials(identifier: &str, password: &str, pds_url: &str) -> Result<(), String> { 136 + let cred_path = credentials_path(); 137 + 138 + // create parent directory 139 + if let Some(parent) = cred_path.parent() { 140 + std::fs::create_dir_all(parent) 141 + .map_err(|e| format!("failed to create config dir: {}", e))?; 142 + } 143 + 144 + // load existing or create new 145 + let mut file = if cred_path.exists() { 146 + let content = std::fs::read_to_string(&cred_path) 147 + .map_err(|e| format!("failed to read credentials: {}", e))?; 148 + serde_json::from_str::<CredentialsFile>(&content).unwrap_or_default() 149 + } else { 150 + CredentialsFile::default() 151 + }; 152 + 153 + // insert or update 154 + file.credentials.insert( 155 + identifier.to_string(), 156 + StoredCredential { 157 + cred_type: "app-password".to_string(), 158 + pds_url: pds_url.to_string(), 159 + identifier: identifier.to_string(), 160 + password: password.to_string(), 161 + }, 162 + ); 163 + 164 + let content = serde_json::to_string_pretty(&file) 165 + .map_err(|e| format!("failed to serialize credentials: {}", e))?; 166 + std::fs::write(&cred_path, content) 167 + .map_err(|e| format!("failed to write credentials: {}", e))?; 168 + 169 + // restrict permissions (unix only) 170 + #[cfg(unix)] 171 + { 172 + use std::os::unix::fs::PermissionsExt; 173 + let perms = std::fs::Permissions::from_mode(0o600); 174 + let _ = std::fs::set_permissions(&cred_path, perms); 175 + } 176 + 177 + Ok(()) 178 + }
+304
src/discovery.rs
··· 1 + //! Content directory scanning and post discovery. 2 + 3 + use std::path::Path; 4 + 5 + use globset::{Glob, GlobSet, GlobSetBuilder}; 6 + use sha2::{Digest, Sha256}; 7 + use walkdir::WalkDir; 8 + 9 + use crate::config::SequoiaConfig; 10 + use crate::markdown; 11 + use crate::types::DiscoveredPost; 12 + 13 + /// Scans the content directory and returns all discovered posts. 14 + /// 15 + /// Applies ignore patterns and filters out drafts. 16 + /// Posts are sorted by publish date (newest first). 17 + pub fn discover_posts( 18 + config: &SequoiaConfig, 19 + base_dir: &Path, 20 + ) -> Result<Vec<DiscoveredPost>, String> { 21 + let content_dir = base_dir.join(&config.content_dir); 22 + if !content_dir.exists() { 23 + return Err(format!( 24 + "content directory does not exist: {}", 25 + content_dir.display() 26 + )); 27 + } 28 + 29 + // build ignore globs 30 + let ignore_set = build_ignore_set(&config.ignore)?; 31 + 32 + let mut posts = Vec::new(); 33 + 34 + for entry in WalkDir::new(&content_dir) 35 + .into_iter() 36 + .filter_map(|e| e.ok()) 37 + { 38 + let path = entry.path(); 39 + 40 + // only process markdown files 41 + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); 42 + if ext != "md" && ext != "mdx" { 43 + continue; 44 + } 45 + 46 + // skip directories 47 + if path.is_dir() { 48 + continue; 49 + } 50 + 51 + // get relative path from content_dir 52 + let relative = path 53 + .strip_prefix(&content_dir) 54 + .map_err(|e| format!("failed to get relative path: {}", e))?; 55 + let relative_str = relative.to_string_lossy().to_string(); 56 + 57 + // check ignore patterns 58 + if ignore_set.is_match(&relative_str) { 59 + continue; 60 + } 61 + 62 + // also check just the filename against ignore patterns 63 + let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); 64 + if ignore_set.is_match(filename) { 65 + continue; 66 + } 67 + 68 + // read and parse file 69 + let raw_content = std::fs::read_to_string(path) 70 + .map_err(|e| format!("failed to read {}: {}", path.display(), e))?; 71 + 72 + let (fm, body) = markdown::parse_frontmatter(&raw_content, &config.frontmatter); 73 + 74 + // compute content hash 75 + let mut hasher = Sha256::new(); 76 + hasher.update(raw_content.as_bytes()); 77 + let hash = format!("sha256:{:x}", hasher.finalize()); 78 + 79 + // derive title: frontmatter -> first heading -> filename 80 + let title = fm.title.unwrap_or_else(|| { 81 + // try first heading 82 + for line in body.lines() { 83 + let trimmed = line.trim(); 84 + if trimmed.starts_with("# ") { 85 + return trimmed[2..].trim().to_string(); 86 + } 87 + } 88 + // fall back to filename without extension 89 + path.file_stem() 90 + .and_then(|s| s.to_str()) 91 + .unwrap_or("Untitled") 92 + .to_string() 93 + }); 94 + 95 + posts.push(DiscoveredPost { 96 + relative_path: relative_str, 97 + title, 98 + description: fm.description, 99 + publish_date: fm.publish_date, 100 + updated_at: fm.updated_at, 101 + tags: fm.tags, 102 + cover_image: fm.cover_image, 103 + body, 104 + raw_content, 105 + content_hash: hash, 106 + is_draft: fm.draft, 107 + }); 108 + } 109 + 110 + // sort by publish date (newest first), then by path 111 + posts.sort_by(|a, b| { 112 + let date_cmp = b.publish_date.cmp(&a.publish_date); 113 + if date_cmp == std::cmp::Ordering::Equal { 114 + a.relative_path.cmp(&b.relative_path) 115 + } else { 116 + date_cmp 117 + } 118 + }); 119 + 120 + Ok(posts) 121 + } 122 + 123 + /// Builds a GlobSet from the ignore patterns in the config. 124 + fn build_ignore_set(patterns: &[String]) -> Result<GlobSet, String> { 125 + let mut builder = GlobSetBuilder::new(); 126 + for pattern in patterns { 127 + let glob = Glob::new(pattern) 128 + .map_err(|e| format!("invalid ignore pattern '{}': {}", pattern, e))?; 129 + builder.add(glob); 130 + } 131 + builder 132 + .build() 133 + .map_err(|e| format!("failed to build ignore set: {}", e)) 134 + } 135 + 136 + /// Converts a content-relative path to a slug for use as an AT Protocol rkey. 137 + /// 138 + /// Example: "posts/my-article.md" -> "posts-my-article" 139 + pub fn path_to_rkey(relative_path: &str) -> String { 140 + let mut rkey = relative_path.to_string(); 141 + 142 + // strip .md / .mdx extension 143 + if let Some(stripped) = rkey.strip_suffix(".mdx") { 144 + rkey = stripped.to_string(); 145 + } else if let Some(stripped) = rkey.strip_suffix(".md") { 146 + rkey = stripped.to_string(); 147 + } 148 + 149 + // replace path separators with hyphens 150 + rkey = rkey.replace('/', "-").replace('\\', "-"); 151 + 152 + // enforce AT Protocol rkey charset: [a-zA-Z0-9._~-] 153 + rkey = rkey 154 + .chars() 155 + .map(|c| { 156 + if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '~' || c == '-' { 157 + c 158 + } else { 159 + '-' 160 + } 161 + }) 162 + .collect(); 163 + 164 + // collapse consecutive hyphens 165 + while rkey.contains("--") { 166 + rkey = rkey.replace("--", "-"); 167 + } 168 + 169 + // trim leading/trailing hyphens 170 + rkey = rkey.trim_matches('-').to_string(); 171 + 172 + // truncate to 512 chars (AT Protocol rkey max) 173 + if rkey.len() > 512 { 174 + rkey.truncate(512); 175 + } 176 + 177 + rkey 178 + } 179 + 180 + /// Resolves the URL path for a post based on config settings. 181 + /// 182 + /// Combines pathPrefix with the slug derived from the content path. 183 + pub fn resolve_post_path(config: &SequoiaConfig, relative_path: &str) -> String { 184 + let mut slug = relative_path.to_string(); 185 + 186 + // strip extension 187 + if let Some(stripped) = slug.strip_suffix(".mdx") { 188 + slug = stripped.to_string(); 189 + } else if let Some(stripped) = slug.strip_suffix(".md") { 190 + slug = stripped.to_string(); 191 + } 192 + 193 + // strip date prefix if configured (YYYY-MM-DD-) 194 + if config.strip_date_prefix { 195 + let re_like = slug.chars().take(11).collect::<String>(); 196 + if re_like.len() == 11 197 + && re_like.chars().nth(4) == Some('-') 198 + && re_like.chars().nth(7) == Some('-') 199 + && re_like.chars().nth(10) == Some('-') 200 + { 201 + slug = slug[11..].to_string(); 202 + } 203 + } 204 + 205 + // strip /index suffix 206 + if slug.ends_with("/index") { 207 + slug = slug[..slug.len() - 6].to_string(); 208 + } 209 + if slug == "index" { 210 + slug = String::new(); 211 + } 212 + 213 + // build full path 214 + let prefix = config.path_prefix.trim_end_matches('/'); 215 + if slug.is_empty() { 216 + format!("{}/", prefix) 217 + } else { 218 + format!("{}/{}", prefix, slug) 219 + } 220 + } 221 + 222 + #[cfg(test)] 223 + mod tests { 224 + use super::*; 225 + 226 + #[test] 227 + fn rkey_basic() { 228 + assert_eq!(path_to_rkey("hello.md"), "hello"); 229 + assert_eq!(path_to_rkey("posts/my-article.md"), "posts-my-article"); 230 + assert_eq!( 231 + path_to_rkey("chapter1/section-2/deep.md"), 232 + "chapter1-section-2-deep" 233 + ); 234 + } 235 + 236 + #[test] 237 + fn rkey_special_chars() { 238 + assert_eq!(path_to_rkey("hello world.md"), "hello-world"); 239 + assert_eq!(path_to_rkey("café.md"), "caf"); 240 + } 241 + 242 + #[test] 243 + fn rkey_mdx() { 244 + assert_eq!(path_to_rkey("post.mdx"), "post"); 245 + } 246 + 247 + #[test] 248 + fn post_path_basic() { 249 + let config = SequoiaConfig { 250 + path_prefix: "/".to_string(), 251 + strip_date_prefix: false, 252 + site_url: String::new(), 253 + content_dir: String::new(), 254 + publication_uri: String::new(), 255 + ignore: vec![], 256 + publish_content: true, 257 + output_dir: String::new(), 258 + frontmatter: Default::default(), 259 + identity: String::new(), 260 + pds_url: String::new(), 261 + }; 262 + assert_eq!(resolve_post_path(&config, "hello.md"), "/hello"); 263 + assert_eq!(resolve_post_path(&config, "posts/foo.md"), "/posts/foo"); 264 + } 265 + 266 + #[test] 267 + fn post_path_with_prefix() { 268 + let config = SequoiaConfig { 269 + path_prefix: "/blog".to_string(), 270 + strip_date_prefix: false, 271 + site_url: String::new(), 272 + content_dir: String::new(), 273 + publication_uri: String::new(), 274 + ignore: vec![], 275 + publish_content: true, 276 + output_dir: String::new(), 277 + frontmatter: Default::default(), 278 + identity: String::new(), 279 + pds_url: String::new(), 280 + }; 281 + assert_eq!(resolve_post_path(&config, "hello.md"), "/blog/hello"); 282 + } 283 + 284 + #[test] 285 + fn post_path_strip_date() { 286 + let config = SequoiaConfig { 287 + path_prefix: "/".to_string(), 288 + strip_date_prefix: true, 289 + site_url: String::new(), 290 + content_dir: String::new(), 291 + publication_uri: String::new(), 292 + ignore: vec![], 293 + publish_content: true, 294 + output_dir: String::new(), 295 + frontmatter: Default::default(), 296 + identity: String::new(), 297 + pds_url: String::new(), 298 + }; 299 + assert_eq!( 300 + resolve_post_path(&config, "2026-01-15-my-post.md"), 301 + "/my-post" 302 + ); 303 + } 304 + }
+237
src/inject.rs
··· 1 + //! HTML post-processing to inject standard.site verification `<link>` tags. 2 + 3 + use std::path::Path; 4 + 5 + use crate::publish; 6 + use crate::types::StateMap; 7 + 8 + /// Injects `<link rel="site.standard.document">` verification tags into HTML files. 9 + /// 10 + /// For each entry in the state file, finds the corresponding HTML in `output_dir` 11 + /// and inserts the link tag before `</head>`. Idempotent — skips files that 12 + /// already contain the link. 13 + /// 14 + /// Returns the number of files modified. 15 + pub fn inject_verification_links(base_dir: &Path, output_dir: &Path) -> Result<usize, String> { 16 + let state = publish::load_state(base_dir); 17 + 18 + if state.is_empty() { 19 + tracing::info!("no published posts in state file, nothing to inject"); 20 + return Ok(0); 21 + } 22 + 23 + let mut modified = 0; 24 + 25 + for (_content_path, post_state) in &state { 26 + let at_uri = &post_state.at_uri; 27 + 28 + // derive HTML path from the AT URI rkey 29 + // the rkey encodes the content path, e.g. "posts-my-article" 30 + // we need to find the corresponding HTML file in output_dir 31 + let rkey = at_uri.rsplit('/').next().unwrap_or(""); 32 + 33 + // try common HTML output patterns 34 + let candidates = html_candidates(output_dir, rkey); 35 + 36 + let mut found = false; 37 + for html_path in &candidates { 38 + if html_path.exists() { 39 + match inject_link_into_file(html_path, at_uri) { 40 + Ok(true) => { 41 + tracing::info!("injected link into {}", html_path.display()); 42 + modified += 1; 43 + found = true; 44 + break; 45 + } 46 + Ok(false) => { 47 + // already present 48 + found = true; 49 + break; 50 + } 51 + Err(e) => { 52 + tracing::warn!("failed to inject link into {}: {}", html_path.display(), e); 53 + } 54 + } 55 + } 56 + } 57 + 58 + if !found { 59 + tracing::debug!( 60 + "no HTML file found for rkey '{}' in {}", 61 + rkey, 62 + output_dir.display() 63 + ); 64 + } 65 + } 66 + 67 + Ok(modified) 68 + } 69 + 70 + /// Generates candidate HTML file paths for a given rkey. 71 + /// 72 + /// The rkey uses hyphens where the original path had slashes, 73 + /// e.g. "posts-my-article" could map to: 74 + /// - dist/posts/my-article/index.html 75 + /// - dist/posts/my-article.html 76 + /// - dist/posts-my-article/index.html 77 + /// - dist/posts-my-article.html 78 + fn html_candidates(output_dir: &Path, rkey: &str) -> Vec<std::path::PathBuf> { 79 + let mut candidates = Vec::new(); 80 + 81 + // try treating the first hyphen as a directory separator 82 + // e.g. "posts-my-article" -> "posts/my-article" 83 + let with_slash = rkey.replacen('-', "/", 1); 84 + if with_slash != rkey { 85 + candidates.push(output_dir.join(&with_slash).join("index.html")); 86 + candidates.push(output_dir.join(format!("{}.html", with_slash))); 87 + } 88 + 89 + // try replacing all hyphens with slashes 90 + let all_slashes = rkey.replace('-', "/"); 91 + if all_slashes != with_slash { 92 + candidates.push(output_dir.join(&all_slashes).join("index.html")); 93 + candidates.push(output_dir.join(format!("{}.html", all_slashes))); 94 + } 95 + 96 + // try the rkey directly 97 + candidates.push(output_dir.join(rkey).join("index.html")); 98 + candidates.push(output_dir.join(format!("{}.html", rkey))); 99 + 100 + candidates 101 + } 102 + 103 + /// Injects a `<link>` tag into an HTML file before `</head>`. 104 + /// 105 + /// Returns `Ok(true)` if the file was modified, `Ok(false)` if the link 106 + /// was already present. 107 + fn inject_link_into_file(path: &Path, at_uri: &str) -> Result<bool, String> { 108 + let content = std::fs::read_to_string(path) 109 + .map_err(|e| format!("failed to read {}: {}", path.display(), e))?; 110 + 111 + let link_tag = format!( 112 + "<link rel=\"site.standard.document\" href=\"{}\" />", 113 + at_uri 114 + ); 115 + 116 + // check if already present 117 + if content.contains(&link_tag) { 118 + return Ok(false); 119 + } 120 + 121 + // find </head> (case-insensitive) 122 + let lower = content.to_lowercase(); 123 + let head_pos = lower.find("</head>"); 124 + 125 + let new_content = match head_pos { 126 + Some(pos) => { 127 + let mut result = String::with_capacity(content.len() + link_tag.len() + 5); 128 + result.push_str(&content[..pos]); 129 + result.push_str(" "); 130 + result.push_str(&link_tag); 131 + result.push('\n'); 132 + result.push_str(&content[pos..]); 133 + result 134 + } 135 + None => { 136 + // no </head> found — can't inject 137 + return Err("no </head> tag found".to_string()); 138 + } 139 + }; 140 + 141 + std::fs::write(path, new_content) 142 + .map_err(|e| format!("failed to write {}: {}", path.display(), e))?; 143 + 144 + Ok(true) 145 + } 146 + 147 + /// Injects verification links using the state file and a custom mapping 148 + /// from content paths to HTML file paths. 149 + /// 150 + /// This variant is useful when the caller knows the exact mapping 151 + /// (e.g. from an SSG's render map). 152 + pub fn inject_with_path_map( 153 + state: &StateMap, 154 + path_map: &std::collections::HashMap<String, std::path::PathBuf>, 155 + ) -> Result<usize, String> { 156 + let mut modified = 0; 157 + 158 + for (content_path, post_state) in state { 159 + if let Some(html_path) = path_map.get(content_path) { 160 + if html_path.exists() { 161 + match inject_link_into_file(html_path, &post_state.at_uri) { 162 + Ok(true) => { 163 + modified += 1; 164 + } 165 + Ok(false) => {} 166 + Err(e) => { 167 + tracing::warn!("failed to inject link into {}: {}", html_path.display(), e); 168 + } 169 + } 170 + } 171 + } 172 + } 173 + 174 + Ok(modified) 175 + } 176 + 177 + #[cfg(test)] 178 + mod tests { 179 + use super::*; 180 + use tempfile::tempdir; 181 + 182 + #[test] 183 + fn inject_into_html() { 184 + let dir = tempdir().unwrap(); 185 + let html_path = dir.path().join("test.html"); 186 + std::fs::write( 187 + &html_path, 188 + "<html><head><title>Test</title></head><body></body></html>", 189 + ) 190 + .unwrap(); 191 + 192 + let at_uri = "at://did:plc:abc/site.standard.document/hello"; 193 + let result = inject_link_into_file(&html_path, at_uri).unwrap(); 194 + assert!(result); 195 + 196 + let content = std::fs::read_to_string(&html_path).unwrap(); 197 + assert!(content.contains(&format!("href=\"{}\"", at_uri))); 198 + assert!(content.contains("rel=\"site.standard.document\"")); 199 + } 200 + 201 + #[test] 202 + fn inject_idempotent() { 203 + let dir = tempdir().unwrap(); 204 + let html_path = dir.path().join("test.html"); 205 + let at_uri = "at://did:plc:abc/site.standard.document/hello"; 206 + 207 + std::fs::write( 208 + &html_path, 209 + "<html><head><title>Test</title></head><body></body></html>", 210 + ) 211 + .unwrap(); 212 + 213 + // first injection 214 + let result1 = inject_link_into_file(&html_path, at_uri).unwrap(); 215 + assert!(result1); 216 + 217 + // second injection — should be no-op 218 + let result2 = inject_link_into_file(&html_path, at_uri).unwrap(); 219 + assert!(!result2); 220 + 221 + // verify only one link tag 222 + let content = std::fs::read_to_string(&html_path).unwrap(); 223 + let count = content.matches("rel=\"site.standard.document\"").count(); 224 + assert_eq!(count, 1); 225 + } 226 + 227 + #[test] 228 + fn inject_no_head_tag() { 229 + let dir = tempdir().unwrap(); 230 + let html_path = dir.path().join("test.html"); 231 + std::fs::write(&html_path, "<html><body>no head</body></html>").unwrap(); 232 + 233 + let result = 234 + inject_link_into_file(&html_path, "at://did:plc:abc/site.standard.document/hello"); 235 + assert!(result.is_err()); 236 + } 237 + }
+8
src/lib.rs
··· 1 + pub mod config; 2 + pub mod credentials; 3 + pub mod discovery; 4 + pub mod inject; 5 + pub mod markdown; 6 + pub mod pds_client; 7 + pub mod publish; 8 + pub mod types;
+270
src/main.rs
··· 1 + //! rsequoia — a Rust CLI for publishing standard.site documents to AT Protocol. 2 + 3 + use std::path::PathBuf; 4 + 5 + use clap::{Parser, Subcommand}; 6 + 7 + use rsequoia::config; 8 + use rsequoia::credentials::{self, Credential}; 9 + use rsequoia::inject; 10 + use rsequoia::pds_client::PdsClient; 11 + use rsequoia::publish; 12 + 13 + #[derive(Parser)] 14 + #[command( 15 + name = "rsequoia", 16 + about = "Publish standard.site documents to AT Protocol" 17 + )] 18 + struct Cli { 19 + #[command(subcommand)] 20 + command: Command, 21 + } 22 + 23 + #[derive(Subcommand)] 24 + enum Command { 25 + /// Authenticate with a PDS and store credentials. 26 + Auth { 27 + /// AT Protocol handle (e.g. alice.bsky.social). 28 + #[arg(long)] 29 + handle: String, 30 + /// Account password or app password. 31 + #[arg(long)] 32 + password: String, 33 + /// PDS URL (defaults to https://bsky.social). 34 + #[arg(long, default_value = "https://bsky.social")] 35 + pds_url: String, 36 + }, 37 + 38 + /// Create a publication record and write publicationUri to sequoia.json. 39 + Init { 40 + /// Publication name. 41 + #[arg(long)] 42 + name: String, 43 + /// Site URL. 44 + #[arg(long)] 45 + url: String, 46 + /// Optional description. 47 + #[arg(long)] 48 + description: Option<String>, 49 + /// Optional icon image path (square, at least 256x256). 50 + #[arg(long)] 51 + icon: Option<PathBuf>, 52 + /// Path to sequoia.json (defaults to ./sequoia.json). 53 + #[arg(long, default_value = "sequoia.json")] 54 + config: PathBuf, 55 + }, 56 + 57 + /// Publish documents to AT Protocol. 58 + Publish { 59 + /// Path to sequoia.json (defaults to ./sequoia.json). 60 + #[arg(long, default_value = "sequoia.json")] 61 + config: PathBuf, 62 + /// Show what would change without writing records. 63 + #[arg(long)] 64 + dry_run: bool, 65 + }, 66 + 67 + /// Inject verification link tags into HTML output files. 68 + Inject { 69 + /// Path to sequoia.json (defaults to ./sequoia.json). 70 + #[arg(long, default_value = "sequoia.json")] 71 + config: PathBuf, 72 + /// Output directory containing HTML files (overrides config outputDir). 73 + #[arg(long)] 74 + output_dir: Option<PathBuf>, 75 + }, 76 + } 77 + 78 + #[tokio::main] 79 + async fn main() { 80 + tracing_subscriber::fmt() 81 + .with_env_filter( 82 + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), 83 + ) 84 + .with_target(false) 85 + .init(); 86 + 87 + let cli = Cli::parse(); 88 + 89 + let result = match cli.command { 90 + Command::Auth { 91 + handle, 92 + password, 93 + pds_url, 94 + } => cmd_auth(&handle, &password, &pds_url).await, 95 + Command::Init { 96 + name, 97 + url, 98 + description, 99 + icon, 100 + config, 101 + } => { 102 + cmd_init( 103 + &name, 104 + &url, 105 + description.as_deref(), 106 + icon.as_deref(), 107 + &config, 108 + ) 109 + .await 110 + } 111 + Command::Publish { config, dry_run } => cmd_publish(&config, dry_run).await, 112 + Command::Inject { config, output_dir } => cmd_inject(&config, output_dir.as_deref()), 113 + }; 114 + 115 + if let Err(e) = result { 116 + tracing::error!("{}", e); 117 + std::process::exit(1); 118 + } 119 + } 120 + 121 + /// Authenticates and stores credentials. 122 + async fn cmd_auth(handle: &str, password: &str, pds_url: &str) -> Result<(), String> { 123 + let mut client = PdsClient::new(pds_url); 124 + let session = client.login(handle, password).await?; 125 + 126 + credentials::save_credentials(handle, password, pds_url)?; 127 + 128 + tracing::info!("authenticated as {} ({})", session.handle, session.did); 129 + Ok(()) 130 + } 131 + 132 + /// Creates a publication record and writes publicationUri to config. 133 + async fn cmd_init( 134 + name: &str, 135 + url: &str, 136 + description: Option<&str>, 137 + icon_path: Option<&std::path::Path>, 138 + config_path: &std::path::Path, 139 + ) -> Result<(), String> { 140 + // resolve credentials 141 + let cred = credentials::resolve_credentials("", "")?; 142 + 143 + let (client, did) = authenticate(cred).await?; 144 + 145 + // build publication record 146 + let mut record = serde_json::json!({ 147 + "$type": "site.standard.publication", 148 + "url": url, 149 + "name": name, 150 + "createdAt": chrono::Utc::now().to_rfc3339(), 151 + }); 152 + if let Some(desc) = description { 153 + record["description"] = serde_json::Value::String(desc.to_string()); 154 + } 155 + 156 + // upload icon if provided 157 + if let Some(icon) = icon_path { 158 + if !icon.exists() { 159 + return Err(format!("icon file not found: {}", icon.display())); 160 + } 161 + let data = std::fs::read(icon).map_err(|e| format!("failed to read icon: {}", e))?; 162 + let mime = publish::mime_from_extension(icon); 163 + let blob_ref = client.upload_blob(data, &mime).await?; 164 + record["icon"] = serde_json::to_value(&blob_ref).unwrap_or_default(); 165 + tracing::info!("uploaded icon from {}", icon.display()); 166 + } 167 + 168 + let resp = client 169 + .put_record(&did, "site.standard.publication", "self", record) 170 + .await?; 171 + 172 + tracing::info!("publication created: {}", resp.uri); 173 + 174 + // update or create sequoia.json with publicationUri 175 + if config_path.exists() { 176 + let mut cfg = config::load_config(config_path)?; 177 + cfg.publication_uri = resp.uri.clone(); 178 + config::save_config(config_path, &cfg)?; 179 + } else { 180 + let cfg = config::SequoiaConfig { 181 + site_url: url.to_string(), 182 + content_dir: "./content".to_string(), 183 + publication_uri: resp.uri.clone(), 184 + path_prefix: "/".to_string(), 185 + ignore: vec![], 186 + publish_content: true, 187 + strip_date_prefix: false, 188 + output_dir: String::new(), 189 + frontmatter: Default::default(), 190 + identity: String::new(), 191 + pds_url: String::new(), 192 + }; 193 + config::save_config(config_path, &cfg)?; 194 + } 195 + 196 + tracing::info!("publicationUri written to {}", config_path.display()); 197 + Ok(()) 198 + } 199 + 200 + /// Publishes documents to AT Protocol. 201 + async fn cmd_publish(config_path: &std::path::Path, dry_run: bool) -> Result<(), String> { 202 + let cfg = config::load_config(config_path)?; 203 + let base_dir = config_path.parent().unwrap_or(std::path::Path::new(".")); 204 + 205 + // resolve credentials 206 + let cred = credentials::resolve_credentials(&cfg.identity, &cfg.pds_url)?; 207 + let (client, did) = authenticate(cred).await?; 208 + 209 + let result = publish::publish(&client, &did, &cfg, base_dir, dry_run).await?; 210 + 211 + if dry_run { 212 + tracing::info!("dry run: {}", result); 213 + } else { 214 + tracing::info!("publish complete: {}", result); 215 + } 216 + 217 + Ok(()) 218 + } 219 + 220 + /// Injects verification links into HTML files. 221 + fn cmd_inject( 222 + config_path: &std::path::Path, 223 + output_dir_override: Option<&std::path::Path>, 224 + ) -> Result<(), String> { 225 + let cfg = config::load_config(config_path)?; 226 + let base_dir = config_path.parent().unwrap_or(std::path::Path::new(".")); 227 + 228 + // determine output directory 229 + let output_dir = if let Some(dir) = output_dir_override { 230 + dir.to_path_buf() 231 + } else if !cfg.output_dir.is_empty() { 232 + base_dir.join(&cfg.output_dir) 233 + } else { 234 + base_dir.join("dist") 235 + }; 236 + 237 + if !output_dir.exists() { 238 + return Err(format!( 239 + "output directory does not exist: {}", 240 + output_dir.display() 241 + )); 242 + } 243 + 244 + let modified = inject::inject_verification_links(base_dir, &output_dir)?; 245 + tracing::info!("injected verification links into {} files", modified); 246 + Ok(()) 247 + } 248 + 249 + /// Authenticates using a resolved credential, returning a PdsClient and DID. 250 + async fn authenticate(cred: Credential) -> Result<(PdsClient, String), String> { 251 + match cred { 252 + Credential::OAuthToken { 253 + access_token, 254 + did, 255 + pds_url, 256 + } => { 257 + let client = PdsClient::with_token(&pds_url, &access_token); 258 + Ok((client, did)) 259 + } 260 + Credential::AppPassword { 261 + identifier, 262 + password, 263 + pds_url, 264 + } => { 265 + let mut client = PdsClient::new(&pds_url); 266 + let session = client.login(&identifier, &password).await?; 267 + Ok((client, session.did)) 268 + } 269 + } 270 + }
+283
src/markdown.rs
··· 1 + //! Frontmatter parsing and markdown-to-plain-text extraction. 2 + 3 + use crate::config::FrontmatterMapping; 4 + 5 + /// Parsed frontmatter fields. 6 + #[derive(Debug, Default)] 7 + pub struct ParsedFrontmatter { 8 + pub title: Option<String>, 9 + pub description: Option<String>, 10 + pub publish_date: Option<String>, 11 + pub updated_at: Option<String>, 12 + pub tags: Option<Vec<String>>, 13 + pub cover_image: Option<String>, 14 + pub draft: bool, 15 + } 16 + 17 + /// Parses YAML frontmatter from a markdown file and returns (frontmatter, body). 18 + /// 19 + /// Supports `---` delimiters for YAML frontmatter. 20 + pub fn parse_frontmatter( 21 + content: &str, 22 + mapping: &FrontmatterMapping, 23 + ) -> (ParsedFrontmatter, String) { 24 + let trimmed = content.trim_start(); 25 + 26 + // check for frontmatter delimiters 27 + if !trimmed.starts_with("---") { 28 + return (ParsedFrontmatter::default(), content.to_string()); 29 + } 30 + 31 + // find the closing delimiter 32 + let after_opening = &trimmed[3..]; 33 + let rest = after_opening.trim_start_matches(['\r', '\n']); 34 + let closing = rest.find("\n---"); 35 + 36 + let (yaml_str, body) = match closing { 37 + Some(pos) => { 38 + let yaml = &rest[..pos]; 39 + let after_close = &rest[pos + 4..]; // skip \n--- 40 + let body = after_close.trim_start_matches(['\r', '\n']); 41 + (yaml, body.to_string()) 42 + } 43 + None => { 44 + // no closing delimiter — treat entire content as body 45 + return (ParsedFrontmatter::default(), content.to_string()); 46 + } 47 + }; 48 + 49 + // parse YAML into a map 50 + let yaml_map: serde_yaml::Value = match serde_yaml::from_str(yaml_str) { 51 + Ok(v) => v, 52 + Err(_) => return (ParsedFrontmatter::default(), body), 53 + }; 54 + 55 + let map = match yaml_map.as_mapping() { 56 + Some(m) => m, 57 + None => return (ParsedFrontmatter::default(), body), 58 + }; 59 + 60 + // extract fields using the mapping 61 + let get_str = |key: &str| -> Option<String> { 62 + if key.is_empty() { 63 + return None; 64 + } 65 + let val = map.get(serde_yaml::Value::String(key.to_string()))?; 66 + match val { 67 + serde_yaml::Value::String(s) => Some(s.clone()), 68 + // handle numeric/bool values that YAML might parse 69 + other => Some(format!("{}", serde_yaml::to_string(other).ok()?.trim())), 70 + } 71 + }; 72 + 73 + let get_bool = |key: &str| -> bool { 74 + if key.is_empty() { 75 + return false; 76 + } 77 + map.get(serde_yaml::Value::String(key.to_string())) 78 + .and_then(|v| v.as_bool()) 79 + .unwrap_or(false) 80 + }; 81 + 82 + let get_tags = |key: &str| -> Option<Vec<String>> { 83 + if key.is_empty() { 84 + return None; 85 + } 86 + let val = map.get(serde_yaml::Value::String(key.to_string()))?; 87 + match val { 88 + serde_yaml::Value::Sequence(seq) => { 89 + let tags: Vec<String> = seq 90 + .iter() 91 + .filter_map(|v| v.as_str().map(|s| s.to_string())) 92 + .collect(); 93 + if tags.is_empty() { None } else { Some(tags) } 94 + } 95 + _ => None, 96 + } 97 + }; 98 + 99 + let fm = ParsedFrontmatter { 100 + title: get_str(&mapping.title), 101 + description: get_str(&mapping.description), 102 + publish_date: get_str(&mapping.publish_date), 103 + updated_at: get_str(&mapping.updated_at), 104 + tags: get_tags(&mapping.tags), 105 + cover_image: get_str(&mapping.cover_image), 106 + draft: get_bool(&mapping.draft), 107 + }; 108 + 109 + (fm, body) 110 + } 111 + 112 + /// Strips markdown formatting to produce plain text. 113 + /// 114 + /// Simple approach: remove common markdown syntax elements. 115 + /// Caps output at 10,000 characters (standard.site textContent limit). 116 + pub fn strip_markdown_to_text(markdown: &str) -> String { 117 + let mut result = String::with_capacity(markdown.len()); 118 + 119 + for line in markdown.lines() { 120 + let trimmed = line.trim(); 121 + 122 + // skip empty lines (collapse into single newline) 123 + if trimmed.is_empty() { 124 + if !result.ends_with('\n') { 125 + result.push('\n'); 126 + } 127 + continue; 128 + } 129 + 130 + // strip heading markers 131 + let line = if trimmed.starts_with('#') { 132 + trimmed.trim_start_matches('#').trim_start() 133 + } else { 134 + trimmed 135 + }; 136 + 137 + // strip bold/italic markers 138 + let line = line.replace("**", "").replace("__", ""); 139 + let line = line.replace('*', "").replace('_', ""); 140 + 141 + // strip inline code 142 + let line = line.replace('`', ""); 143 + 144 + // strip link syntax [text](url) -> text 145 + let line = strip_links(&line); 146 + 147 + // strip image syntax ![alt](url) -> alt 148 + let line = strip_images(&line); 149 + 150 + result.push_str(&line); 151 + result.push('\n'); 152 + } 153 + 154 + let result = result.trim().to_string(); 155 + 156 + // cap at 10,000 chars 157 + if result.len() > 10_000 { 158 + result[..10_000].to_string() 159 + } else { 160 + result 161 + } 162 + } 163 + 164 + /// Strips markdown link syntax: [text](url) -> text 165 + fn strip_links(text: &str) -> String { 166 + let mut result = String::with_capacity(text.len()); 167 + let mut chars = text.chars().peekable(); 168 + 169 + while let Some(c) = chars.next() { 170 + if c == '[' { 171 + // collect link text 172 + let mut link_text = String::new(); 173 + let mut found_close = false; 174 + for inner in chars.by_ref() { 175 + if inner == ']' { 176 + found_close = true; 177 + break; 178 + } 179 + link_text.push(inner); 180 + } 181 + if found_close && chars.peek() == Some(&'(') { 182 + // skip the (url) part 183 + chars.next(); // consume '(' 184 + let mut depth = 1; 185 + for inner in chars.by_ref() { 186 + if inner == '(' { 187 + depth += 1; 188 + } else if inner == ')' { 189 + depth -= 1; 190 + if depth == 0 { 191 + break; 192 + } 193 + } 194 + } 195 + result.push_str(&link_text); 196 + } else { 197 + // not a valid link, keep as-is 198 + result.push('['); 199 + result.push_str(&link_text); 200 + if found_close { 201 + result.push(']'); 202 + } 203 + } 204 + } else { 205 + result.push(c); 206 + } 207 + } 208 + 209 + result 210 + } 211 + 212 + /// Strips markdown image syntax: ![alt](url) -> alt 213 + fn strip_images(text: &str) -> String { 214 + text.replace("![", "[") // then strip_links handles it 215 + } 216 + 217 + #[cfg(test)] 218 + mod tests { 219 + use super::*; 220 + 221 + #[test] 222 + fn parse_yaml_frontmatter() { 223 + let content = "---\ntitle: \"Hello World\"\ndate: \"2026-05-01\"\ndraft: false\n---\n\nBody text here."; 224 + let mapping = FrontmatterMapping::default(); 225 + let (fm, body) = parse_frontmatter(content, &mapping); 226 + assert_eq!(fm.title.as_deref(), Some("Hello World")); 227 + assert_eq!(fm.publish_date.as_deref(), Some("2026-05-01")); 228 + assert!(!fm.draft); 229 + assert_eq!(body, "Body text here."); 230 + } 231 + 232 + #[test] 233 + fn parse_draft_frontmatter() { 234 + let content = "---\ntitle: Draft Post\ndraft: true\n---\n\nDraft body."; 235 + let mapping = FrontmatterMapping::default(); 236 + let (fm, _body) = parse_frontmatter(content, &mapping); 237 + assert!(fm.draft); 238 + } 239 + 240 + #[test] 241 + fn parse_tags() { 242 + let content = "---\ntitle: Tagged\ntags:\n - rust\n - atproto\n---\n\nBody."; 243 + let mapping = FrontmatterMapping::default(); 244 + let (fm, _body) = parse_frontmatter(content, &mapping); 245 + assert_eq!( 246 + fm.tags, 247 + Some(vec!["rust".to_string(), "atproto".to_string()]) 248 + ); 249 + } 250 + 251 + #[test] 252 + fn no_frontmatter() { 253 + let content = "Just some regular markdown."; 254 + let mapping = FrontmatterMapping::default(); 255 + let (fm, body) = parse_frontmatter(content, &mapping); 256 + assert!(fm.title.is_none()); 257 + assert_eq!(body, content); 258 + } 259 + 260 + #[test] 261 + fn strip_markdown() { 262 + let md = "# Hello\n\nThis is **bold** and *italic*.\n\n[link](https://example.com)\n"; 263 + let text = strip_markdown_to_text(md); 264 + assert!(text.contains("Hello")); 265 + assert!(text.contains("This is bold and italic.")); 266 + assert!(text.contains("link")); 267 + assert!(!text.contains("https://example.com")); 268 + assert!(!text.contains("**")); 269 + } 270 + 271 + #[test] 272 + fn custom_field_mapping() { 273 + let content = "---\ntitle: Hugo Post\nsummary: A hugo post\ndate: 2026-01-01\nlastmod: 2026-02-01\n---\n\nBody."; 274 + let mapping = FrontmatterMapping { 275 + description: "summary".to_string(), 276 + updated_at: "lastmod".to_string(), 277 + ..FrontmatterMapping::default() 278 + }; 279 + let (fm, _body) = parse_frontmatter(content, &mapping); 280 + assert_eq!(fm.description.as_deref(), Some("A hugo post")); 281 + assert_eq!(fm.updated_at.as_deref(), Some("2026-02-01")); 282 + } 283 + }
+233
src/pds_client.rs
··· 1 + //! HTTP client for PDS XRPC API. 2 + //! 3 + //! Supports Bearer token auth (from createSession or OAuth pass-through). 4 + //! Provides the XRPC calls needed for standard.site record management. 5 + 6 + use crate::types::*; 7 + 8 + /// Client for interacting with a PDS server over XRPC. 9 + pub struct PdsClient { 10 + base_url: String, 11 + auth_token: Option<String>, 12 + http: reqwest::Client, 13 + } 14 + 15 + impl PdsClient { 16 + /// Creates an unauthenticated client. 17 + pub fn new(base_url: impl Into<String>) -> Self { 18 + Self { 19 + base_url: base_url.into().trim_end_matches('/').to_string(), 20 + auth_token: None, 21 + http: reqwest::Client::new(), 22 + } 23 + } 24 + 25 + /// Creates a client with a pre-existing Bearer token. 26 + pub fn with_token(base_url: impl Into<String>, token: impl Into<String>) -> Self { 27 + Self { 28 + base_url: base_url.into().trim_end_matches('/').to_string(), 29 + auth_token: Some(token.into()), 30 + http: reqwest::Client::new(), 31 + } 32 + } 33 + 34 + /// Logs in with identifier/password and stores the resulting access token. 35 + /// Returns the session response (includes DID). 36 + pub async fn login( 37 + &mut self, 38 + identifier: &str, 39 + password: &str, 40 + ) -> Result<CreateSessionResponse, String> { 41 + let url = format!("{}/xrpc/com.atproto.server.createSession", self.base_url); 42 + let body = serde_json::json!({ 43 + "identifier": identifier, 44 + "password": password, 45 + }); 46 + 47 + let resp = self 48 + .http 49 + .post(&url) 50 + .json(&body) 51 + .send() 52 + .await 53 + .map_err(|e| format!("createSession request failed: {}", e))?; 54 + 55 + if !resp.status().is_success() { 56 + let status = resp.status(); 57 + let text = resp.text().await.unwrap_or_default(); 58 + return Err(format!("createSession failed ({}): {}", status, text)); 59 + } 60 + 61 + let session: CreateSessionResponse = resp 62 + .json() 63 + .await 64 + .map_err(|e| format!("failed to parse createSession response: {}", e))?; 65 + 66 + self.auth_token = Some(session.access_jwt.clone()); 67 + Ok(session) 68 + } 69 + 70 + /// Returns the current DID (extracted from token, not stored separately). 71 + /// For OAuth tokens passed via env, the DID comes from the credential. 72 + pub fn base_url(&self) -> &str { 73 + &self.base_url 74 + } 75 + 76 + /// Builds an authenticated request. 77 + fn authed_request(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder { 78 + let mut req = self.http.request(method, url); 79 + if let Some(ref token) = self.auth_token { 80 + req = req.bearer_auth(token); 81 + } 82 + req 83 + } 84 + 85 + /// Creates or updates a record via `com.atproto.repo.putRecord`. 86 + pub async fn put_record( 87 + &self, 88 + repo: &str, 89 + collection: &str, 90 + rkey: &str, 91 + record: serde_json::Value, 92 + ) -> Result<PutRecordResponse, String> { 93 + let url = format!("{}/xrpc/com.atproto.repo.putRecord", self.base_url); 94 + let body = serde_json::json!({ 95 + "repo": repo, 96 + "collection": collection, 97 + "rkey": rkey, 98 + "record": record, 99 + }); 100 + 101 + let resp = self 102 + .authed_request(reqwest::Method::POST, &url) 103 + .json(&body) 104 + .send() 105 + .await 106 + .map_err(|e| format!("putRecord request failed: {}", e))?; 107 + 108 + if !resp.status().is_success() { 109 + let status = resp.status(); 110 + let text = resp.text().await.unwrap_or_default(); 111 + return Err(format!( 112 + "putRecord failed for {}/{} ({}): {}", 113 + collection, rkey, status, text 114 + )); 115 + } 116 + 117 + resp.json() 118 + .await 119 + .map_err(|e| format!("failed to parse putRecord response: {}", e)) 120 + } 121 + 122 + /// Deletes a record via `com.atproto.repo.deleteRecord`. 123 + pub async fn delete_record( 124 + &self, 125 + repo: &str, 126 + collection: &str, 127 + rkey: &str, 128 + ) -> Result<(), String> { 129 + let url = format!("{}/xrpc/com.atproto.repo.deleteRecord", self.base_url); 130 + let body = serde_json::json!({ 131 + "repo": repo, 132 + "collection": collection, 133 + "rkey": rkey, 134 + }); 135 + 136 + let resp = self 137 + .authed_request(reqwest::Method::POST, &url) 138 + .json(&body) 139 + .send() 140 + .await 141 + .map_err(|e| format!("deleteRecord request failed: {}", e))?; 142 + 143 + if !resp.status().is_success() { 144 + let status = resp.status(); 145 + let text = resp.text().await.unwrap_or_default(); 146 + return Err(format!( 147 + "deleteRecord failed for {}/{} ({}): {}", 148 + collection, rkey, status, text 149 + )); 150 + } 151 + 152 + Ok(()) 153 + } 154 + 155 + /// Lists records in a collection via `com.atproto.repo.listRecords`. 156 + pub async fn list_records( 157 + &self, 158 + repo: &str, 159 + collection: &str, 160 + ) -> Result<Vec<ListRecordEntry>, String> { 161 + let mut all_records = Vec::new(); 162 + let mut cursor: Option<String> = None; 163 + 164 + loop { 165 + let mut url = format!( 166 + "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit=100", 167 + self.base_url, repo, collection, 168 + ); 169 + if let Some(ref c) = cursor { 170 + url.push_str(&format!("&cursor={}", c)); 171 + } 172 + 173 + let resp = self 174 + .authed_request(reqwest::Method::GET, &url) 175 + .send() 176 + .await 177 + .map_err(|e| format!("listRecords request failed: {}", e))?; 178 + 179 + if !resp.status().is_success() { 180 + let status = resp.status(); 181 + let text = resp.text().await.unwrap_or_default(); 182 + return Err(format!( 183 + "listRecords failed for {} ({}): {}", 184 + collection, status, text 185 + )); 186 + } 187 + 188 + let page: ListRecordsResponse = resp 189 + .json() 190 + .await 191 + .map_err(|e| format!("failed to parse listRecords response: {}", e))?; 192 + 193 + all_records.extend(page.records); 194 + 195 + match page.cursor { 196 + Some(c) if !c.is_empty() => cursor = Some(c), 197 + _ => break, 198 + } 199 + } 200 + 201 + Ok(all_records) 202 + } 203 + 204 + /// Uploads a blob via `com.atproto.repo.uploadBlob`. 205 + pub async fn upload_blob(&self, data: Vec<u8>, mime_type: &str) -> Result<BlobRef, String> { 206 + let url = format!("{}/xrpc/com.atproto.repo.uploadBlob", self.base_url); 207 + 208 + let resp = self 209 + .authed_request(reqwest::Method::POST, &url) 210 + .header("Content-Type", mime_type) 211 + .body(data) 212 + .send() 213 + .await 214 + .map_err(|e| format!("uploadBlob request failed: {}", e))?; 215 + 216 + if !resp.status().is_success() { 217 + let status = resp.status(); 218 + let text = resp.text().await.unwrap_or_default(); 219 + return Err(format!("uploadBlob failed ({}): {}", status, text)); 220 + } 221 + 222 + #[derive(serde::Deserialize)] 223 + struct UploadResponse { 224 + blob: BlobRef, 225 + } 226 + let upload: UploadResponse = resp 227 + .json() 228 + .await 229 + .map_err(|e| format!("failed to parse uploadBlob response: {}", e))?; 230 + 231 + Ok(upload.blob) 232 + } 233 + }
+331
src/publish.rs
··· 1 + //! Diff-and-sync logic for publishing standard.site records. 2 + 3 + use std::collections::HashMap; 4 + use std::path::Path; 5 + 6 + use crate::config::SequoiaConfig; 7 + use crate::discovery; 8 + use crate::markdown; 9 + use crate::pds_client::PdsClient; 10 + use crate::types::*; 11 + 12 + /// Result of a publish operation. 13 + #[derive(Debug, Default)] 14 + pub struct PublishResult { 15 + pub created: usize, 16 + pub updated: usize, 17 + pub deleted: usize, 18 + pub skipped: usize, 19 + } 20 + 21 + impl std::fmt::Display for PublishResult { 22 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 23 + write!( 24 + f, 25 + "{} created, {} updated, {} deleted, {} unchanged", 26 + self.created, self.updated, self.deleted, self.skipped 27 + ) 28 + } 29 + } 30 + 31 + /// Loads the state file from disk. 32 + pub fn load_state(base_dir: &Path) -> StateMap { 33 + let state_path = base_dir.join(".sequoia-state.json"); 34 + if !state_path.exists() { 35 + return StateMap::new(); 36 + } 37 + match std::fs::read_to_string(&state_path) { 38 + Ok(content) => serde_json::from_str(&content).unwrap_or_default(), 39 + Err(_) => StateMap::new(), 40 + } 41 + } 42 + 43 + /// Saves the state file to disk. 44 + fn save_state(base_dir: &Path, state: &StateMap) -> Result<(), String> { 45 + let state_path = base_dir.join(".sequoia-state.json"); 46 + let content = serde_json::to_string_pretty(state) 47 + .map_err(|e| format!("failed to serialize state: {}", e))?; 48 + std::fs::write(&state_path, content) 49 + .map_err(|e| format!("failed to write state file: {}", e))?; 50 + Ok(()) 51 + } 52 + 53 + /// Ensures a publication record exists on the PDS. 54 + /// 55 + /// If `publication_uri` is set, verifies it exists. Otherwise creates one with rkey "self". 56 + pub async fn ensure_publication( 57 + client: &PdsClient, 58 + did: &str, 59 + config: &SequoiaConfig, 60 + ) -> Result<String, String> { 61 + if !config.publication_uri.is_empty() { 62 + return Ok(config.publication_uri.clone()); 63 + } 64 + 65 + // create a new publication record 66 + let record = serde_json::json!({ 67 + "$type": "site.standard.publication", 68 + "url": config.site_url, 69 + "name": config.site_url, 70 + "createdAt": chrono::Utc::now().to_rfc3339(), 71 + }); 72 + 73 + let resp = client 74 + .put_record(did, "site.standard.publication", "self", record) 75 + .await?; 76 + 77 + Ok(resp.uri) 78 + } 79 + 80 + /// Publishes standard.site records by diffing local posts against tracked state. 81 + /// 82 + /// Creates, updates, or deletes `site.standard.document` records as needed. 83 + pub async fn publish( 84 + client: &PdsClient, 85 + did: &str, 86 + config: &SequoiaConfig, 87 + base_dir: &Path, 88 + dry_run: bool, 89 + ) -> Result<PublishResult, String> { 90 + // ensure publication exists 91 + let publication_uri = ensure_publication(client, did, config).await?; 92 + 93 + // discover posts 94 + let all_posts = discovery::discover_posts(config, base_dir)?; 95 + 96 + // filter out drafts 97 + let posts: Vec<_> = all_posts.into_iter().filter(|p| !p.is_draft).collect(); 98 + 99 + // load current state 100 + let state = load_state(base_dir); 101 + 102 + // build map of current posts by relative path 103 + let mut local_posts: HashMap<String, &DiscoveredPost> = HashMap::new(); 104 + for post in &posts { 105 + local_posts.insert(post.relative_path.clone(), post); 106 + } 107 + 108 + let mut result = PublishResult::default(); 109 + let mut new_state = StateMap::new(); 110 + 111 + // process each local post: create or update 112 + for post in &posts { 113 + let rkey = discovery::path_to_rkey(&post.relative_path); 114 + let existing = state.get(&post.relative_path); 115 + 116 + // check if content changed 117 + let needs_update = match existing { 118 + Some(prev) => prev.content_hash != post.content_hash, 119 + None => true, 120 + }; 121 + 122 + if !needs_update { 123 + // unchanged — keep existing state 124 + if let Some(prev) = existing { 125 + new_state.insert(post.relative_path.clone(), prev.clone()); 126 + } 127 + result.skipped += 1; 128 + continue; 129 + } 130 + 131 + let post_path = discovery::resolve_post_path(config, &post.relative_path); 132 + let now = chrono::Utc::now().to_rfc3339(); 133 + 134 + // build document record (only standard.site fields) 135 + let mut record = serde_json::json!({ 136 + "$type": "site.standard.document", 137 + "title": post.title, 138 + "site": publication_uri, 139 + "path": post_path, 140 + "publishedAt": post.publish_date.as_deref().unwrap_or(&now), 141 + }); 142 + 143 + // add optional fields 144 + if let Some(ref desc) = post.description { 145 + record["description"] = serde_json::Value::String(desc.clone()); 146 + } 147 + if let Some(ref updated) = post.updated_at { 148 + record["updatedAt"] = serde_json::Value::String(updated.clone()); 149 + } 150 + if let Some(ref tags) = post.tags { 151 + record["tags"] = serde_json::to_value(tags).unwrap_or_default(); 152 + } 153 + 154 + // upload and attach coverImage if specified in frontmatter 155 + if !dry_run { 156 + if let Some(ref cover_path) = post.cover_image { 157 + let content_dir = base_dir.join(&config.content_dir); 158 + // try relative to content dir first, then site dir 159 + let image_path = if content_dir.join(cover_path).exists() { 160 + content_dir.join(cover_path) 161 + } else { 162 + base_dir.join(cover_path) 163 + }; 164 + if image_path.exists() { 165 + let data = std::fs::read(&image_path).map_err(|e| { 166 + format!("failed to read cover image {}: {}", image_path.display(), e) 167 + })?; 168 + let mime = mime_from_extension(&image_path); 169 + match client.upload_blob(data, &mime).await { 170 + Ok(blob_ref) => { 171 + record["coverImage"] = 172 + serde_json::to_value(&blob_ref).unwrap_or_default(); 173 + } 174 + Err(e) => { 175 + tracing::warn!( 176 + "failed to upload cover image for {}: {}", 177 + post.relative_path, 178 + e 179 + ); 180 + } 181 + } 182 + } else { 183 + tracing::warn!( 184 + "cover image not found for {}: {}", 185 + post.relative_path, 186 + cover_path 187 + ); 188 + } 189 + } 190 + } 191 + 192 + // add textContent (stripped markdown) 193 + if config.publish_content { 194 + let text = markdown::strip_markdown_to_text(&post.body); 195 + if !text.is_empty() { 196 + record["textContent"] = serde_json::Value::String(text); 197 + } 198 + } 199 + 200 + // add canonicalUrl 201 + let canonical = format!("{}{}", config.site_url.trim_end_matches('/'), post_path); 202 + record["canonicalUrl"] = serde_json::Value::String(canonical); 203 + 204 + if dry_run { 205 + if existing.is_some() { 206 + tracing::info!("would update: {} (rkey: {})", post.relative_path, rkey); 207 + result.updated += 1; 208 + } else { 209 + tracing::info!("would create: {} (rkey: {})", post.relative_path, rkey); 210 + result.created += 1; 211 + } 212 + // preserve or create placeholder state for dry-run 213 + if let Some(prev) = existing { 214 + new_state.insert(post.relative_path.clone(), prev.clone()); 215 + } 216 + continue; 217 + } 218 + 219 + // write record to PDS 220 + let resp = client 221 + .put_record(did, "site.standard.document", &rkey, record) 222 + .await?; 223 + 224 + let publish_date = post.publish_date.clone().unwrap_or_else(|| now.clone()); 225 + 226 + new_state.insert( 227 + post.relative_path.clone(), 228 + PostState { 229 + at_uri: resp.uri, 230 + cid: resp.cid, 231 + content_hash: post.content_hash.clone(), 232 + published_at: publish_date, 233 + }, 234 + ); 235 + 236 + if existing.is_some() { 237 + tracing::info!("updated: {} (rkey: {})", post.relative_path, rkey); 238 + result.updated += 1; 239 + } else { 240 + tracing::info!("created: {} (rkey: {})", post.relative_path, rkey); 241 + result.created += 1; 242 + } 243 + } 244 + 245 + // delete records for posts that no longer exist locally 246 + for (path, prev_state) in &state { 247 + if !local_posts.contains_key(path) { 248 + let rkey = discovery::path_to_rkey(path); 249 + 250 + if dry_run { 251 + tracing::info!("would delete: {} (rkey: {})", path, rkey); 252 + result.deleted += 1; 253 + continue; 254 + } 255 + 256 + match client 257 + .delete_record(did, "site.standard.document", &rkey) 258 + .await 259 + { 260 + Ok(()) => { 261 + tracing::info!("deleted: {} (rkey: {})", path, rkey); 262 + result.deleted += 1; 263 + } 264 + Err(e) => { 265 + tracing::warn!("failed to delete {}: {}", path, e); 266 + // keep in state so we retry next time 267 + new_state.insert(path.clone(), prev_state.clone()); 268 + } 269 + } 270 + } 271 + } 272 + 273 + // save state 274 + if !dry_run { 275 + save_state(base_dir, &new_state)?; 276 + } 277 + 278 + Ok(result) 279 + } 280 + 281 + /// Guesses MIME type from file extension. 282 + pub fn mime_from_extension(path: &Path) -> String { 283 + match path 284 + .extension() 285 + .and_then(|e| e.to_str()) 286 + .unwrap_or("") 287 + .to_lowercase() 288 + .as_str() 289 + { 290 + "png" => "image/png", 291 + "jpg" | "jpeg" => "image/jpeg", 292 + "gif" => "image/gif", 293 + "webp" => "image/webp", 294 + "svg" => "image/svg+xml", 295 + "ico" => "image/x-icon", 296 + _ => "application/octet-stream", 297 + } 298 + .to_string() 299 + } 300 + 301 + /// Uploads an icon image and creates/updates a publication record with it. 302 + pub async fn upload_icon_for_publication( 303 + client: &PdsClient, 304 + did: &str, 305 + config: &SequoiaConfig, 306 + icon_path: &Path, 307 + ) -> Result<(), String> { 308 + if !icon_path.exists() { 309 + return Err(format!("icon file not found: {}", icon_path.display())); 310 + } 311 + 312 + let data = std::fs::read(icon_path).map_err(|e| format!("failed to read icon: {}", e))?; 313 + let mime = mime_from_extension(icon_path); 314 + 315 + let blob_ref = client.upload_blob(data, &mime).await?; 316 + 317 + let record = serde_json::json!({ 318 + "$type": "site.standard.publication", 319 + "url": config.site_url, 320 + "name": config.site_url, 321 + "createdAt": chrono::Utc::now().to_rfc3339(), 322 + "icon": blob_ref, 323 + }); 324 + 325 + client 326 + .put_record(did, "site.standard.publication", "self", record) 327 + .await?; 328 + 329 + tracing::info!("publication icon uploaded from {}", icon_path.display()); 330 + Ok(()) 331 + }
+173
src/types.rs
··· 1 + //! Standard.site record types and state tracking structs. 2 + 3 + use std::collections::HashMap; 4 + 5 + use serde::{Deserialize, Serialize}; 6 + 7 + // -- AT Protocol blob reference ----------------------------------------------- 8 + 9 + /// Blob reference returned by uploadBlob, used in coverImage and icon fields. 10 + #[derive(Debug, Clone, Serialize, Deserialize)] 11 + pub struct BlobRef { 12 + #[serde(rename = "$type")] 13 + pub blob_type: String, 14 + #[serde(rename = "ref")] 15 + pub link: CidLink, 16 + #[serde(rename = "mimeType")] 17 + pub mime_type: String, 18 + pub size: u64, 19 + } 20 + 21 + /// CID link wrapper used inside blob references. 22 + #[derive(Debug, Clone, Serialize, Deserialize)] 23 + pub struct CidLink { 24 + #[serde(rename = "$link")] 25 + pub link: String, 26 + } 27 + 28 + // -- standard.site records ---------------------------------------------------- 29 + 30 + /// `site.standard.publication` record — one per site. 31 + #[derive(Debug, Clone, Serialize, Deserialize)] 32 + pub struct PublicationRecord { 33 + #[serde(rename = "$type")] 34 + pub record_type: String, 35 + pub url: String, 36 + pub name: String, 37 + #[serde(skip_serializing_if = "Option::is_none")] 38 + pub description: Option<String>, 39 + #[serde(skip_serializing_if = "Option::is_none")] 40 + pub icon: Option<BlobRef>, 41 + #[serde(rename = "createdAt")] 42 + pub created_at: String, 43 + #[serde(skip_serializing_if = "Option::is_none")] 44 + pub preferences: Option<PublicationPreferences>, 45 + } 46 + 47 + /// Optional preferences on a publication record. 48 + #[derive(Debug, Clone, Serialize, Deserialize)] 49 + pub struct PublicationPreferences { 50 + #[serde(rename = "showInDiscover", skip_serializing_if = "Option::is_none")] 51 + pub show_in_discover: Option<bool>, 52 + } 53 + 54 + /// `site.standard.document` record — one per published page. 55 + #[derive(Debug, Clone, Serialize, Deserialize)] 56 + pub struct DocumentRecord { 57 + #[serde(rename = "$type")] 58 + pub record_type: String, 59 + pub title: String, 60 + /// AT URI of the publication this document belongs to. 61 + pub site: String, 62 + #[serde(skip_serializing_if = "Option::is_none")] 63 + pub path: Option<String>, 64 + #[serde(rename = "textContent", skip_serializing_if = "Option::is_none")] 65 + pub text_content: Option<String>, 66 + #[serde(rename = "canonicalUrl", skip_serializing_if = "Option::is_none")] 67 + pub canonical_url: Option<String>, 68 + #[serde(rename = "publishedAt")] 69 + pub published_at: String, 70 + #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")] 71 + pub updated_at: Option<String>, 72 + #[serde(skip_serializing_if = "Option::is_none")] 73 + pub description: Option<String>, 74 + #[serde(rename = "coverImage", skip_serializing_if = "Option::is_none")] 75 + pub cover_image: Option<BlobRef>, 76 + #[serde(skip_serializing_if = "Option::is_none")] 77 + pub tags: Option<Vec<String>>, 78 + } 79 + 80 + // -- state tracking ----------------------------------------------------------- 81 + 82 + /// Per-file publish state, stored in `.sequoia-state.json`. 83 + #[derive(Debug, Clone, Serialize, Deserialize)] 84 + pub struct PostState { 85 + #[serde(rename = "atUri")] 86 + pub at_uri: String, 87 + pub cid: String, 88 + #[serde(rename = "contentHash")] 89 + pub content_hash: String, 90 + #[serde(rename = "publishedAt")] 91 + pub published_at: String, 92 + } 93 + 94 + /// The full state file mapping content paths to their publish state. 95 + pub type StateMap = HashMap<String, PostState>; 96 + 97 + // -- discovered post ---------------------------------------------------------- 98 + 99 + /// A post discovered from the content directory, with parsed metadata. 100 + #[derive(Debug, Clone)] 101 + pub struct DiscoveredPost { 102 + /// path relative to content_dir, e.g. "hello.md" 103 + pub relative_path: String, 104 + /// extracted title 105 + pub title: String, 106 + /// extracted description 107 + pub description: Option<String>, 108 + /// publish date as ISO 8601 109 + pub publish_date: Option<String>, 110 + /// updated date as ISO 8601 111 + pub updated_at: Option<String>, 112 + /// tags list 113 + pub tags: Option<Vec<String>>, 114 + /// markdown body with frontmatter stripped 115 + pub body: String, 116 + /// raw file content (for hashing) 117 + pub raw_content: String, 118 + /// SHA-256 hash of raw file content 119 + pub content_hash: String, 120 + /// cover image path (relative to content dir or site dir) 121 + pub cover_image: Option<String>, 122 + /// whether this post is a draft 123 + pub is_draft: bool, 124 + } 125 + 126 + // -- PDS XRPC response types ------------------------------------------------- 127 + 128 + /// Response from `com.atproto.repo.putRecord`. 129 + #[derive(Debug, Deserialize)] 130 + pub struct PutRecordResponse { 131 + pub uri: String, 132 + pub cid: String, 133 + } 134 + 135 + /// Response from `com.atproto.server.createSession`. 136 + #[derive(Debug, Deserialize)] 137 + pub struct CreateSessionResponse { 138 + pub did: String, 139 + #[serde(rename = "accessJwt")] 140 + pub access_jwt: String, 141 + #[serde(rename = "refreshJwt")] 142 + pub refresh_jwt: String, 143 + pub handle: String, 144 + } 145 + 146 + /// A single record entry from `com.atproto.repo.listRecords`. 147 + #[derive(Debug, Deserialize)] 148 + pub struct ListRecordEntry { 149 + pub uri: String, 150 + pub cid: String, 151 + pub value: serde_json::Value, 152 + } 153 + 154 + /// Response from `com.atproto.repo.listRecords`. 155 + #[derive(Debug, Deserialize)] 156 + pub struct ListRecordsResponse { 157 + pub records: Vec<ListRecordEntry>, 158 + pub cursor: Option<String>, 159 + } 160 + 161 + /// Error response from PDS XRPC endpoints. 162 + #[derive(Debug, Deserialize)] 163 + pub struct XrpcError { 164 + pub error: String, 165 + #[serde(default)] 166 + pub message: String, 167 + } 168 + 169 + impl std::fmt::Display for XrpcError { 170 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 171 + write!(f, "{}: {}", self.error, self.message) 172 + } 173 + }