Compare commits
1393 Commits
kibo-v0.5.0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 17478a4ac4 | |||
| b3031b3375 | |||
| 2e401379a0 | |||
| 45ab6ebf71 | |||
| 00f7d69ea6 | |||
| 408d83c350 | |||
| 43df9e098c | |||
| 0c7861071d | |||
| 6f430bdb8c | |||
| 4b415b215d | |||
| 8614e9eded | |||
| c85da92cbc | |||
| 297fc3b855 | |||
| c9d5a62fcb | |||
| 90b3b51c48 | |||
| 5966ab05e4 | |||
| c3506339cd | |||
| e54843291e | |||
| b0b261fe9f | |||
| 6786be296d | |||
| e5068bbbf3 | |||
| 36cfe49b20 | |||
| 33cc13708a | |||
| 2389632812 | |||
| e0bcdb8105 | |||
| 45e83c98b9 | |||
| 753bbf3e7e | |||
| 54cc0cb446 | |||
| d64dcb75a9 | |||
| f599115f6c | |||
| 9fc45625ad | |||
| c68d1d1fda | |||
| 6cbe09af23 | |||
| 96d35d1d29 | |||
| e23554811b | |||
| 041c542046 | |||
| 66dc7cd8f5 | |||
| b00692249c | |||
| ff2c04a100 | |||
| 7cee0e2c5a | |||
| 744032f1f1 | |||
| 99b171bad6 | |||
| 37e2b6eae2 | |||
| a967fe8f35 | |||
| a3f3c54675 | |||
| f41874f438 | |||
| 98bbfec525 | |||
| 1bcf3235b6 | |||
| 07734b8bab | |||
| a2fd1e03ad | |||
| 90e8741fb7 | |||
| 5f5563fece | |||
| c7edfce481 | |||
| 7b3dd83b93 | |||
| cae16227fd | |||
| dc2ca0ca27 | |||
| d161462137 | |||
| be20633945 | |||
| 2bbc535b58 | |||
| 88c38e74f9 | |||
| a61b76a4a5 | |||
| 46b888337c | |||
| 4b49a04186 | |||
| 15b0cd2445 | |||
| 76720434d7 | |||
| 200cd1011e | |||
| cb9f277d49 | |||
| 102933b406 | |||
| e64ffac8d1 | |||
| a94d31dfdf | |||
| 087a3b6fd6 | |||
| 7181d59966 | |||
| 3b7734a61a | |||
| 7860c5a8bd | |||
| 5df399d2f7 | |||
| b2345db279 | |||
| 7e2fc8b455 | |||
| c1ff095e4b | |||
| cc8fde59e8 | |||
| e43b53b429 | |||
| 6938204a24 | |||
| 52883bbdba | |||
| 100495fdba | |||
| 0ad5be6974 | |||
| 66037c862f | |||
| ee20175cbf | |||
| 7ad0adf659 | |||
| 6219d2301d | |||
| 0aaffc6c43 | |||
| 9c74881c5d | |||
| bf8de73541 | |||
| 56e8103178 | |||
| 773c0d090b | |||
| d6f4c0ac19 | |||
| 0552ba60d2 | |||
| ff056587f7 | |||
| 0b871e8600 | |||
| 0bdca9086a | |||
| bbab864ed9 | |||
| d1b328e658 | |||
| df0a482f8d | |||
| 6ff43c0f74 | |||
| 4daabcee2c | |||
| a6021b26cc | |||
| 1a706da13c | |||
| 20c4a113c9 | |||
| e5819769e8 | |||
| 421e5286ce | |||
| 68db22b9e8 | |||
| 90aca2e048 | |||
| 528c134f26 | |||
| 5cc3fbfa6e | |||
| 8fc2e71492 | |||
| 445c60a6f1 | |||
| dd6eca138b | |||
| 774580ee11 | |||
| fe5f30bca6 | |||
| c52a076bfc | |||
| e62b0ac2a5 | |||
| 3f2b5d3084 | |||
| aab16f8832 | |||
| a9c0a09191 | |||
| 948a7cdd88 | |||
| 25b2268563 | |||
| dd88996f7f | |||
| 1643cf86ed | |||
| 6e8be1af22 | |||
| 9d18e2db9b | |||
| d2b8992932 | |||
| f4910efd7d | |||
| 1b39d21bbe | |||
| cc9ebfaf42 | |||
| 9347b42c9a | |||
| cb74087f27 | |||
| 086bfd9938 | |||
| da7671744f | |||
| abcb238022 | |||
| dc32bd480f | |||
| 4663d13194 | |||
| 9cb5f2c880 | |||
| 2b8a0a8cf7 | |||
| 6f879a5551 | |||
| 1068ad4e8f | |||
| 9b42b40a36 | |||
| 43f3be4924 | |||
| a7e41df1c6 | |||
| f1749472e7 | |||
| 66494c081c | |||
| 6c8afc942c | |||
| 1dcbbd801b | |||
| 76869ed2b6 | |||
| b24bfdc15c | |||
| e543e4a5db | |||
| 9b639ef7d1 | |||
| 07bc2d42b8 | |||
| 7a0b4b5890 | |||
| 2210443e37 | |||
| 8bf6570843 | |||
| 26a3b0f5e8 | |||
| 741c957f31 | |||
| e4496742a4 | |||
| ce00de5da8 | |||
| f5c50e69fc | |||
| 9709c2040d | |||
| 3faa989691 | |||
| 84e924b77e | |||
| c5b16e7048 | |||
| c1335cec31 | |||
| bdc3ba1df6 | |||
| 6afce0bbdc | |||
| 327873d010 | |||
| 08175009d2 | |||
| a5d3be465e | |||
| fd2b93367d | |||
| 2a93f51e81 | |||
| 008143ff00 | |||
| d340855c8b | |||
| 78d6d9d6f1 | |||
| 5cc85b0619 | |||
| 7433ce0d0e | |||
| 75a97b4da9 | |||
| c23e0f2a3c | |||
| 08ba4ad996 | |||
| 39da441d14 | |||
| 904ec93668 | |||
| 4cd8d9eb56 | |||
| 283baca848 | |||
| 765261648d | |||
| c3cef71aa3 | |||
| 18d9c166d8 | |||
| 286256ebf0 | |||
| 12aae503c9 | |||
| 95e5168244 | |||
| 5fd9fff9cf | |||
| db5b3887f9 | |||
| 5a3e1b4e6e | |||
| 21a0226a19 | |||
| c5c49f62d1 | |||
| dac66c988d | |||
| 303d168681 | |||
| 1ddb3385e2 | |||
| eb75274dbf | |||
| 3a7887348c | |||
| 0a4cb0601f | |||
| 861e29277c | |||
| c76b149ef9 | |||
| 4c4c6fc840 | |||
| 0c14dfe924 | |||
| 17e531b4ee | |||
| f022f62cce | |||
| e91f1386b1 | |||
| 02f543af38 | |||
| 20c96fb551 | |||
| acd3d6f425 | |||
| 2b15a24b6d | |||
| 7fac0bc613 | |||
| 62f51761ee | |||
| 5340cc288e | |||
| befe3c8fb7 | |||
| 41ec24c81e | |||
| 42b497ff65 | |||
| 01d908a560 | |||
| 42debcce80 | |||
| 8bc993eceb | |||
| 366ac33e23 | |||
| b5a7023bd3 | |||
| 883b38c77c | |||
| 59c767a9e2 | |||
| 9b5bb848f7 | |||
| 5bf06530ce | |||
| 768e6870cb | |||
| 79829ddd53 | |||
| 78082801c6 | |||
| 50771ddccc | |||
| 3a8a9ddecc | |||
| 6cd45c1f1f | |||
| 1a2db43cf5 | |||
| 4840e564f4 | |||
| 744dce932c | |||
| 8dfc1bc932 | |||
| d92cf43c57 | |||
| 099699872e | |||
| 5099903043 | |||
| 982fe47a33 | |||
| 65d5fadd13 | |||
| b55f5255ad | |||
| 83edef4806 | |||
| d4936d889a | |||
| c938cc8eae | |||
| 0558834eef | |||
| 098950fdde | |||
| 91e68a1d1e | |||
| 7172ddb247 | |||
| 96f2e058f7 | |||
| 8782944191 | |||
| ae26db6df2 | |||
| d038141a8a | |||
| f6960c61d6 | |||
| 07fa2d2c9a | |||
| 82c6d69a0b | |||
| d4dc1b9e49 | |||
| 24d2b7b142 | |||
| b6e56c4e9f | |||
| 45c77a4c3b | |||
| 09af190ac0 | |||
| d24f3691cb | |||
| daaaa15483 | |||
| 041652d85d | |||
| 17570e12b8 | |||
| 78172734db | |||
| 19d4a193ff | |||
| 66680368b6 | |||
| b4ded21ea3 | |||
| 7412373d8a | |||
| 259960b80b | |||
| 18bb4186a8 | |||
| 6d3307c0df | |||
| 6eea20b89a | |||
| 5077cefda8 | |||
| 14d7adfdd5 | |||
| 000027fab8 | |||
| ade23795b8 | |||
| 67ad33b07a | |||
| d54874d3a4 | |||
| ec6420254a | |||
| 74fff13d18 | |||
| 0d2deb1b63 | |||
| c4c0004c4a | |||
| a59cdfef7c | |||
| f495451b34 | |||
| c53c6560fa | |||
| d6def7643d | |||
| fef7a24951 | |||
| 514b0513de | |||
| 514fdc40ee | |||
| f731f0d9d0 | |||
| fbff230c86 | |||
| fdaa5032a9 | |||
| ef491a3a66 | |||
| 926721c482 | |||
| 8859de5393 | |||
| 2991562234 | |||
| b45c6ec05f | |||
| 4b3aaee03b | |||
| 1ed4f258b4 | |||
| 485f118a5f | |||
| 573336ed80 | |||
| 143aa90b18 | |||
| b807b50a64 | |||
| 147a3c7593 | |||
| a7bbfda799 | |||
| f683adba13 | |||
| 17106f887a | |||
| 8f93ff9f68 | |||
| 1d671ea41f | |||
| b8e57f4788 | |||
| 19bd17566f | |||
| 2ce6a7cee2 | |||
| 45de61b438 | |||
| 8910c0988e | |||
| 1e68c160a1 | |||
| 2df9ee4a1d | |||
| b18cca92ab | |||
| d8b55340f7 | |||
| 92e1a0ccaf | |||
| 24f344c0b1 | |||
| 455dc683eb | |||
| b397b811f9 | |||
| 04ddc6223e | |||
| 42540fba99 | |||
| f62943199c | |||
| 5609e6c010 | |||
| 5848d25612 | |||
| ae067739ce | |||
| ae2dd43073 | |||
| bc06567bb0 | |||
| bdb0c0878e | |||
| b74319bf10 | |||
| d3721b0020 | |||
| ad51280e51 | |||
| f1c0435bce | |||
| 43229bf79f | |||
| c5a270aabc | |||
| 46d85d397d | |||
| c1565c5f42 | |||
| fdf8661a4b | |||
| 6e5b2c0e63 | |||
| 9626c7de32 | |||
| 9e36a4188a | |||
| 0d177494d9 | |||
| 9d365f4bbb | |||
| f705cc04a9 | |||
| 7bcc32fea1 | |||
| d53e533c9f | |||
| b4278842d9 | |||
| a0d378d06d | |||
| 0795c1bbf8 | |||
| 3709ceff8e | |||
| b2a1251774 | |||
| 2b31c7f6b7 | |||
| c83955eea7 | |||
| c2135a7066 | |||
| 90078760c1 | |||
| b97f32f86e | |||
| 71dd7e9852 | |||
| 984122f394 | |||
| c5d63b3090 | |||
| 6a728a3357 | |||
| 3e29328949 | |||
| f9c86bc308 | |||
| d50c6e0a73 | |||
| db1dce0f3b | |||
| ed0c9ade1a | |||
| 9aed86cbf2 | |||
| a3238304f5 | |||
| b88f4762a5 | |||
| 8f93a5947e | |||
| 5ede3dc416 | |||
| 64ef63a056 | |||
| 46ac55d950 | |||
| 961dea6934 | |||
| cc51cc81f9 | |||
| 362e8d1603 | |||
| cba3b7dc38 | |||
| e4bd11317a | |||
| 0da380a55b | |||
| 3e8cf4a975 | |||
| 0bff57fb43 | |||
| c2240c7a60 | |||
| bb2458c765 | |||
| d55377e169 | |||
| a4857ee8f4 | |||
| 7f1f6044dc | |||
| 6bb5c63db7 | |||
| cf6c755e51 | |||
| 81ab1886d1 | |||
| 90f2d64019 | |||
| a0efe491e5 | |||
| ee59731ed2 | |||
| 2df549f1f8 | |||
| efefd39439 | |||
| 9bea14b341 | |||
| cbad78962f | |||
| d4faedfba1 | |||
| bcebf1cdc5 | |||
| 1011825949 | |||
| bf07570848 | |||
| 5a73f1a88e | |||
| 7b60a5b060 | |||
| a29ae29487 | |||
| 011e49e1cc | |||
| 9507eb3de5 | |||
| 9a2ee0273f | |||
| 8c32ad2483 | |||
| 7c80bb0612 | |||
| fe2b11c88e | |||
| 92cb184a5c | |||
| a935573ef8 | |||
| 266342cd98 | |||
| 2ae542ecdb | |||
| eedb8d22c1 | |||
| 6f2a87be4f | |||
| ef0b77baa8 | |||
| 9e23de4ba1 | |||
| 891f0dad9e | |||
| 730e8bb4d4 | |||
| 91b7f86225 | |||
| 0d63724903 | |||
| 269c1d5fdf | |||
| 28f6b0f18b | |||
| 35df8d99dc | |||
| 0628f08e6b | |||
| ccb2db2309 | |||
| 4e7cd9ab6f | |||
| 4d97cec869 | |||
| 7cb1bfa667 | |||
| 159c983a3f | |||
| 4abb00b86d | |||
| 7bf0220f25 | |||
| e10013fd2c | |||
| a6664bbb93 | |||
| 1750c06369 | |||
| a2bd7ca299 | |||
| 85c7933ad6 | |||
| d5ec291579 | |||
| 6845ad409b | |||
| e7a5ab9450 | |||
| c75421f46e | |||
| 72c17096ea | |||
| 78fc5ffcf7 | |||
| cccaf6b206 | |||
| 9e4fe62de2 | |||
| f74115c6e2 | |||
| cefc8cfd42 | |||
| 3b7aa8242a | |||
| be0d749f9c | |||
| 2128aab6ca | |||
| f559e4027e | |||
| 4352868410 | |||
| f04b548f8c | |||
| 2f9dd47cc2 | |||
| 87f0c2c084 | |||
| fb7c92da79 | |||
| 2377f51718 | |||
| ff2c29c34f | |||
| 4a06caec67 | |||
| 2a79211aee | |||
| cd5334215a | |||
| dfcb04484b | |||
| d18c872072 | |||
| 80b2c636b0 | |||
| b779edc0d6 | |||
| 3bc20a0a46 | |||
| 121928bc57 | |||
| 1d63b8901d | |||
| 474c430ad1 | |||
| f968ae4fd4 | |||
| aa61e327f6 | |||
| 605a8b86b8 | |||
| ba60b7e4f6 | |||
| 9cba9bfec4 | |||
| ed10e21ee9 | |||
| 9d8fcbe866 | |||
| afe4123a17 | |||
| bbba8f4373 | |||
| 897aab032e | |||
| 5b2c83ae6e | |||
| dc15cceb1e | |||
| b5c2d6ce9e | |||
| 0eeda63abb | |||
| d4933ae314 | |||
| 53ffe0e06c | |||
| 0433e3b256 | |||
| 9b409799c8 | |||
| dd96709d18 | |||
| 3818a72045 | |||
| 0437ce1bb4 | |||
| 0d5d7da70f | |||
| 277a0eb6a7 | |||
| c02fc37491 | |||
| 1d440be352 | |||
| 67b2897a8c | |||
| 519e7c4179 | |||
| 36bc1fb491 | |||
| 9e3fe4e557 | |||
| a6d8278730 | |||
| b23d20ea05 | |||
| cf4bc470e4 | |||
| da923e409a | |||
| f7d7c5704a | |||
| f03bbd9a92 | |||
| ff5bb770d7 | |||
| 8dd350264a | |||
| cde090685a | |||
| a9f1dad091 | |||
| 54827cd0a2 | |||
| e01bb53b2e | |||
| 9f2b808cdb | |||
| 6709ded66c | |||
| fecaf0f400 | |||
| 730e83472a | |||
| 88145d08e5 | |||
| c367802b4a | |||
| 3d36524707 | |||
| 6cdc5879bb | |||
| 79d14cd260 | |||
| f6020b32a7 | |||
| aa5c4a8d69 | |||
| ec1f2de5cf | |||
| 3d01822d27 | |||
| f066fcda32 | |||
| b3b4df0fc7 | |||
| 616a97d242 | |||
| d9dabb4a96 | |||
| 371fb2cb17 | |||
| 5c824e50b8 | |||
| fbe99e33cd | |||
| 35bf1afcff | |||
| 543cde525e | |||
| dad7780ab8 | |||
| eb941778f2 | |||
| b7acce6527 | |||
| 247d3c758b | |||
| 79f7e89740 | |||
| 8d7bcbd947 | |||
| 23a59806c2 | |||
| 1e76e137ab | |||
| cef03c495f | |||
| 36b56a400c | |||
| c6f63fd4a2 | |||
| 7cdf47a9e4 | |||
| 9b706dfaee | |||
| f7bfe5ecaa | |||
| 6ef43ce7ff | |||
| 3c87d36535 | |||
| a62a377081 | |||
| b557477770 | |||
| bf13249003 | |||
| 31c5a5dde5 | |||
| 758256a1a2 | |||
| c660cb4e89 | |||
| 0512dcaf4f | |||
| d1075afc02 | |||
| f037f01b27 | |||
| 65e563a889 | |||
| bd18297af3 | |||
| 77505ca7cb | |||
| c22c16044c | |||
| 889a70efdd | |||
| 2386020639 | |||
| 60adac0eb7 | |||
| 95686ae858 | |||
| fd4cf5d414 | |||
| 49794c5e04 | |||
| e29387f3c1 | |||
| 581a800612 | |||
| 1456f47fd1 | |||
| a9b2da86ff | |||
| 6c67dc4a98 | |||
| 2edd9ed2d7 | |||
| 9dda513f84 | |||
| 5ecfd6cd42 | |||
| f494486e12 | |||
| 9613fce919 | |||
| 486871379c | |||
| fba0550dda | |||
| 371ff86287 | |||
| c90953adbe | |||
| 4031bf3e79 | |||
| 9adaff488a | |||
| 9f6168915f | |||
| 64b90dd678 | |||
| 93e02aed44 | |||
| 8302660d88 | |||
| 2c0e3d1119 | |||
| 7bbf03766e | |||
| 7a2ba17d20 | |||
| ac30f0e512 | |||
| 2e1037ff36 | |||
| 626c52044d | |||
| f7ee4e487a | |||
| 7b3e172948 | |||
| 6bb1a2a311 | |||
| 3b00a92fa4 | |||
| f39681bb2b | |||
| 967d2c7f35 | |||
| b0d933a7ab | |||
| 96e0df110e | |||
| 91a6129e8d | |||
| d9c829c3c6 | |||
| 467dfcc4b8 | |||
| 8a938c00f6 | |||
| 5661735f3e | |||
| 1c7434ff83 | |||
| d75c2a881b | |||
| ddb1db7a8e | |||
| 407a365055 | |||
| 335cbce09e | |||
| 922a0abb60 | |||
| 25a0ebe51e | |||
| 3a836ab0f4 | |||
| 524ab3de05 | |||
| e77993fb76 | |||
| 0c442b4a71 | |||
| 670aa95494 | |||
| 5ffb66c0dc | |||
| b675b70067 | |||
| 1484eae53c | |||
| b12a72ea1a | |||
| 1b9e18f98b | |||
| 8fe0af349d | |||
| 5826d78e35 | |||
| 325811fee7 | |||
| 69f6d32d4a | |||
| ea70c381de | |||
| 6f45ec13f3 | |||
| 3bc0615000 | |||
| 69729842a4 | |||
| 5f4fc646f5 | |||
| 85570c73cb | |||
| 3a3f6b8593 | |||
| 426d7797a3 | |||
| cb0abc324e | |||
| e832ffbe23 | |||
| abffdec497 | |||
| 70e7e24b4f | |||
| 13ab7d39d7 | |||
| 3cae817915 | |||
| c33444a92e | |||
| 3e9b1cc2b2 | |||
| ac6175688d | |||
| a6f8108165 | |||
| 8cff55a405 | |||
| bd376f86ea | |||
| d9f28e85af | |||
| ed18fd55e1 | |||
| 5b06098368 | |||
| e89a67b9a7 | |||
| 445959f5b9 | |||
| 647f177f31 | |||
| 705dbdbd7e | |||
| 31d2f8ef37 | |||
| 236b4097c5 | |||
| f5790d5c8a | |||
| f08ac7f916 | |||
| e77d338357 | |||
| 5d6325ae30 | |||
| 9ba77dac0f | |||
| f9856cf0aa | |||
| de93f08e93 | |||
| d538280f4b | |||
| bbb74b76c8 | |||
| eadf93b804 | |||
| f29443fc15 | |||
| 75a023bdd8 | |||
| d30344ee3c | |||
| 02d635d48b | |||
| 40ec356cc3 | |||
| 5a5d4da57d | |||
| efb247d104 | |||
| 457b0e24c5 | |||
| 6e0ac138d8 | |||
| 26c6c92bb8 | |||
| e1ad45f44b | |||
| aebca14d78 | |||
| 42b0d7a174 | |||
| a37c2474fe | |||
| 5f308e9da7 | |||
| 3aadced85d | |||
| 9375d5aded | |||
| 2c8205146c | |||
| 8d5a2b911d | |||
| 7d5de7bf24 | |||
| 4b1410855a | |||
| 78a4d1af65 | |||
| 5e3519aad4 | |||
| 4386ef47fe | |||
| 135a18d56f | |||
| 71f45479b9 | |||
| bcb8d5bed6 | |||
| 8f19bf7350 | |||
| 25860636f0 | |||
| 8c2402cacb | |||
| 4b910ceaa7 | |||
| 4a0ce6337f | |||
| e134ed11a9 | |||
| 03b83846ef | |||
| 7c86c803fa | |||
| a31d9dc15e | |||
| 57749da919 | |||
| 9ad3acbdf9 | |||
| 6fa53aca9f | |||
| bd53168c4e | |||
| 08d17b4a09 | |||
| c5657b9c31 | |||
| 549e2da05b | |||
| c5e912593a | |||
| a86085c2db | |||
| edbec6fd5c | |||
| a76139c0ea | |||
| 59f1296d56 | |||
| 14ae41c7ba | |||
| df09b3aa28 | |||
| f9fad2d775 | |||
| fa609c73ba | |||
| 9b2f334130 | |||
| a006cefd71 | |||
| 4b2ada14a0 | |||
| 1ad8d8a631 | |||
| 3ca83a2289 | |||
| 2ccf0ef856 | |||
| f7f065c6e0 | |||
| 593af69230 | |||
| 032f3cb66b | |||
| 692a1889ab | |||
| 825a4a77c0 | |||
| 882a3525af | |||
| b491b1f41f | |||
| db5d784ff7 | |||
| db57db4bd9 | |||
| c5e9b75261 | |||
| c59ac62e45 | |||
| 9c8b9b1a3b | |||
| 158b0254ed | |||
| 3526a177fc | |||
| e755f2856a | |||
| 2ec3ca8308 | |||
| 1cf75b48b5 | |||
| abde9ed162 | |||
| 998db1beed | |||
| 79e352d06e | |||
| b8f77433b9 | |||
| 96b967f6fb | |||
| 68c71e62d6 | |||
| 60a38b4108 | |||
| f4a1384dc4 | |||
| b88f0bab56 | |||
| f23907768f | |||
| f280b03cab | |||
| 554c0e565d | |||
| cfc5f7633b | |||
| 82050c7c01 | |||
| f4edb695de | |||
| dc2fa233ab | |||
| a1f31a14be | |||
| d27cc02e8c | |||
| fcc74ba212 | |||
| f48ad577d3 | |||
| 60c73f5635 | |||
| 24248215e9 | |||
| b6ec133368 | |||
| 35e567cfb6 | |||
| 25c697cca1 | |||
| 30dc695741 | |||
| 9e41d51702 | |||
| dc86514329 | |||
| c644781d18 | |||
| eedc0dd075 | |||
| c8c62b504b | |||
| 8467e218ae | |||
| e8f77ab2e5 | |||
| 1d2c927d94 | |||
| 81da73bc53 | |||
| 2dcbd8df99 | |||
| 37f5f50867 | |||
| f6a2a0540b | |||
| dc2e847f58 | |||
| e77fe0253e | |||
| 3d3787a8d9 | |||
| 11b323ef00 | |||
| df577ca7f5 | |||
| a2ba4d89f3 | |||
| 2ad55bf558 | |||
| cf08e470ef | |||
| 82e59d409e | |||
| 7d01e9e91e | |||
| 1e4acfe124 | |||
| 4f1653b086 | |||
| 6cd60a064b | |||
| 8072c4670c | |||
| 4ffa2e3993 | |||
| 9b230d23dd | |||
| baa7c9cc22 | |||
| 33a92cfad4 | |||
| e9f6295014 | |||
| 71078b5bdd | |||
| 6cce92af22 | |||
| d3b8520c41 | |||
| 5425085953 | |||
| db0298ac1b | |||
| 7bfca87caf | |||
| 5f87594ead | |||
| bb46481d7f | |||
| 1821d5d57b | |||
| 6ad15221de | |||
| 83d74da556 | |||
| 114228e8eb | |||
| a53f89c849 | |||
| 7ff79c3164 | |||
| db344749b6 | |||
| 1c6ece48a8 | |||
| b622285999 | |||
| 5fde0101bf | |||
| a6062d4c39 | |||
| 66f1e92cb6 | |||
| d9c4653f82 | |||
| cfdf8fdbca | |||
| 138b2bd357 | |||
| 16b14b1fe1 | |||
| c4ce718bb2 | |||
| 62d4b35c93 | |||
| 7407c032e5 | |||
| 9d03fdf31d | |||
| dfe5148f17 | |||
| 0d5b792c57 | |||
| 2279aa8f18 | |||
| d45686128e | |||
| 5b6ce5d8ee | |||
| aad34c4d52 | |||
| 470082cc65 | |||
| 6554f35710 | |||
| 335fe24a54 | |||
| 3831ef7b25 | |||
| 8127337a09 | |||
| 9a59c2e541 | |||
| 27adca5653 | |||
| 2c5b502da9 | |||
| 23f6397a97 | |||
| 43117825d7 | |||
| cc5701ea62 | |||
| 9524eafea1 | |||
| c28a0f96f7 | |||
| 301dee96dc | |||
| 185fc7b6ed | |||
| 6d194dbb71 | |||
| d34f4bdd12 | |||
| 17dc4bde5e | |||
| ce50b14591 | |||
| f7bd319954 | |||
| e9c0121a18 | |||
| 01aa425f81 | |||
| 38d5c7dff6 | |||
| e3b4b9b618 | |||
| a5951c58f3 | |||
| 504d6eaa9f | |||
| 6253fa30ef | |||
| 47f7cef4f4 | |||
| 72bba06e71 | |||
| 9b92c5ce38 | |||
| dfa077a1c9 | |||
| 18fb2e7d4d | |||
| a610fd53e2 | |||
| 16abce1f2d | |||
| f3b42f34a6 | |||
| 6483d324de | |||
| 5ab97050dd | |||
| 17eed70903 | |||
| 88067c03b7 | |||
| 7c1e5b913f | |||
| 0014235e91 | |||
| a39b7be1d1 | |||
| de98c5f706 | |||
| 10b496e845 | |||
| bbe7bf390d | |||
| 4777b3400a | |||
| acaa70e944 | |||
| 4049d694f7 | |||
| e155a3dacf | |||
| a224e4c4d8 | |||
| edaeda5424 | |||
| 09d974913d | |||
| f82edb290a | |||
| 3d8b33ae94 | |||
| 565ecbd436 | |||
| 3359dfcc29 | |||
| 1c2afd14dd | |||
| fe5343c1d6 | |||
| 08cfefc02a | |||
| f6d9332c48 | |||
| cc6913c854 | |||
| 8c75fbd0a4 | |||
| 0de6d62409 | |||
| 5ba7ce5b7c | |||
| e106d30852 | |||
| 30affc884b | |||
| 745717ea49 | |||
| 4efd98b758 | |||
| 36640e3710 | |||
| 311c4fd29d | |||
| f50374f983 | |||
| 82ceb7f021 | |||
| 0aba3bc1d8 | |||
| f6c984ff3c | |||
| 4091ab6b6c | |||
| fb9fd5b51a | |||
| 9389700a01 | |||
| 016c1b2233 | |||
| 38b8a08297 | |||
| c9ffd3ad99 | |||
| 61f960de28 | |||
| da1ff2cacc | |||
| 05036c682f | |||
| 7d47bc8042 | |||
| 98cfd160ef | |||
| b5e3262b67 | |||
| 009fb35c4c | |||
| 8648d3131a | |||
| 00c316c35d | |||
| 5f8de8e756 | |||
| ee5dc8fc41 | |||
| a61926988a | |||
| bd8c4dfb6b | |||
| ce9b4bc4dd | |||
| 8b12b00114 | |||
| 1775cc1d54 | |||
| e4bd09df24 | |||
| 5e8c7da4df | |||
| c85592eefe | |||
| 05861c9113 | |||
| 3508d1e315 | |||
| e3177b8054 | |||
| 03e3760152 | |||
| 4740610923 | |||
| e28a0cde55 | |||
| 5b855fd835 | |||
| a2f5704581 | |||
| f7aa9424db | |||
| aa8b47a3dd | |||
| 11911c1898 | |||
| 4814c1971d | |||
| be9569f3fb | |||
| 900e72f95a | |||
| d2827f188b | |||
| cf9903b759 | |||
| 23f96461f4 | |||
| 9f2fd26e98 | |||
| 78d837c080 | |||
| 241b9312b7 | |||
| ed70ad7378 | |||
| 00213176d8 | |||
| 406650a45a | |||
| 56750ccf3c | |||
| dfc286b393 | |||
| 49a66f72fc | |||
| 3f237689da | |||
| cf1fb483b3 | |||
| b10f5e3f67 | |||
| c4fc24c513 | |||
| 3ac9c2d95e | |||
| e5ab4dafc0 | |||
| 10ae1911c3 | |||
| 73ebcdf0d6 | |||
| 5347523921 | |||
| 7ef70b953b | |||
| ccaca524fe | |||
| dd51f91cab | |||
| 537d98b41b | |||
| 9c4cadfc04 | |||
| 2001370441 | |||
| cc87b22757 | |||
| c0a65b30ad | |||
| c07e66c086 | |||
| a0cfc1be2b | |||
| 1505454793 | |||
| e1dff66283 | |||
| 5be801a086 | |||
| 94d4b05c29 | |||
| cebb889f7e | |||
| c4ed6ed034 | |||
| ec960bfefa | |||
| 79f689dde1 | |||
| 3b3654df56 | |||
| c66f008f07 | |||
| 37d9498d90 | |||
| 1ff67093db | |||
| daed37ccb8 | |||
| d41d807b4f | |||
| d6fa5c8a55 | |||
| 2dd608dfed | |||
| a98546f605 | |||
| 3567559d4e | |||
| 216476ee45 | |||
| 3fc28c07fb | |||
| 85f6ef063d | |||
| 1e71e2d68f | |||
| b24a29895f | |||
| 0167a2ae59 | |||
| 2c867103ca | |||
| 8c289df336 | |||
| 4489920cbf | |||
| 029a85081b | |||
| 1bc739d07f | |||
| c229e218f6 | |||
| a66f4ad4bd | |||
| 1dd687dab7 | |||
| 50ff6e2745 | |||
| 811dec713b | |||
| 617d6f4bd7 | |||
| 57cd2d6252 | |||
| ec64f8d048 | |||
| ed288a9dba | |||
| 27da0a4102 | |||
| 3c01ba1a76 | |||
| 252c8833ae | |||
| f45fb6efe6 | |||
| 8cc1f8d691 | |||
| bff22b5182 | |||
| d31d47eb32 | |||
| 5fe984c39d | |||
| 7f07b0daa7 | |||
| 5de9757d46 | |||
| f89276d7b8 | |||
| 30ba034206 | |||
| fa1e5aaa7f | |||
| 870c70180f | |||
| 6d35c26b3f | |||
| be4e693a27 | |||
| 5810276156 | |||
| d10ac3f87b | |||
| 9810bc09e9 | |||
| a0a13eb2a8 | |||
| 6e996797b8 | |||
| 663092b501 | |||
| 8ea13544de | |||
| e73daa6214 | |||
| d83a833b4d | |||
| ec3a2f29f0 | |||
| cf92c60a01 | |||
| b7f51b03bc | |||
| 903e69ff77 | |||
| c4167ddaad | |||
| 50bfdb0d68 | |||
| a6cb09ff1c | |||
| e4c9f23476 | |||
| 44e5415d43 | |||
| 1c653693ed | |||
| 39c470ad7a | |||
| 1103e538a5 | |||
| c0cd4cba6f | |||
| b91120e8d4 | |||
| 005774a4c2 | |||
| 16bbfebfba | |||
| 15505cd82d | |||
| 016d80e002 | |||
| 0f3c267a48 | |||
| 589bb02411 | |||
| c0f4ece17b | |||
| c3ae3cb768 | |||
| c9e0f9d985 | |||
| e3431c2fa3 | |||
| 5979b9771e | |||
| aa61832fb2 | |||
| 2ac6e982b1 | |||
| 3204ddcf07 | |||
| c87b1c133c | |||
| 9b275ecdae | |||
| d6fd7de361 | |||
| 49d66a133e | |||
| c559f26d0e | |||
| bbe9f1bad2 | |||
| 7e1fb6472d | |||
| 0ff8d20573 | |||
| 9c1f9448dc | |||
| 43a6081dd6 | |||
| 985e961876 | |||
| 098f6de047 | |||
| 1b0f90fd68 | |||
| 12252f407b | |||
| 3b6e3f47ab | |||
| 6a9ac9b025 | |||
| ae6aa4088b | |||
| c08f431180 | |||
| 123c1f56e9 | |||
| 35ac65a864 | |||
| e9f362cc87 | |||
| 65685c23e1 | |||
| 2f74748cea | |||
| f477bd66f3 | |||
| d7d77ae8f0 | |||
| 31110a740d | |||
| b64d8b1d7f | |||
| c46006aacc | |||
| 92f81b1493 | |||
| 70213cfc8f | |||
| 8a82bf5c50 | |||
| 37405384a2 | |||
| 54ea6cc53b | |||
| 339c00d815 | |||
| ea6b4dcde2 | |||
| 2b84623d1e | |||
| c8b3afa56b | |||
| 1348f3c24c | |||
| 62208ce3e1 | |||
| 813b2481de | |||
| 27b924ba61 | |||
| b40170b8ce | |||
| 8bfa9d2734 | |||
| c7cf76d4a8 | |||
| dfd2969b3e | |||
| 0e1866fe1d | |||
| b9ae46b913 | |||
| 06e7284055 | |||
| 93289e8fca | |||
| 130d5057d4 | |||
| be492d5084 | |||
| e0bf1d736f | |||
| 5a6b71cbeb | |||
| e6934cd5e2 | |||
| b5aada0792 | |||
| 165ea83ac3 | |||
| 440a82dee4 | |||
| 9c2d3e5e26 | |||
| 6fb6abcbe5 | |||
| dc449dafd1 | |||
| ecdaeebbfb | |||
| fa958b59bd | |||
| fb3d8521cd | |||
| 608c401cf3 | |||
| 1c3da90a24 | |||
| 34567f3375 | |||
| 51bcbeb48f | |||
| cc0f9c42df | |||
| a11bf5523b | |||
| 1921c3d901 | |||
| d568469e8b | |||
| 20d5c7e8d5 | |||
| 9f289ed9de | |||
| 93ee5e480b | |||
| 98a312701f | |||
| cbcf603b63 | |||
| f976f672cf | |||
| cfc3081e8a | |||
| 99818924ee | |||
| 9bbf3a027f | |||
| 93e01902e3 | |||
| 34919aba05 | |||
| a8ee4cf57f | |||
| b39548b4c6 | |||
| 4217c22ff6 | |||
| 4ab10670c9 | |||
| 2883f88de6 | |||
| e002a61a19 | |||
| 5893376279 | |||
| 411c5e4c4d | |||
| c2a77072d2 | |||
| c8a25934a6 | |||
| 7b38355cd4 | |||
| ddc54e0b98 | |||
| 8a7003782b | |||
| 8e6464dacb | |||
| 92b1dc0afb | |||
| 7562f51e07 | |||
| 09bba99e68 | |||
| 9d674cd49b | |||
| 88a0c9ea03 | |||
| 5014e0ce3e | |||
| b7a1ee9ebc | |||
| 292ceddd66 | |||
| 4b52b80000 | |||
| 9f20664c6e | |||
| 851a6aac0e | |||
| 1f1e73c47a | |||
| 112f61ca18 | |||
| 96eeacbe2b | |||
| 3f62da879c | |||
| aa30feb875 | |||
| 9ba3c2b7c5 | |||
| 320c708e10 | |||
| efa7294f59 | |||
| ae0e092935 | |||
| c77aecbfce | |||
| 700352ec45 | |||
| 664b125ce2 | |||
| 5f4b1c9e32 | |||
| d11d3f19bd | |||
| f34f4f2738 | |||
| 15db7c2310 | |||
| f9257ed04d | |||
| 15e6ef8488 | |||
| 9ae0a57f22 | |||
| 1e38c21f8e | |||
| bdc3c19163 | |||
| d55478da54 | |||
| 82bcc55645 | |||
| 07618ebe43 | |||
| 1492834d1e | |||
| 5ab6197356 | |||
| 0a789fe551 | |||
| caa8ff23ed | |||
| ee30d1d36d | |||
| 0d9415db9d | |||
| 8020e1126f | |||
| 3439422057 | |||
| 68d2bf736f | |||
| d78c39fd8c | |||
| b1dcad86b4 | |||
| 9b6124074d | |||
| 02cbaa1e80 | |||
| a12f1321c7 | |||
| 8b67f592ac | |||
| 319d17b337 | |||
| 476eaa85da | |||
| d26099855c | |||
| e47456da17 | |||
| a464d5d0b6 | |||
| 1cfb7b5615 | |||
| ac7c2f3d03 | |||
| 638d9e6e01 | |||
| 8b9df2a396 | |||
| d7fe911bde | |||
| 0acc3d511b | |||
| 4cf465f419 | |||
| b686d317a9 | |||
| dcef541852 | |||
| abdd733f11 | |||
| 942431e882 | |||
| 1c75ea046c | |||
| f32b6daa51 | |||
| 3736d6ba5e | |||
| 9788b01f35 | |||
| 9aec991da6 | |||
| 910701ce04 | |||
| 34b462d511 | |||
| 139e93b2f0 | |||
| 0dd7e9359e | |||
| 41cf0225e3 | |||
| 962254e511 | |||
| a7f2b24bac | |||
| 1323d988af | |||
| 7c49e5c749 | |||
| cd69ec4fa3 | |||
| 4c7e9fbee2 | |||
| 1639df5616 | |||
| 810cdbd790 | |||
| 0d4f4aec4e | |||
| 6b1863d3b4 | |||
| 27f5a3b16b | |||
| 876cd8291b | |||
| d0c46e4ef3 | |||
| feb8898ebf | |||
| 4fef8c5cfd | |||
| 7d56d8e35b | |||
| 5f1a3a9c8f | |||
| 0767b3156d | |||
| 9f16379b41 | |||
| be632aaf37 | |||
| 118c87faf7 | |||
| ec1e53d566 | |||
| 6a17ee414a | |||
| 6700686e4b | |||
| e8c34dd59b | |||
| 4c2da31bb3 | |||
| c0144b99bf | |||
| a0c32fc146 | |||
| a07b641adb | |||
| 0bb869fb33 | |||
| 72389e0129 | |||
| f49529fa70 | |||
| afcc34b5cc | |||
| 655b99cac8 | |||
| cc5091e28c | |||
| 1c72362c6b | |||
| 50ad5f681b | |||
| 50bf670931 | |||
| 7a8896864f | |||
| 51fbf148d9 | |||
| a9929438cd | |||
| 5a94b6b56c | |||
| 52cfbf60d4 | |||
| 29c10f8854 | |||
| ad761e388d | |||
| 07493ab0a6 | |||
| 7441011ae7 | |||
| d0818f456d | |||
| 06ea07a021 | |||
| 36d97ad5ca | |||
| a995eb2929 | |||
| c459a3033d | |||
| b4fbcf6bee | |||
| b9e679a514 | |||
| 64d73b93e4 | |||
| db70b05088 | |||
| 9428beeae5 | |||
| f9f7172702 | |||
| f1851b304c | |||
| d2ca6f1d46 | |||
| b27297cdc6 | |||
| 0d0edd7917 | |||
| fc6f12fb22 | |||
| d24096374f | |||
| 4f1d04009a | |||
| 79e9fde937 | |||
| 0ebaf6a171 | |||
| be2012f28d | |||
| ceefc8ffc6 | |||
| 0453b6903a | |||
| 691952249b | |||
| 0ceae2852e | |||
| 6d7ff38cf2 | |||
| 1b93ccf608 | |||
| 5b1ca3711a | |||
| 877f9299e1 | |||
| 677aca7a03 | |||
| 66b31a62d0 | |||
| 34923638c5 | |||
| bb61b3dc22 | |||
| 01ecae8979 | |||
| 53175c9ed7 | |||
| bc7a76755b | |||
| 92758f3e4e | |||
| b09767c526 | |||
| 2f93fd7c36 | |||
| 8acbcc548c | |||
| 19cf34f9d4 | |||
| 8c3f519016 | |||
| e63b42278c | |||
| 66ecd2fcf8 | |||
| f0d86f2392 | |||
| 5e39510f21 | |||
| 2cb4d65f3d | |||
| 15f2e05192 | |||
| a122333aaa | |||
| 06b2186bf9 | |||
| ed10dccfe2 | |||
| a1006dddb5 | |||
| 443a32dc81 | |||
| b034b4fe2f | |||
| 27b270148b | |||
| 269c64e4ed | |||
| eaf76e27f5 | |||
| 385b881068 | |||
| cf26696d12 | |||
| cb7ea40e7c | |||
| 5aaa55197e | |||
| d86d614520 | |||
| 138ca80c10 | |||
| d11a1622f8 | |||
| 42c996e16e | |||
| 1e37d75e49 | |||
| ad34d9d402 | |||
| 8c610f8a83 | |||
| f7f3e3cc03 | |||
| d68c6f9f2e | |||
| 90a5c4fbf8 | |||
| 042be6e229 | |||
| 4923c2e204 | |||
| b94d94e116 | |||
| d629ae8fbb | |||
| 1296a2e9ec | |||
| 009d02fa68 | |||
| 4cc57e9c91 | |||
| d373c6398e | |||
| 82746a0669 | |||
| 1212c3627b | |||
| 813f16ccee | |||
| 1c3cb91ecd | |||
| 5b1735db2b | |||
| bf31ee5fd6 | |||
| 1380b42c1d | |||
| dea853d840 | |||
| d72bf0739a | |||
| 481f5c0a97 | |||
| 2b017ac6b5 | |||
| 8a733ee337 | |||
| 9dd87a48a6 | |||
| 8fabbde13b | |||
| e0a378cb81 | |||
| 0b3329ca35 | |||
| 50c77b51db | |||
| c883ed19d6 | |||
| 795791219e | |||
| f6f4660cd2 | |||
| 9576f6e91e |
@@ -0,0 +1,5 @@
|
||||
[build]
|
||||
rustflags = ["-C", "target-cpu=native"]
|
||||
|
||||
[alias]
|
||||
dev = "run -p brk_cli --features brk_server/bindgen"
|
||||
@@ -0,0 +1,3 @@
|
||||
.git
|
||||
target
|
||||
docker
|
||||
@@ -1,59 +1,53 @@
|
||||
# Mac OS
|
||||
.DS_Store
|
||||
|
||||
# To do
|
||||
/charts
|
||||
TODO.md
|
||||
|
||||
# Builds
|
||||
dist
|
||||
target
|
||||
|
||||
# I/O
|
||||
in
|
||||
out
|
||||
.log
|
||||
/datasets
|
||||
/datasets2
|
||||
/price
|
||||
*..*
|
||||
/txout_*
|
||||
/db
|
||||
|
||||
# Sync
|
||||
.stfolder
|
||||
websites/dist
|
||||
bridge/
|
||||
/ids.txt
|
||||
rust_out
|
||||
|
||||
# Copies
|
||||
*\ copy*
|
||||
|
||||
# Ignored
|
||||
ignore
|
||||
_*
|
||||
!__*.py
|
||||
/*.md
|
||||
/*.py
|
||||
/*.json
|
||||
/*.html
|
||||
!/btc-cycle-sim.html
|
||||
/research
|
||||
/filter_*
|
||||
/heatmaps*
|
||||
/oracle*
|
||||
/playground
|
||||
/*.txt
|
||||
/*.csv
|
||||
|
||||
# Scripts
|
||||
/start-node.sh
|
||||
# Logs
|
||||
*.log*
|
||||
|
||||
# Editors
|
||||
.vscode
|
||||
.zed
|
||||
# Environment variables/configs
|
||||
.env
|
||||
|
||||
# Configs
|
||||
config.toml
|
||||
|
||||
# Flamegraph
|
||||
flamegraph/
|
||||
# Profiling
|
||||
profile.json.gz
|
||||
flamegraph.svg
|
||||
*.trace
|
||||
|
||||
# AI
|
||||
.claude/settings*
|
||||
!CLAUDE.md
|
||||
|
||||
# Expand
|
||||
expand.rs
|
||||
|
||||
# Benchmarks
|
||||
benches
|
||||
[0-9]/
|
||||
/benches
|
||||
|
||||
# Snapshots
|
||||
snapshots*/
|
||||
|
||||
# Docker
|
||||
docker/kibo
|
||||
|
||||
# Types
|
||||
website/scripts/types/paths.d.ts
|
||||
|
||||
# Misc
|
||||
OPENSATS.md
|
||||
# AI
|
||||
.claude
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
<!--
|
||||
## v. 0.X.Y | WIP
|
||||

|
||||
-->
|
||||
|
||||
## v. 0.5.0 | [873199](https://mempool.space/block/0000000000000000000270925aa6a565be92e13164565a3f7994ca1966e48050) - 2024/12/04
|
||||
|
||||

|
||||
|
||||
## Datasets
|
||||
|
||||
- Added `Sell Side Risk Ratio` to all entities
|
||||
- Added `Open`, `High` and `Low` datasets
|
||||
- Added `Satoshis Per Dollar`
|
||||
- Added `All Time High`
|
||||
- Added `All Time High Date`
|
||||
- Added `Days Since All Time High`
|
||||
- Added `Max Days Between All Time Highs`
|
||||
- Added `Max Years Between All Time Highs`
|
||||
- Added `Drawdown`
|
||||
- Added `Adjusted Value Created`, `Adjusted Value Destroyed` and `Adjusted Spent Output Profit Ratio` to all entities
|
||||
- Added `Realized Profit To Loss Ratio` to all entities
|
||||
- Added `Hash Price Min`
|
||||
- Added `Hash Price Rebound`
|
||||
- Removed all year datasets (25) in favor for epoch datasets (5), the former was too granular to be really useful
|
||||
- Removed datasets split by liquidity for all datasets **already split by any address kind**, while fun to have, they took time to compute, ram, and space to store and no one was actually checking them
|
||||
- Fixed a lot of values in split by liquidity datasets
|
||||
|
||||
## Website
|
||||
|
||||
- Updated the design yet again which made the website for something more minimal and easier on the eyes
|
||||
- Added a *Save In Bitcoin* (DCA) simulation page
|
||||
- ~Added a dashboard~ Added the latest values to the tree next to each option instead, while less values are visible at a time, it's much more readable and organised
|
||||
- Added a library of PDFs
|
||||
- Fixed service worker not passing 304 (not modified) response and instead serving cached responses
|
||||
- Fixed history not being properly registered
|
||||
- Fixed window being moveable on iOS when in standalone mode when it shouldn't be
|
||||
- Added `Compare` section to all groups, to compare all datasets within a group
|
||||
- Updated `Solid Signals` library, which had an important breaking change on the `createEffect` function which might bring some bugs
|
||||
- Fixed some datasets paths
|
||||
- A lot of code reorg and file splits
|
||||
- Adopted a framework like approach to load pages while still being pure JS without a build step
|
||||
- Probably more that was forgotten
|
||||
|
||||
## Parser
|
||||
|
||||
- Added a `/datasets/last` json file with all the latest values
|
||||
- Added `--rpcconnect` parameter to the config
|
||||
- Added handling of SIGINT and SIGTERM terminal signals which menas you can now safely CTRL+C or kill the parser while it's exporting
|
||||
- Added config print at the start of the program
|
||||
- Compressed `empty_address_data` struct to save space (should shave of between up to 50% of the `address_index_to_empty_address_data` database)
|
||||
- Doubled the number of `txid_to_tx_data` databases from 4096 to 8192
|
||||
- ~Added `--recompute_computed true` argument, to allow recomputation of computed datasets in case of a bug~ Buggy for now
|
||||
- Fixed not saved arguments, not being processed properly
|
||||
- Fixed bug in `generic_map.multi_insert_simple_average`
|
||||
- Added defragmentation option `--first-defragment true` of databases to save space (which can save up to 50%)
|
||||
- Fixed bug in the computation of averages in `GenericMap`
|
||||
- Added support and paramer for cookie files with `--rpccookiefile`, and auto find if the path is `--datadir/.cookie`
|
||||
- Increased number of retries and time between them when fetching price from exchanges APIs
|
||||
|
||||
## Server
|
||||
|
||||
- Fixed links in several places missing the `/api` part and thus not working
|
||||
- Fixed broken last values routes
|
||||
- Added support for the `/datasets/last` file via the `/api/last` route
|
||||
- Added support for `.json` (won't change anything) and `.csv` (will download a csv file) extension at the end of datasets routes
|
||||
- Added `all=true` query parameter to dataset routes to get to full history
|
||||
|
||||
## Biter
|
||||
|
||||
- Moved back to this repo
|
||||
|
||||
## v. 0.4.0 | [861950](https://mempool.space/block/00000000000000000000530d0e30ccf7deeace122dcc99f2668a06c6dad83629) - 2024/09/19
|
||||
|
||||

|
||||
|
||||
### Brand
|
||||
|
||||
- **Satonomics** is now **kibō** 🎉
|
||||
|
||||
### Website
|
||||
|
||||
- Complete redesign of the website
|
||||
- Rewrote the whole application and removed `node`/`npm`/`pnpm` dependencies in favor for pure `HTML`/`CSS`/`Javascript`
|
||||
- Website is now served by the server
|
||||
- Added Trading View attribution link to the settings frame and file in the lightweight charts folder
|
||||
- Many other changes
|
||||
|
||||
### Parser
|
||||
|
||||
- Changed the block iterator from a custom version of [bitcoin-explorer](https://crates.io/crates/bitcoin-explorer) to the homemade [biter](https://crates.io/crates/biter) which allows the parser to run alongside `bitcoind`
|
||||
- Added datasets compression thanks to [zstd](https://crates.io/crates/zstd) to reduce disk usage
|
||||
- Use the Bitcoin RPC server for various calls instead of running cli commands and then parsing the JSON from the output
|
||||
- **Important database changes that will need a full rescan**:
|
||||
- Changed databases page size from 1MB to 4KB for improved disk usage
|
||||
- Split txid_to_tx_data database in 4096 chunks (from 256) for improved disk usage
|
||||
- Split address_index_to_X databases to chunks of 25_000 instead of 50_000
|
||||
- Removed local Multisig database
|
||||
- Updated the config, run with `-h` to see possible args
|
||||
- Moved outputs from `/target/outputs` to `/out` to allow to run commands like `cargo clean` without side effects
|
||||
- Various first run fixes
|
||||
- Added to `-h` which arguments are saved, which is all of them at the time of writing
|
||||
|
||||
### Server
|
||||
|
||||
- Updated the code to support compressed binaries
|
||||
- Added serving of the website
|
||||
- Improved `Cache-Control` behavior
|
||||
|
||||
## v. 0.3.0 | [853930](https://mempool.space/block/00000000000000000002eb5e9a7950ca2d5d98bd1ed28fc9098aa630d417985d) - 2024/07/26
|
||||
|
||||

|
||||
|
||||
### Parser
|
||||
|
||||
- Global
|
||||
- Improved self-hosting by:
|
||||
- Fixing an incredibly annoying bug that made the program panic because of a wrong utxo/address durable state after a or many new datasets were added/changed after a first successful parse of the chain
|
||||
- Fixing a bug that would crash the program if launched for the first time ever
|
||||
- Auto fetch prices from the main Satonomics instance if missing instead of only trying Kraken's and Binance's API which are limited to the last 16 hours
|
||||
- Merged the core of `HeightMap` and `DateMap` structs into `GenericMap`
|
||||
- Added `Height` struct and many others
|
||||
- Reorganized outputs of both the parser and the server for ease of use and easier sync compatibility
|
||||
- CLI
|
||||
- Added an argument parser for improved UX with several options
|
||||
- Datasets
|
||||
- Added the following datasets for all entities:
|
||||
- Value destroyed
|
||||
- Value created
|
||||
- Spent Output Profit Ratio (SOPR)
|
||||
- Added the following ratio datasets and their variations to all prices {realized, moving average, any cointime, etc}:
|
||||
- Market Price to {X}
|
||||
- Market Price to {X} Ratio
|
||||
- Market Price to {X} Ratio 1 Week SMA
|
||||
- Market Price to {X} Ratio 1 Month SMA
|
||||
- Market Price to {X} Ratio 1 Year SMA
|
||||
- Market Price to {X} Ratio 1 Year SMA Momentum Oscillator
|
||||
- Market Price to {X} Ratio 99th Percentile
|
||||
- Market Price to {X} Ratio 99.5th Percentile
|
||||
- Market Price to {X} Ratio 99.9th Percentile
|
||||
- Market Price to {X} Ratio 1st Percentile
|
||||
- Market Price to {X} Ratio 0.5th Percentile
|
||||
- {X} 1% Top Probability
|
||||
- {X} 0.5% Top Probability
|
||||
- {X} 0.1% Top Probability
|
||||
- {X} 1% Bottom Probability
|
||||
- {X} 0.5% Bottom Probability
|
||||
- {X} 0.1% Bottom Probability
|
||||
- Added block metadatasets and their variants (raw/sum/average/min/max/percentiles):
|
||||
- Block size
|
||||
- Block weight
|
||||
- Block VBytes
|
||||
- Block interval
|
||||
- Price
|
||||
- Improved error message when price cannot be found
|
||||
|
||||
### App
|
||||
|
||||
- General
|
||||
- Added chart scroll button for nice animations à la Wicked
|
||||
- Added scale mode switch (Linear/Logarithmic) at the bottom right of all charts
|
||||
- Added unit at the top left of all charts
|
||||
- Added a backup API in case the main one fails or is offline
|
||||
- Complete redesign of the datasets object
|
||||
- Removed import of routes in JSON in favor for hardcoded typed routes in string format which resulted in:
|
||||
- \+ A much lighter app
|
||||
- \+ Better Lighthouse score
|
||||
- \- Slower Typescript server
|
||||
- Fixed datasets with null values crashing their fetch function
|
||||
- Added a 'Go to a random chart' button in several places
|
||||
- Chart
|
||||
- Fixed series color being set to default ones after hovering the legend
|
||||
- Fixed chart starting showing candlesticks and quickly switching to a line when it should've started directly with the line
|
||||
- Separated the QRCode generator library from the main chunk and made it imported on click
|
||||
- Fixed timescale changing on small screen after changing charts
|
||||
- Folders
|
||||
- Added the size in the "filename" of address cohorts grouped by size
|
||||
- Favorites
|
||||
- Added a 'favorite' and 'unfavorite' button at the bottom
|
||||
- Settings
|
||||
- Removed the horizontal scroll bar which was unintended
|
||||
|
||||
### Server
|
||||
|
||||
- Run file
|
||||
- Only run with a watcher if `cargo watch` is available
|
||||
- Removed id_to_path file in favor for only `paths.d.ts` in `app/src/types`
|
||||
|
||||
## v. 0.2.0 | [851286](https://mempool.space/block/0000000000000000000281ca7f1bf8c50702bfca168c7af1bdc67c977c1ac8ed) - 2024/07/08
|
||||
|
||||

|
||||
|
||||
### App
|
||||
|
||||
- General
|
||||
- Added the height version of all datasets and many optimizations to make them usable but only available on desktop and tablets for now
|
||||
- Added a light theme
|
||||
- Charts
|
||||
- Added split panes in order to have the vertical axis visible for all datasets
|
||||
- Added min and max values on the charts
|
||||
- Fixed legend hovering on mobile not resetting on touch end
|
||||
- Added "3 months" and yearly time scale setters (from year 2009 to today)
|
||||
- Hide scrollbar of timescale setters and instead added scroll buttons to the legend only visible on desktop
|
||||
- Improved Share/QR Code screen
|
||||
- Changed all Area series to Line series
|
||||
- Fixed horizontal scrollable legend not updating on preset change
|
||||
- Performance
|
||||
- Improved app's reactivity
|
||||
- Added some chunk splitting for a faster initial load
|
||||
- Global improvements that increased the Lighthouse's performance score
|
||||
- Settings
|
||||
- Finally made a proper component where you can chose the app's theme, between a moving or static background and its text opacity
|
||||
- Added donations section with a leaderboard
|
||||
- Added various links that are visible on the bottom side of the strip on desktop to mobile users
|
||||
- Added install instructions when not installed for Apple users
|
||||
- Misc
|
||||
- Support mini window size, could be useful for embedded views
|
||||
- Hopefully made scrollbars a little more subtle on WIndows and Linux, can't test
|
||||
- Generale style updates
|
||||
|
||||
### Parser
|
||||
|
||||
- Fixed ulimit only being run in Mac OS instead of whenever the program is detected
|
||||
|
||||
## v. 0.1.1 | [849240](https://mempool.space/block/000000000000000000002b8653988655071c07bb5f7181c038f9326bc86db741) - 2024/06/24
|
||||
|
||||

|
||||
|
||||
### Parser
|
||||
|
||||
- Fixed overflow in `Price` struct which caused many Realized Caps and Realized Prices to have completely bogus data
|
||||
- Fixed Realized Cap computation which was using rounded prices instead normal ones
|
||||
|
||||
### Server
|
||||
|
||||
- Added the chunk, date and time of the request to the terminal logs
|
||||
|
||||
### App
|
||||
|
||||
- Chart
|
||||
- Added double click option on a legend to toggle the visibility of all other series
|
||||
- Added highlight effect to a legend by darkening the color of all the other series on the chart while hovering it with the mouse
|
||||
- Added an API link in the legend for each dataset where applicable (when isn't generated locally)
|
||||
- Save fullscreen preference in local storage and url
|
||||
- Improved resize bar on desktop
|
||||
- Changed resize button logo
|
||||
- Changed the share button to visible on small screen too
|
||||
- Improved share screen
|
||||
- Fixed time range shifting not being the one in url params or saved in local storage
|
||||
- Fixed time range shifting on series toggling via the legend
|
||||
- Fixed time range shifting on fullscreen
|
||||
- Fixed time range shifting on resize of the sidebar
|
||||
- Set default view at first load to last 6 months
|
||||
- Added some padding around the datasets (year 1970 to 2100)
|
||||
- History
|
||||
- Changed background for the sticky dates from blur to a solid color as it didn't appear properly in Firefox
|
||||
- Build
|
||||
- Tried to add lazy loads to have split chunks after build, to have much faster load times and they worked great ! But they completely broke Safari on iOS, we can't have nice things
|
||||
- Removed many libraries and did some things manually instead to improve build size
|
||||
- Strip
|
||||
- Temporarily removed the Home button on the strip bar on desktop as there is no landing page yet
|
||||
- Settings
|
||||
- Added version
|
||||
- PWA
|
||||
- Fixed background update
|
||||
- Changed update check frequency to 1 minute (~1kb to fetch every minute which is very reasonable)
|
||||
- Added a nice banner to ask the user to install the update
|
||||
- Misc
|
||||
- Removed tracker even though it was a very privacy friendly as it appeared to not be working properly
|
||||
|
||||
### Price
|
||||
|
||||
- Deleted old price datasets and their backups
|
||||
|
||||
## v. 0.1.0 | [848642](https://mempool.space/block/000000000000000000020be5761d70751252219a9557f55e91ecdfb86c4e026a) - 2024/06/19
|
||||
|
||||

|
||||
|
||||
## v. 0.0.X | [835444](https://mempool.space/block/000000000000000000009f93907a0dd83c080d5585cc7ec82c076d45f6d7c872) - 2024/03/20
|
||||
|
||||

|
||||
@@ -1,8 +0,0 @@
|
||||
# Guidelines
|
||||
|
||||
## Parser
|
||||
|
||||
- Avoid floats as much as possible
|
||||
- Use structs like `WAmount` and `Price` for calculations
|
||||
- **Only** use `WAmount.to_btc()` when inserting or computing inside a dataset. It is **very** expensive.
|
||||
- No `Arc`, `Rc`, `Mutex` even from third party libraries, they're slower
|
||||
@@ -0,0 +1,94 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*"]
|
||||
package.description = "The Bitcoin Research Kit is a suite of tools designed to extract, compute and display data stored on a Bitcoin Core node"
|
||||
package.license = "MIT"
|
||||
package.edition = "2024"
|
||||
package.version = "0.3.4"
|
||||
package.homepage = "https://bitcoinresearchkit.org"
|
||||
package.repository = "https://github.com/bitcoinresearchkit/brk"
|
||||
package.readme = "README.md"
|
||||
|
||||
[profile.dev]
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
opt-level = 2
|
||||
split-debuginfo = "unpacked"
|
||||
|
||||
[profile.release]
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
overflow-checks = false
|
||||
|
||||
[profile.bloaty]
|
||||
debug = true
|
||||
lto = false
|
||||
strip = false
|
||||
inherits = "release"
|
||||
|
||||
[profile.profiling]
|
||||
inherits = "release"
|
||||
debug = true
|
||||
|
||||
[workspace.dependencies]
|
||||
aide = { version = "0.16.0-alpha.4", features = ["axum-json", "axum-query"] }
|
||||
axum = { version = "0.8.9", default-features = false, features = ["http1", "json", "query", "tokio", "tracing"] }
|
||||
bitcoin = { version = "0.32.10", features = ["serde"] }
|
||||
brk_alloc = { version = "0.3.4", path = "crates/brk_alloc" }
|
||||
brk_bencher = { version = "0.3.4", path = "crates/brk_bencher" }
|
||||
brk_bindgen = { version = "0.3.4", path = "crates/brk_bindgen" }
|
||||
brk_cli = { version = "0.3.4", path = "crates/brk_cli" }
|
||||
brk_client = { version = "0.3.4", path = "crates/brk_client" }
|
||||
brk_cohort = { version = "0.3.4", path = "crates/brk_cohort" }
|
||||
brk_computer = { version = "0.3.4", path = "crates/brk_computer" }
|
||||
brk_error = { version = "0.3.4", path = "crates/brk_error" }
|
||||
brk_fetcher = { version = "0.3.4", path = "crates/brk_fetcher" }
|
||||
brk_indexer = { version = "0.3.4", path = "crates/brk_indexer" }
|
||||
brk_iterator = { version = "0.3.4", path = "crates/brk_iterator" }
|
||||
brk_logger = { version = "0.3.4", path = "crates/brk_logger" }
|
||||
brk_mempool = { version = "0.3.4", path = "crates/brk_mempool" }
|
||||
brk_oracle = { version = "0.3.4", path = "crates/brk_oracle" }
|
||||
brk_query = { version = "0.3.4", path = "crates/brk_query", features = ["tokio"] }
|
||||
brk_reader = { version = "0.3.4", path = "crates/brk_reader" }
|
||||
brk_rpc = { version = "0.3.4", path = "crates/brk_rpc" }
|
||||
brk_server = { version = "0.3.4", path = "crates/brk_server" }
|
||||
brk_store = { version = "0.3.4", path = "crates/brk_store" }
|
||||
brk_traversable = { version = "0.3.4", path = "crates/brk_traversable", features = ["pco", "derive"] }
|
||||
brk_traversable_derive = { version = "0.3.4", path = "crates/brk_traversable_derive" }
|
||||
brk_types = { version = "0.3.4", path = "crates/brk_types" }
|
||||
brk_website = { version = "0.3.4", path = "crates/brk_website" }
|
||||
byteview = "0.10.1"
|
||||
color-eyre = "0.6.5"
|
||||
corepc-jsonrpc = { package = "jsonrpc", version = "0.19.0", features = ["simple_http"], default-features = false }
|
||||
corepc-types = { version = "0.15.0", features = ["std"], default-features = false }
|
||||
derive_more = { version = "2.1.1", features = ["deref", "deref_mut"] }
|
||||
fjall = "3.1.5"
|
||||
indexmap = { version = "2.14.0", features = ["serde"] }
|
||||
jiff = { version = "0.2.29", features = ["perf-inline", "tz-system"], default-features = false }
|
||||
owo-colors = "4.3.0"
|
||||
parking_lot = "0.12.5"
|
||||
pco = "1.0.2"
|
||||
rayon = "1.12.0"
|
||||
rustc-hash = "2.1.2"
|
||||
schemars = { version = "1.2.1", features = ["indexmap2"] }
|
||||
serde = "1.0.228"
|
||||
serde_bytes = "0.11.19"
|
||||
serde_derive = "1.0.228"
|
||||
serde_json = { version = "1.0.150", features = ["float_roundtrip", "preserve_order"] }
|
||||
smallvec = "1.15.2"
|
||||
tokio = { version = "1.52.3", features = ["rt-multi-thread"] }
|
||||
tower-http = { version = "0.7.0", features = ["catch-panic", "compression-br", "compression-gzip", "compression-zstd", "cors", "normalize-path", "timeout", "trace"] }
|
||||
tower-layer = "0.3"
|
||||
tracing = { version = "0.1", default-features = false, features = ["std"] }
|
||||
ureq = { version = "3.3.0", features = ["json"] }
|
||||
vecdb = { version = "0.10.3", features = ["derive", "serde_json", "pco", "schemars"] }
|
||||
# vecdb = { path = "../anydb/crates/vecdb", features = ["derive", "serde_json", "pco", "schemars"] }
|
||||
|
||||
[workspace.metadata.release]
|
||||
shared-version = true
|
||||
tag-name = "v{{version}}"
|
||||
pre-release-commit-message = "release: v{{version}}"
|
||||
tag-message = "release: v{{version}}"
|
||||
allow-branch = ["main", "next"]
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Biter
|
||||
Copyright (c) 2025 Bitcoin Research Kit
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 kibō
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,166 +0,0 @@
|
||||
<a href="https://kibo.money" target="_blank">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/kibo-money/kibo/main/assets/logo-long-text-dark.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/kibo-money/kibo/main/assets/logo-long-text-light.svg">
|
||||
<img alt="kibō" src="https://raw.githubusercontent.com/kibo-money/kibo/main/assets/logo-long-text-light.svg" width="210" height="auto">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Description
|
||||
|
||||
[**kibō**](https://kibo.money) (_hope_ in japanese) is primarily an open source Bitcoin Core data extractor and visualizer (similar to [Glassnode](https://glassnode.com)) which goal is to empower anybody with data about Bitcoin for free.
|
||||
|
||||
The project is split in 3 parts:
|
||||
|
||||
- First you have the extractor (parser), which parses the block data files from your Bitcoin Core node and computes a very wide range of datasets which are stored in compressed binary files
|
||||
> For the curious, it takes at the very least 24 hours to parse all the blocks and compute all datasets. After that it will wait for a new block and take between 1 and 3 minutes to be up to date
|
||||
- Then there is the website on which you can view, among other things, all datasets in various charts
|
||||
- Finally there is the server which serves the website and the generated data via an [API](https://github.com/kibo-money/kibo/tree/main#endpoints)
|
||||
|
||||
Whether you're an enthusiast, a researcher, a miner, an analyst, a trader, a skeptic or just curious, there is something for everyone !
|
||||
|
||||
This project was created out of frustration by all the alternatives that were either very expensive and thus discriminatory and against bitcoin values or just very limited and none were open-source and verifiable. So while it's not the first tool trying to solve these problems, it's the first that is completely free, open-source and self-hostable.
|
||||
|
||||
If you are a user of [mempool.space](https://mempool.space), you'll find this to be very complimentary, as it offers a macro view of the chain over time instead of a detailed one.
|
||||
|
||||
## Instances
|
||||
|
||||
| URL | Type | Version | Status | Last Height | Up Time Ratio |
|
||||
| ------------------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [kibo.money](https://kibo.money) | Main |  |  |  |  |
|
||||
| [backup.kibo.money](https://backup.kibo.money) | Backup |  |  |  |  |
|
||||
| [preview.kibo.money](https://preview.kibo.money) | Dev |  |  |  |  |
|
||||
|
||||
Please open an issue if you want to add another instance
|
||||
|
||||
## Endpoints
|
||||
|
||||
> If you running locally, you can replace `https://kibo.money` by `http://localhost:3110`
|
||||
|
||||
- [/](https://kibo.money/): Website
|
||||
- [/api](https://kibo.money/api): A JSON with all available datasets, with their respective id and endpoint, better viewed in a Firefox based browser
|
||||
- /api/TIMESCALE-to-ID: `TIMESCALE` can be `date` or `height`, and `ID` is the id with `_` replaced by `-`, let's take `date-to-close` (price at the end of each day) as an example
|
||||
- [/api/date-to-close](https://kibo.money/api/date-to-close): current year's values in a json format
|
||||
- [/api/date-to-close?chunk=2009](https://kibo.money/api/date-to-close?chunk=2009): values from the year 2009 in a json format
|
||||
- [/api/date-to-close?all=true](https://kibo.money/api/date-to-close?all=true): all values in a json format
|
||||
- You can also specify the extension to download a file, either `.json` or `.csv` to get the dataset in a CSV format; like so:
|
||||
- [/api/date-to-close.csv](https://kibo.money/api/date-to-close.csv)
|
||||
- [/api/date-to-close.csv?chunk=2009](https://kibo.money/api/date-to-close.csv?chunk=2009)
|
||||
- [/api/date-to-close.csv?all=true](https://kibo.money/api/date-to-close.csv?all=true)
|
||||
|
||||
## Roadmap
|
||||
|
||||
- **More Datasets/Charts**
|
||||
- **Simulations**
|
||||
- **Nostr integration**
|
||||
- **API Documentation**
|
||||
- **Descriptions**
|
||||
- **Docker support**
|
||||
- **Start9 support**
|
||||
|
||||
## Setup
|
||||
|
||||
### Requirements
|
||||
|
||||
- At least 16 GB of RAM
|
||||
- 1 TB of free space (will use 70% of that without defragmentation and 40% after)
|
||||
- A running instance of bitcoin-core with:
|
||||
- `-txindex=1`
|
||||
- `-blocksxor=0`
|
||||
- RPC credentials
|
||||
- Example: `bitcoind -datadir="$HOME/.bitcoin" -blocksonly -txindex=1 -blocksxor=0`
|
||||
- Git
|
||||
|
||||
### Manual
|
||||
|
||||
_Mac OS and Linux only, Windows is unsupported_
|
||||
|
||||
First we need to install Rust (https://www.rust-lang.org/tools/install)
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
```
|
||||
|
||||
If you already had Rust installed you could update it just in case
|
||||
|
||||
```bash
|
||||
rustup update
|
||||
```
|
||||
|
||||
> If you're on Ubuntu you'll probably also need to install `open-ssl` with
|
||||
>
|
||||
> ```bash
|
||||
> sudo apt install libssl-dev pkg-config
|
||||
> ```
|
||||
|
||||
Optionally, you can also install `cargo-watch` for the server to automatically restart it on file change, which will be triggered by new code and new datasets from the parser (https://github.com/watchexec/cargo-watch?tab=readme-ov-file#install)
|
||||
|
||||
```bash
|
||||
cargo install cargo-watch --locked
|
||||
```
|
||||
|
||||
Then you need to choose a path where all files related to **kibō** will live
|
||||
|
||||
```bash
|
||||
cd ???
|
||||
```
|
||||
|
||||
We can now clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kibo-money/kibo.git
|
||||
```
|
||||
|
||||
In a new terminal, go to the `parser`'s folder of the repository
|
||||
|
||||
```bash
|
||||
cd ???/kibo/parser
|
||||
```
|
||||
|
||||
Now we can finally start by running the parser, you need to use the `./run.sh` script instead of `cargo run -r` as we need to set various system variables for the program to run smoothly
|
||||
|
||||
For the first launch, the parser will need several information such as:
|
||||
|
||||
- `--datadir`: which is bitcoin data directory path, prefer `$HOME` to `~` as the latter might not work
|
||||
|
||||
Optionally you can also specify:
|
||||
|
||||
- `--rpccookiefile`: the path to the cookie file if not default
|
||||
- `--rpcuser`: the username of the RPC credentials to talk to the bitcoin server if set
|
||||
- `--rpcpassword`: the password of the RPC credentials if set
|
||||
- `--rpcconnect`: if the bitcoin core server's IP is different than `localhost`
|
||||
- `--rpcport`: if the port is different than `8332`
|
||||
|
||||
Everything will be saved in a `config.toml` file, which will allow you to simply run `./run.sh` next time
|
||||
|
||||
Here's an example
|
||||
|
||||
```bash
|
||||
./run.sh --datadir=$HOME/Developer/bitcoin
|
||||
```
|
||||
|
||||
In a **new** terminal, go to the `server`'s folder of the repository
|
||||
|
||||
```bash
|
||||
cd ???/kibo/server
|
||||
```
|
||||
|
||||
And start it also with the `run.sh` script instead of `cargo run -r`
|
||||
|
||||
```bash
|
||||
./run.sh
|
||||
```
|
||||
|
||||
Then the easiest to let others access your server is to use `cloudflared` which will also cache requests. For more information go to: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
|
||||
|
||||
## Donate
|
||||
|
||||
<img width="159" alt="image" src="https://github.com/user-attachments/assets/8bbb759f-4874-46cb-b093-b30cb30f5828">
|
||||
|
||||
[bc1q950q4ukpxxm6wjjkv6cpq8jzpazaxrrwftctkt](bitcoin:bc1q950q4ukpxxm6wjjkv6cpq8jzpazaxrrwftctkt)
|
||||
|
||||
<img width="159" alt="image" src="https://github.com/user-attachments/assets/745e39c7-be26-4f2a-90f2-54786e62ba35">
|
||||
|
||||
[lnurl1dp68gurn8ghj7ampd3kx2ar0veekzar0wd5xjtnrdakj7tnhv4kxctttdehhwm30d3h82unvwqhkxmmww3jkuar8d35kgetj8yuq363hv4](lightning:lnurl1dp68gurn8ghj7ampd3kx2ar0veekzar0wd5xjtnrdakj7tnhv4kxctttdehhwm30d3h82unvwqhkxmmww3jkuar8d35kgetj8yuq363hv4)
|
||||
|
||||
[Geyser Fund](https://geyser.fund/project/kibo/)
|
||||
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M 10.874 7.57 L 10.874 6.802 C 10.104 5.809 9.587 4.634 9.397 3.379 C 9.339 3.009 8.877 2.865 8.637 3.152 C 8.059 3.833 7.605 4.632 7.299 5.517 C 8.234 6.565 9.486 7.284 10.874 7.57 Z M 13.937 4.749 C 12.729 4.749 11.751 5.731 11.751 6.939 L 11.751 8.563 C 8.895 8.394 6.474 6.636 5.379 4.142 C 5.229 3.8 4.746 3.78 4.585 4.117 C 4.131 5.077 3.876 6.149 3.876 7.28 C 3.876 9.217 4.808 11.025 6.203 12.364 C 6.562 12.711 6.915 12.998 7.266 13.261 L 3.331 14.245 C 3.038 14.318 2.907 14.658 3.072 14.912 C 3.547 15.648 4.723 16.895 7.261 16.999 C 7.48 17.007 7.698 16.928 7.864 16.783 L 9.648 15.249 L 11.751 15.249 C 14.167 15.249 16.125 13.293 16.125 10.876 L 16.125 6.499 L 17 4.749 L 13.937 4.749 Z M 13.937 7.391 C 13.697 7.391 13.458 7.175 13.458 6.935 C 13.458 6.695 13.697 6.483 13.937 6.483 C 14.177 6.483 14.399 6.703 14.399 6.943 C 14.399 7.183 14.177 7.391 13.937 7.391 Z" style="fill: #12100f;"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M 10.874 7.57 L 10.874 6.802 C 10.104 5.809 9.587 4.634 9.397 3.379 C 9.339 3.009 8.877 2.865 8.637 3.152 C 8.059 3.833 7.605 4.632 7.299 5.517 C 8.234 6.565 9.486 7.284 10.874 7.57 Z M 13.937 4.749 C 12.729 4.749 11.751 5.731 11.751 6.939 L 11.751 8.563 C 8.895 8.394 6.474 6.636 5.379 4.142 C 5.229 3.8 4.746 3.78 4.585 4.117 C 4.131 5.077 3.876 6.149 3.876 7.28 C 3.876 9.217 4.808 11.025 6.203 12.364 C 6.562 12.711 6.915 12.998 7.266 13.261 L 3.331 14.245 C 3.038 14.318 2.907 14.658 3.072 14.912 C 3.547 15.648 4.723 16.895 7.261 16.999 C 7.48 17.007 7.698 16.928 7.864 16.783 L 9.648 15.249 L 11.751 15.249 C 14.167 15.249 16.125 13.293 16.125 10.876 L 16.125 6.499 L 17 4.749 L 13.937 4.749 Z M 13.937 7.391 C 13.697 7.391 13.458 7.175 13.458 6.935 C 13.458 6.695 13.697 6.483 13.937 6.483 C 14.177 6.483 14.399 6.703 14.399 6.943 C 14.399 7.183 14.177 7.391 13.937 7.391 Z" style="fill: #fffaf6;"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M 10.874 7.57 L 10.874 6.802 C 10.104 5.809 9.587 4.634 9.397 3.379 C 9.339 3.009 8.877 2.865 8.637 3.152 C 8.059 3.833 7.605 4.632 7.299 5.517 C 8.234 6.565 9.486 7.284 10.874 7.57 Z M 13.937 4.749 C 12.729 4.749 11.751 5.731 11.751 6.939 L 11.751 8.563 C 8.895 8.394 6.474 6.636 5.379 4.142 C 5.229 3.8 4.746 3.78 4.585 4.117 C 4.131 5.077 3.876 6.149 3.876 7.28 C 3.876 9.217 4.808 11.025 6.203 12.364 C 6.562 12.711 6.915 12.998 7.266 13.261 L 3.331 14.245 C 3.038 14.318 2.907 14.658 3.072 14.912 C 3.547 15.648 4.723 16.895 7.261 16.999 C 7.48 17.007 7.698 16.928 7.864 16.783 L 9.648 15.249 L 11.751 15.249 C 14.167 15.249 16.125 13.293 16.125 10.876 L 16.125 6.499 L 17 4.749 L 13.937 4.749 Z M 13.937 7.391 C 13.697 7.391 13.458 7.175 13.458 6.935 C 13.458 6.695 13.697 6.483 13.937 6.483 C 14.177 6.483 14.399 6.703 14.399 6.943 C 14.399 7.183 14.177 7.391 13.937 7.391 Z" style="fill: #f26610;"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,11 +0,0 @@
|
||||
<svg viewBox="0 0 720 180" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs></defs>
|
||||
<g transform="matrix(7.5, 0, 0, 7.5, -2046.71228, -1592.744873)">
|
||||
<ellipse style="fill: #f26610;" cx="284.895" cy="224.366" rx="12" ry="12"></ellipse>
|
||||
<path d="M 285.769 221.936 L 285.769 221.168 C 284.999 220.175 284.482 219 284.292 217.745 C 284.234 217.375 283.772 217.231 283.532 217.518 C 282.954 218.199 282.5 218.998 282.194 219.883 C 283.129 220.931 284.381 221.65 285.769 221.936 Z M 288.832 219.115 C 287.624 219.115 286.646 220.097 286.646 221.305 L 286.646 222.929 C 283.79 222.76 281.369 221.002 280.274 218.508 C 280.124 218.166 279.641 218.146 279.48 218.483 C 279.026 219.443 278.771 220.515 278.771 221.646 C 278.771 223.583 279.703 225.391 281.098 226.73 C 281.457 227.077 281.81 227.364 282.161 227.627 L 278.226 228.611 C 277.933 228.684 277.802 229.024 277.967 229.278 C 278.442 230.014 279.618 231.261 282.156 231.365 C 282.375 231.373 282.593 231.294 282.759 231.149 L 284.543 229.615 L 286.646 229.615 C 289.062 229.615 291.02 227.659 291.02 225.242 L 291.02 220.865 L 291.895 219.115 L 288.832 219.115 Z M 288.832 221.757 C 288.592 221.757 288.353 221.541 288.353 221.301 C 288.353 221.061 288.592 220.849 288.832 220.849 C 289.072 220.849 289.294 221.069 289.294 221.309 C 289.294 221.549 289.072 221.757 288.832 221.757 Z" style="fill: #fffaf6;"></path>
|
||||
</g>
|
||||
<g transform="matrix(1, 0, 0, 1, -30, 0)">
|
||||
<path d="M 278.049 146.789 L 278.049 127.527 L 287.141 117.972 L 304.4 146.789 L 331.83 146.789 L 303.784 100.251 L 332.755 69.739 L 303.013 69.739 L 278.049 97.477 L 278.049 30.598 L 254.318 30.598 L 254.318 146.789 L 278.049 146.789 Z M 354.169 57.719 C 361.565 57.719 367.575 51.709 367.575 44.158 C 367.575 36.608 361.565 30.752 354.169 30.752 C 346.618 30.752 340.608 36.608 340.608 44.158 C 340.608 51.709 346.618 57.719 354.169 57.719 Z M 342.457 146.789 L 366.188 146.789 L 366.188 69.739 L 342.457 69.739 L 342.457 146.789 Z M 406.407 146.789 L 407.64 136.927 C 411.801 144.015 421.047 148.792 431.834 148.792 C 453.716 148.792 468.972 132.92 468.972 109.035 C 468.972 83.916 455.257 67.119 433.683 67.119 C 422.588 67.119 412.417 71.742 407.794 78.677 L 407.794 30.598 L 384.063 30.598 L 384.063 146.789 L 406.407 146.789 Z M 407.948 107.802 C 407.948 96.244 415.653 88.539 426.749 88.539 C 437.998 88.539 445.087 96.398 445.087 107.802 C 445.087 119.205 437.998 127.064 426.749 127.064 C 415.653 127.064 407.948 119.359 407.948 107.802 Z M 498.713 56.332 L 543.402 56.332 L 543.402 40.306 L 498.713 40.306 L 498.713 56.332 Z M 478.526 108.11 C 478.526 132.458 496.402 148.638 521.058 148.638 C 545.56 148.638 563.435 132.458 563.435 108.11 C 563.435 83.762 545.56 67.428 521.058 67.428 C 496.402 67.428 478.526 83.762 478.526 108.11 Z M 502.412 107.956 C 502.412 96.398 509.963 88.693 521.058 88.693 C 531.999 88.693 539.55 96.398 539.55 107.956 C 539.55 119.667 531.999 127.372 521.058 127.372 C 509.963 127.372 502.412 119.667 502.412 107.956 Z" style="fill: #fffaf6;"></path>
|
||||
<path d="M 589.19 97.802 L 589.19 106.23 L 610.948 106.23 C 605.1 112.938 597.446 119.044 587.986 124.376 L 593.404 131.514 C 597.532 128.934 601.488 126.268 605.186 123.43 L 605.186 146.048 L 614.13 146.048 L 614.13 123.43 L 626.944 123.43 L 626.944 149.402 L 635.974 149.402 L 635.974 123.43 L 649.82 123.43 L 649.82 134.008 C 649.82 136.072 649.046 137.104 647.498 137.104 L 640.36 136.674 L 642.768 145.188 L 650.422 145.188 C 655.926 145.188 658.678 142.092 658.678 135.986 L 658.678 115.174 L 635.974 115.174 L 635.974 108.638 L 626.944 108.638 L 626.944 115.174 L 614.388 115.174 C 617.054 112.336 619.548 109.326 621.784 106.23 L 665.128 106.23 L 665.128 97.802 L 626.858 97.802 C 627.89 95.824 628.836 93.76 629.696 91.61 L 620.838 90.492 C 619.806 92.9 618.516 95.394 617.14 97.802 L 589.19 97.802 Z M 648.1 68.734 C 642.338 72.088 636.232 75.098 629.868 77.678 C 621.612 75.012 612.926 72.518 603.896 70.282 L 599.252 77.248 C 605.272 78.624 611.206 80.258 617.226 82.15 C 610.088 84.386 602.606 86.106 594.78 87.482 L 599.596 95.308 C 612.324 92.04 622.472 89.116 630.04 86.364 C 638.124 89.116 646.122 92.298 654.034 95.824 L 658.936 88.428 C 653.26 86.02 647.412 83.698 641.392 81.548 C 646.208 79.226 651.11 76.56 655.926 73.55 L 648.1 68.734 Z M 675.438 77.85 L 675.438 85.848 L 682.404 85.848 L 682.404 98.92 C 682.404 101.5 681.114 103.22 678.62 104.166 L 680.684 110.874 C 692.036 108.896 701.926 106.66 710.182 104.08 L 708.634 96.426 C 703.474 98.146 697.454 99.608 690.574 100.984 L 690.574 85.848 L 712.332 85.848 L 712.332 77.85 L 698.916 77.85 C 698.4 74.668 697.884 71.744 697.368 69.164 L 688.338 70.712 C 688.94 72.862 689.542 75.27 690.144 77.85 L 675.438 77.85 Z M 724.028 89.632 L 739.25 89.632 L 739.25 93.502 L 723.856 93.502 C 723.942 92.47 724.028 91.352 724.028 90.32 L 724.028 89.632 Z M 739.25 83.096 L 724.028 83.096 L 724.028 79.226 L 739.25 79.226 L 739.25 83.096 Z M 722.652 100.038 L 739.25 100.038 L 739.25 100.898 C 739.25 103.048 738.218 104.166 736.24 104.166 C 733.918 104.166 731.424 103.994 728.758 103.822 L 730.822 111.562 L 738.734 111.562 C 744.582 111.562 747.506 108.982 747.506 103.908 L 747.506 72.002 L 715.6 72.002 L 715.6 90.922 C 715.428 97.286 713.192 102.532 708.892 106.746 L 715.342 112.594 C 718.782 109.068 721.276 104.854 722.652 100.038 Z M 708.462 121.452 L 708.462 126.784 L 683.608 126.784 L 683.608 134.352 L 708.462 134.352 L 708.462 139.598 L 675.524 139.598 L 675.524 147.51 L 750 147.51 L 750 139.598 L 717.062 139.598 L 717.062 134.352 L 742.174 134.352 L 742.174 126.784 L 717.062 126.784 L 717.062 121.452 L 746.216 121.452 L 746.216 113.712 L 679.308 113.712 L 679.308 121.452 L 708.462 121.452 Z" style="fill: #68625f"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.6 KiB |
@@ -1,11 +0,0 @@
|
||||
<svg viewBox="0 0 720 180" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs></defs>
|
||||
<g transform="matrix(7.5, 0, 0, 7.5, -2046.71228, -1592.744873)">
|
||||
<ellipse style="fill: #f26610;" cx="284.895" cy="224.366" rx="12" ry="12"></ellipse>
|
||||
<path d="M 285.769 221.936 L 285.769 221.168 C 284.999 220.175 284.482 219 284.292 217.745 C 284.234 217.375 283.772 217.231 283.532 217.518 C 282.954 218.199 282.5 218.998 282.194 219.883 C 283.129 220.931 284.381 221.65 285.769 221.936 Z M 288.832 219.115 C 287.624 219.115 286.646 220.097 286.646 221.305 L 286.646 222.929 C 283.79 222.76 281.369 221.002 280.274 218.508 C 280.124 218.166 279.641 218.146 279.48 218.483 C 279.026 219.443 278.771 220.515 278.771 221.646 C 278.771 223.583 279.703 225.391 281.098 226.73 C 281.457 227.077 281.81 227.364 282.161 227.627 L 278.226 228.611 C 277.933 228.684 277.802 229.024 277.967 229.278 C 278.442 230.014 279.618 231.261 282.156 231.365 C 282.375 231.373 282.593 231.294 282.759 231.149 L 284.543 229.615 L 286.646 229.615 C 289.062 229.615 291.02 227.659 291.02 225.242 L 291.02 220.865 L 291.895 219.115 L 288.832 219.115 Z M 288.832 221.757 C 288.592 221.757 288.353 221.541 288.353 221.301 C 288.353 221.061 288.592 220.849 288.832 220.849 C 289.072 220.849 289.294 221.069 289.294 221.309 C 289.294 221.549 289.072 221.757 288.832 221.757 Z" style="fill: #fffaf6;"></path>
|
||||
</g>
|
||||
<g transform="matrix(1, 0, 0, 1, -30, 0)">
|
||||
<path d="M 278.049 146.789 L 278.049 127.527 L 287.141 117.972 L 304.4 146.789 L 331.83 146.789 L 303.784 100.251 L 332.755 69.739 L 303.013 69.739 L 278.049 97.477 L 278.049 30.598 L 254.318 30.598 L 254.318 146.789 L 278.049 146.789 Z M 354.169 57.719 C 361.565 57.719 367.575 51.709 367.575 44.158 C 367.575 36.608 361.565 30.752 354.169 30.752 C 346.618 30.752 340.608 36.608 340.608 44.158 C 340.608 51.709 346.618 57.719 354.169 57.719 Z M 342.457 146.789 L 366.188 146.789 L 366.188 69.739 L 342.457 69.739 L 342.457 146.789 Z M 406.407 146.789 L 407.64 136.927 C 411.801 144.015 421.047 148.792 431.834 148.792 C 453.716 148.792 468.972 132.92 468.972 109.035 C 468.972 83.916 455.257 67.119 433.683 67.119 C 422.588 67.119 412.417 71.742 407.794 78.677 L 407.794 30.598 L 384.063 30.598 L 384.063 146.789 L 406.407 146.789 Z M 407.948 107.802 C 407.948 96.244 415.653 88.539 426.749 88.539 C 437.998 88.539 445.087 96.398 445.087 107.802 C 445.087 119.205 437.998 127.064 426.749 127.064 C 415.653 127.064 407.948 119.359 407.948 107.802 Z M 498.713 56.332 L 543.402 56.332 L 543.402 40.306 L 498.713 40.306 L 498.713 56.332 Z M 478.526 108.11 C 478.526 132.458 496.402 148.638 521.058 148.638 C 545.56 148.638 563.435 132.458 563.435 108.11 C 563.435 83.762 545.56 67.428 521.058 67.428 C 496.402 67.428 478.526 83.762 478.526 108.11 Z M 502.412 107.956 C 502.412 96.398 509.963 88.693 521.058 88.693 C 531.999 88.693 539.55 96.398 539.55 107.956 C 539.55 119.667 531.999 127.372 521.058 127.372 C 509.963 127.372 502.412 119.667 502.412 107.956 Z" style="fill: #12100f;"></path>
|
||||
<path d="M 589.19 97.802 L 589.19 106.23 L 610.948 106.23 C 605.1 112.938 597.446 119.044 587.986 124.376 L 593.404 131.514 C 597.532 128.934 601.488 126.268 605.186 123.43 L 605.186 146.048 L 614.13 146.048 L 614.13 123.43 L 626.944 123.43 L 626.944 149.402 L 635.974 149.402 L 635.974 123.43 L 649.82 123.43 L 649.82 134.008 C 649.82 136.072 649.046 137.104 647.498 137.104 L 640.36 136.674 L 642.768 145.188 L 650.422 145.188 C 655.926 145.188 658.678 142.092 658.678 135.986 L 658.678 115.174 L 635.974 115.174 L 635.974 108.638 L 626.944 108.638 L 626.944 115.174 L 614.388 115.174 C 617.054 112.336 619.548 109.326 621.784 106.23 L 665.128 106.23 L 665.128 97.802 L 626.858 97.802 C 627.89 95.824 628.836 93.76 629.696 91.61 L 620.838 90.492 C 619.806 92.9 618.516 95.394 617.14 97.802 L 589.19 97.802 Z M 648.1 68.734 C 642.338 72.088 636.232 75.098 629.868 77.678 C 621.612 75.012 612.926 72.518 603.896 70.282 L 599.252 77.248 C 605.272 78.624 611.206 80.258 617.226 82.15 C 610.088 84.386 602.606 86.106 594.78 87.482 L 599.596 95.308 C 612.324 92.04 622.472 89.116 630.04 86.364 C 638.124 89.116 646.122 92.298 654.034 95.824 L 658.936 88.428 C 653.26 86.02 647.412 83.698 641.392 81.548 C 646.208 79.226 651.11 76.56 655.926 73.55 L 648.1 68.734 Z M 675.438 77.85 L 675.438 85.848 L 682.404 85.848 L 682.404 98.92 C 682.404 101.5 681.114 103.22 678.62 104.166 L 680.684 110.874 C 692.036 108.896 701.926 106.66 710.182 104.08 L 708.634 96.426 C 703.474 98.146 697.454 99.608 690.574 100.984 L 690.574 85.848 L 712.332 85.848 L 712.332 77.85 L 698.916 77.85 C 698.4 74.668 697.884 71.744 697.368 69.164 L 688.338 70.712 C 688.94 72.862 689.542 75.27 690.144 77.85 L 675.438 77.85 Z M 724.028 89.632 L 739.25 89.632 L 739.25 93.502 L 723.856 93.502 C 723.942 92.47 724.028 91.352 724.028 90.32 L 724.028 89.632 Z M 739.25 83.096 L 724.028 83.096 L 724.028 79.226 L 739.25 79.226 L 739.25 83.096 Z M 722.652 100.038 L 739.25 100.038 L 739.25 100.898 C 739.25 103.048 738.218 104.166 736.24 104.166 C 733.918 104.166 731.424 103.994 728.758 103.822 L 730.822 111.562 L 738.734 111.562 C 744.582 111.562 747.506 108.982 747.506 103.908 L 747.506 72.002 L 715.6 72.002 L 715.6 90.922 C 715.428 97.286 713.192 102.532 708.892 106.746 L 715.342 112.594 C 718.782 109.068 721.276 104.854 722.652 100.038 Z M 708.462 121.452 L 708.462 126.784 L 683.608 126.784 L 683.608 134.352 L 708.462 134.352 L 708.462 139.598 L 675.524 139.598 L 675.524 147.51 L 750 147.51 L 750 139.598 L 717.062 139.598 L 717.062 134.352 L 742.174 134.352 L 742.174 126.784 L 717.062 126.784 L 717.062 121.452 L 746.216 121.452 L 746.216 113.712 L 679.308 113.712 L 679.308 121.452 L 708.462 121.452 Z" style="fill: #b4aca9;"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.6 KiB |
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<ellipse style="fill: #f26610;" cx="12" cy="12" rx="12" ry="12"/>
|
||||
<path d="M 12.874 9.57 L 12.874 8.802 C 12.104 7.809 11.587 6.634 11.397 5.379 C 11.339 5.009 10.877 4.865 10.637 5.152 C 10.059 5.833 9.605 6.632 9.299 7.517 C 10.234 8.565 11.486 9.284 12.874 9.57 Z M 15.937 6.749 C 14.729 6.749 13.751 7.731 13.751 8.939 L 13.751 10.563 C 10.895 10.394 8.474 8.636 7.379 6.142 C 7.229 5.8 6.746 5.78 6.585 6.117 C 6.131 7.077 5.876 8.149 5.876 9.28 C 5.876 11.217 6.808 13.025 8.203 14.364 C 8.562 14.711 8.915 14.998 9.266 15.261 L 5.331 16.245 C 5.038 16.318 4.907 16.658 5.072 16.912 C 5.547 17.648 6.723 18.895 9.261 18.999 C 9.48 19.007 9.698 18.928 9.864 18.783 L 11.648 17.249 L 13.751 17.249 C 16.167 17.249 18.125 15.293 18.125 12.876 L 18.125 8.499 L 19 6.749 L 15.937 6.749 Z M 15.937 9.391 C 15.697 9.391 15.458 9.175 15.458 8.935 C 15.458 8.695 15.697 8.483 15.937 8.483 C 16.177 8.483 16.399 8.703 16.399 8.943 C 16.399 9.183 16.177 9.391 15.937 9.391 Z" style="fill: #fffaf6;"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg viewBox="0 0 500 180" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs/>
|
||||
<g transform="matrix(1, 0, 0, 1, -252.158997, 0)">
|
||||
<path d="M 278.049 146.789 L 278.049 127.527 L 287.141 117.972 L 304.4 146.789 L 331.83 146.789 L 303.784 100.251 L 332.755 69.739 L 303.013 69.739 L 278.049 97.477 L 278.049 30.598 L 254.318 30.598 L 254.318 146.789 L 278.049 146.789 Z M 354.169 57.719 C 361.565 57.719 367.575 51.709 367.575 44.158 C 367.575 36.608 361.565 30.752 354.169 30.752 C 346.618 30.752 340.608 36.608 340.608 44.158 C 340.608 51.709 346.618 57.719 354.169 57.719 Z M 342.457 146.789 L 366.188 146.789 L 366.188 69.739 L 342.457 69.739 L 342.457 146.789 Z M 406.407 146.789 L 407.64 136.927 C 411.801 144.015 421.047 148.792 431.834 148.792 C 453.716 148.792 468.972 132.92 468.972 109.035 C 468.972 83.916 455.257 67.119 433.683 67.119 C 422.588 67.119 412.417 71.742 407.794 78.677 L 407.794 30.598 L 384.063 30.598 L 384.063 146.789 L 406.407 146.789 Z M 407.948 107.802 C 407.948 96.244 415.653 88.539 426.749 88.539 C 437.998 88.539 445.087 96.398 445.087 107.802 C 445.087 119.205 437.998 127.064 426.749 127.064 C 415.653 127.064 407.948 119.359 407.948 107.802 Z M 498.713 56.332 L 543.402 56.332 L 543.402 40.306 L 498.713 40.306 L 498.713 56.332 Z M 478.526 108.11 C 478.526 132.458 496.402 148.638 521.058 148.638 C 545.56 148.638 563.435 132.458 563.435 108.11 C 563.435 83.762 545.56 67.428 521.058 67.428 C 496.402 67.428 478.526 83.762 478.526 108.11 Z M 502.412 107.956 C 502.412 96.398 509.963 88.693 521.058 88.693 C 531.999 88.693 539.55 96.398 539.55 107.956 C 539.55 119.667 531.999 127.372 521.058 127.372 C 509.963 127.372 502.412 119.667 502.412 107.956 Z" style="fill: #fffaf6;"/>
|
||||
<path d="M 589.19 97.802 L 589.19 106.23 L 610.948 106.23 C 605.1 112.938 597.446 119.044 587.986 124.376 L 593.404 131.514 C 597.532 128.934 601.488 126.268 605.186 123.43 L 605.186 146.048 L 614.13 146.048 L 614.13 123.43 L 626.944 123.43 L 626.944 149.402 L 635.974 149.402 L 635.974 123.43 L 649.82 123.43 L 649.82 134.008 C 649.82 136.072 649.046 137.104 647.498 137.104 L 640.36 136.674 L 642.768 145.188 L 650.422 145.188 C 655.926 145.188 658.678 142.092 658.678 135.986 L 658.678 115.174 L 635.974 115.174 L 635.974 108.638 L 626.944 108.638 L 626.944 115.174 L 614.388 115.174 C 617.054 112.336 619.548 109.326 621.784 106.23 L 665.128 106.23 L 665.128 97.802 L 626.858 97.802 C 627.89 95.824 628.836 93.76 629.696 91.61 L 620.838 90.492 C 619.806 92.9 618.516 95.394 617.14 97.802 L 589.19 97.802 Z M 648.1 68.734 C 642.338 72.088 636.232 75.098 629.868 77.678 C 621.612 75.012 612.926 72.518 603.896 70.282 L 599.252 77.248 C 605.272 78.624 611.206 80.258 617.226 82.15 C 610.088 84.386 602.606 86.106 594.78 87.482 L 599.596 95.308 C 612.324 92.04 622.472 89.116 630.04 86.364 C 638.124 89.116 646.122 92.298 654.034 95.824 L 658.936 88.428 C 653.26 86.02 647.412 83.698 641.392 81.548 C 646.208 79.226 651.11 76.56 655.926 73.55 L 648.1 68.734 Z M 675.438 77.85 L 675.438 85.848 L 682.404 85.848 L 682.404 98.92 C 682.404 101.5 681.114 103.22 678.62 104.166 L 680.684 110.874 C 692.036 108.896 701.926 106.66 710.182 104.08 L 708.634 96.426 C 703.474 98.146 697.454 99.608 690.574 100.984 L 690.574 85.848 L 712.332 85.848 L 712.332 77.85 L 698.916 77.85 C 698.4 74.668 697.884 71.744 697.368 69.164 L 688.338 70.712 C 688.94 72.862 689.542 75.27 690.144 77.85 L 675.438 77.85 Z M 724.028 89.632 L 739.25 89.632 L 739.25 93.502 L 723.856 93.502 C 723.942 92.47 724.028 91.352 724.028 90.32 L 724.028 89.632 Z M 739.25 83.096 L 724.028 83.096 L 724.028 79.226 L 739.25 79.226 L 739.25 83.096 Z M 722.652 100.038 L 739.25 100.038 L 739.25 100.898 C 739.25 103.048 738.218 104.166 736.24 104.166 C 733.918 104.166 731.424 103.994 728.758 103.822 L 730.822 111.562 L 738.734 111.562 C 744.582 111.562 747.506 108.982 747.506 103.908 L 747.506 72.002 L 715.6 72.002 L 715.6 90.922 C 715.428 97.286 713.192 102.532 708.892 106.746 L 715.342 112.594 C 718.782 109.068 721.276 104.854 722.652 100.038 Z M 708.462 121.452 L 708.462 126.784 L 683.608 126.784 L 683.608 134.352 L 708.462 134.352 L 708.462 139.598 L 675.524 139.598 L 675.524 147.51 L 750 147.51 L 750 139.598 L 717.062 139.598 L 717.062 134.352 L 742.174 134.352 L 742.174 126.784 L 717.062 126.784 L 717.062 121.452 L 746.216 121.452 L 746.216 113.712 L 679.308 113.712 L 679.308 121.452 L 708.462 121.452 Z" style="fill: #867e7b;"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.4 KiB |
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg viewBox="0 0 500 180" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs/>
|
||||
<g transform="matrix(1, 0, 0, 1, -252.158997, 0)">
|
||||
<path d="M 278.049 146.789 L 278.049 127.527 L 287.141 117.972 L 304.4 146.789 L 331.83 146.789 L 303.784 100.251 L 332.755 69.739 L 303.013 69.739 L 278.049 97.477 L 278.049 30.598 L 254.318 30.598 L 254.318 146.789 L 278.049 146.789 Z M 354.169 57.719 C 361.565 57.719 367.575 51.709 367.575 44.158 C 367.575 36.608 361.565 30.752 354.169 30.752 C 346.618 30.752 340.608 36.608 340.608 44.158 C 340.608 51.709 346.618 57.719 354.169 57.719 Z M 342.457 146.789 L 366.188 146.789 L 366.188 69.739 L 342.457 69.739 L 342.457 146.789 Z M 406.407 146.789 L 407.64 136.927 C 411.801 144.015 421.047 148.792 431.834 148.792 C 453.716 148.792 468.972 132.92 468.972 109.035 C 468.972 83.916 455.257 67.119 433.683 67.119 C 422.588 67.119 412.417 71.742 407.794 78.677 L 407.794 30.598 L 384.063 30.598 L 384.063 146.789 L 406.407 146.789 Z M 407.948 107.802 C 407.948 96.244 415.653 88.539 426.749 88.539 C 437.998 88.539 445.087 96.398 445.087 107.802 C 445.087 119.205 437.998 127.064 426.749 127.064 C 415.653 127.064 407.948 119.359 407.948 107.802 Z M 498.713 56.332 L 543.402 56.332 L 543.402 40.306 L 498.713 40.306 L 498.713 56.332 Z M 478.526 108.11 C 478.526 132.458 496.402 148.638 521.058 148.638 C 545.56 148.638 563.435 132.458 563.435 108.11 C 563.435 83.762 545.56 67.428 521.058 67.428 C 496.402 67.428 478.526 83.762 478.526 108.11 Z M 502.412 107.956 C 502.412 96.398 509.963 88.693 521.058 88.693 C 531.999 88.693 539.55 96.398 539.55 107.956 C 539.55 119.667 531.999 127.372 521.058 127.372 C 509.963 127.372 502.412 119.667 502.412 107.956 Z" style="fill: rgb(16, 16, 14);"/>
|
||||
<path d="M 589.19 97.802 L 589.19 106.23 L 610.948 106.23 C 605.1 112.938 597.446 119.044 587.986 124.376 L 593.404 131.514 C 597.532 128.934 601.488 126.268 605.186 123.43 L 605.186 146.048 L 614.13 146.048 L 614.13 123.43 L 626.944 123.43 L 626.944 149.402 L 635.974 149.402 L 635.974 123.43 L 649.82 123.43 L 649.82 134.008 C 649.82 136.072 649.046 137.104 647.498 137.104 L 640.36 136.674 L 642.768 145.188 L 650.422 145.188 C 655.926 145.188 658.678 142.092 658.678 135.986 L 658.678 115.174 L 635.974 115.174 L 635.974 108.638 L 626.944 108.638 L 626.944 115.174 L 614.388 115.174 C 617.054 112.336 619.548 109.326 621.784 106.23 L 665.128 106.23 L 665.128 97.802 L 626.858 97.802 C 627.89 95.824 628.836 93.76 629.696 91.61 L 620.838 90.492 C 619.806 92.9 618.516 95.394 617.14 97.802 L 589.19 97.802 Z M 648.1 68.734 C 642.338 72.088 636.232 75.098 629.868 77.678 C 621.612 75.012 612.926 72.518 603.896 70.282 L 599.252 77.248 C 605.272 78.624 611.206 80.258 617.226 82.15 C 610.088 84.386 602.606 86.106 594.78 87.482 L 599.596 95.308 C 612.324 92.04 622.472 89.116 630.04 86.364 C 638.124 89.116 646.122 92.298 654.034 95.824 L 658.936 88.428 C 653.26 86.02 647.412 83.698 641.392 81.548 C 646.208 79.226 651.11 76.56 655.926 73.55 L 648.1 68.734 Z M 675.438 77.85 L 675.438 85.848 L 682.404 85.848 L 682.404 98.92 C 682.404 101.5 681.114 103.22 678.62 104.166 L 680.684 110.874 C 692.036 108.896 701.926 106.66 710.182 104.08 L 708.634 96.426 C 703.474 98.146 697.454 99.608 690.574 100.984 L 690.574 85.848 L 712.332 85.848 L 712.332 77.85 L 698.916 77.85 C 698.4 74.668 697.884 71.744 697.368 69.164 L 688.338 70.712 C 688.94 72.862 689.542 75.27 690.144 77.85 L 675.438 77.85 Z M 724.028 89.632 L 739.25 89.632 L 739.25 93.502 L 723.856 93.502 C 723.942 92.47 724.028 91.352 724.028 90.32 L 724.028 89.632 Z M 739.25 83.096 L 724.028 83.096 L 724.028 79.226 L 739.25 79.226 L 739.25 83.096 Z M 722.652 100.038 L 739.25 100.038 L 739.25 100.898 C 739.25 103.048 738.218 104.166 736.24 104.166 C 733.918 104.166 731.424 103.994 728.758 103.822 L 730.822 111.562 L 738.734 111.562 C 744.582 111.562 747.506 108.982 747.506 103.908 L 747.506 72.002 L 715.6 72.002 L 715.6 90.922 C 715.428 97.286 713.192 102.532 708.892 106.746 L 715.342 112.594 C 718.782 109.068 721.276 104.854 722.652 100.038 Z M 708.462 121.452 L 708.462 126.784 L 683.608 126.784 L 683.608 134.352 L 708.462 134.352 L 708.462 139.598 L 675.524 139.598 L 675.524 147.51 L 750 147.51 L 750 139.598 L 717.062 139.598 L 717.062 134.352 L 742.174 134.352 L 742.174 126.784 L 717.062 126.784 L 717.062 121.452 L 746.216 121.452 L 746.216 113.712 L 679.308 113.712 L 679.308 121.452 L 708.462 121.452 Z" style="fill: rgb(192, 192, 171);"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.4 KiB |
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg viewBox="0 0 310 180" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs/>
|
||||
<g transform="matrix(1, 0, 0, 1, -253.876495, 0)">
|
||||
<path d="M 278.049 146.789 L 278.049 127.527 L 287.141 117.972 L 304.4 146.789 L 331.83 146.789 L 303.784 100.251 L 332.755 69.739 L 303.013 69.739 L 278.049 97.477 L 278.049 30.598 L 254.318 30.598 L 254.318 146.789 L 278.049 146.789 Z M 354.169 57.719 C 361.565 57.719 367.575 51.709 367.575 44.158 C 367.575 36.608 361.565 30.752 354.169 30.752 C 346.618 30.752 340.608 36.608 340.608 44.158 C 340.608 51.709 346.618 57.719 354.169 57.719 Z M 342.457 146.789 L 366.188 146.789 L 366.188 69.739 L 342.457 69.739 L 342.457 146.789 Z M 406.407 146.789 L 407.64 136.927 C 411.801 144.015 421.047 148.792 431.834 148.792 C 453.716 148.792 468.972 132.92 468.972 109.035 C 468.972 83.916 455.257 67.119 433.683 67.119 C 422.588 67.119 412.417 71.742 407.794 78.677 L 407.794 30.598 L 384.063 30.598 L 384.063 146.789 L 406.407 146.789 Z M 407.948 107.802 C 407.948 96.244 415.653 88.539 426.749 88.539 C 437.998 88.539 445.087 96.398 445.087 107.802 C 445.087 119.205 437.998 127.064 426.749 127.064 C 415.653 127.064 407.948 119.359 407.948 107.802 Z M 498.713 56.332 L 543.402 56.332 L 543.402 40.306 L 498.713 40.306 L 498.713 56.332 Z M 478.526 108.11 C 478.526 132.458 496.402 148.638 521.058 148.638 C 545.56 148.638 563.435 132.458 563.435 108.11 C 563.435 83.762 545.56 67.428 521.058 67.428 C 496.402 67.428 478.526 83.762 478.526 108.11 Z M 502.412 107.956 C 502.412 96.398 509.963 88.693 521.058 88.693 C 531.999 88.693 539.55 96.398 539.55 107.956 C 539.55 119.667 531.999 127.372 521.058 127.372 C 509.963 127.372 502.412 119.667 502.412 107.956 Z" style="fill: rgb(16, 16, 14);"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg viewBox="0 0 310 180" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs/>
|
||||
<g transform="matrix(1, 0, 0, 1, -253.876495, 0)">
|
||||
<path d="M 278.049 146.789 L 278.049 127.527 L 287.141 117.972 L 304.4 146.789 L 331.83 146.789 L 303.784 100.251 L 332.755 69.739 L 303.013 69.739 L 278.049 97.477 L 278.049 30.598 L 254.318 30.598 L 254.318 146.789 L 278.049 146.789 Z M 354.169 57.719 C 361.565 57.719 367.575 51.709 367.575 44.158 C 367.575 36.608 361.565 30.752 354.169 30.752 C 346.618 30.752 340.608 36.608 340.608 44.158 C 340.608 51.709 346.618 57.719 354.169 57.719 Z M 342.457 146.789 L 366.188 146.789 L 366.188 69.739 L 342.457 69.739 L 342.457 146.789 Z M 406.407 146.789 L 407.64 136.927 C 411.801 144.015 421.047 148.792 431.834 148.792 C 453.716 148.792 468.972 132.92 468.972 109.035 C 468.972 83.916 455.257 67.119 433.683 67.119 C 422.588 67.119 412.417 71.742 407.794 78.677 L 407.794 30.598 L 384.063 30.598 L 384.063 146.789 L 406.407 146.789 Z M 407.948 107.802 C 407.948 96.244 415.653 88.539 426.749 88.539 C 437.998 88.539 445.087 96.398 445.087 107.802 C 445.087 119.205 437.998 127.064 426.749 127.064 C 415.653 127.064 407.948 119.359 407.948 107.802 Z M 498.713 56.332 L 543.402 56.332 L 543.402 40.306 L 498.713 40.306 L 498.713 56.332 Z M 478.526 108.11 C 478.526 132.458 496.402 148.638 521.058 148.638 C 545.56 148.638 563.435 132.458 563.435 108.11 C 563.435 83.762 545.56 67.428 521.058 67.428 C 496.402 67.428 478.526 83.762 478.526 108.11 Z M 502.412 107.956 C 502.412 96.398 509.963 88.693 521.058 88.693 C 531.999 88.693 539.55 96.398 539.55 107.956 C 539.55 119.667 531.999 127.372 521.058 127.372 C 509.963 127.372 502.412 119.667 502.412 107.956 Z" style="fill: rgb(16, 16, 14);"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 496 KiB |
|
Before Width: | Height: | Size: 564 KiB |
|
Before Width: | Height: | Size: 592 KiB |
|
Before Width: | Height: | Size: 453 KiB |
|
Before Width: | Height: | Size: 526 KiB |
|
Before Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 386 KiB |
@@ -1,466 +0,0 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711"
|
||||
|
||||
[[package]]
|
||||
name = "base58ck"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f"
|
||||
dependencies = [
|
||||
"bitcoin-internals",
|
||||
"bitcoin_hashes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
|
||||
|
||||
[[package]]
|
||||
name = "bech32"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d965446196e3b7decd44aa7ee49e31d630118f90ef12f97900f262eb915c951d"
|
||||
|
||||
[[package]]
|
||||
name = "bitcoin"
|
||||
version = "0.32.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea507acc1cd80fc084ace38544bbcf7ced7c2aa65b653b102de0ce718df668f6"
|
||||
dependencies = [
|
||||
"base58ck",
|
||||
"bech32",
|
||||
"bitcoin-internals",
|
||||
"bitcoin-io",
|
||||
"bitcoin-units",
|
||||
"bitcoin_hashes",
|
||||
"hex-conservative",
|
||||
"hex_lit",
|
||||
"secp256k1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitcoin-internals"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitcoin-io"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "340e09e8399c7bd8912f495af6aa58bea0c9214773417ffaa8f6460f93aaee56"
|
||||
|
||||
[[package]]
|
||||
name = "bitcoin-units"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2"
|
||||
dependencies = [
|
||||
"bitcoin-internals",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitcoin_hashes"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16"
|
||||
dependencies = [
|
||||
"bitcoin-io",
|
||||
"hex-conservative",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitcoincore-rpc"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedd23ae0fd321affb4bbbc36126c6f49a32818dc6b979395d24da8c9d4e80ee"
|
||||
dependencies = [
|
||||
"bitcoincore-rpc-json",
|
||||
"jsonrpc",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitcoincore-rpc-json"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8909583c5fab98508e80ef73e5592a651c954993dc6b7739963257d19f0e71a"
|
||||
dependencies = [
|
||||
"bitcoin",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "biter"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"bitcoin",
|
||||
"bitcoincore-rpc",
|
||||
"crossbeam",
|
||||
"derived-deref",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2aba8f4e9906c7ce3c73463f62a7f0c65183ada1a2d47e397cc8810827f9694f"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"crossbeam-deque",
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-queue",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df0346b5d5e76ac2fe4e327c5fd1118d6be7c51dfb18f9b7922923f287471e35"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80"
|
||||
|
||||
[[package]]
|
||||
name = "derived-deref"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "805ef2023ccd65425743a91ecd11fc020979a0b01921db3104fb606d18a7b43e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hex-conservative"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5313b072ce3c597065a808dbf612c4c8e8590bdbf8b579508bf7a762c5eae6cd"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hex_lit"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
|
||||
|
||||
[[package]]
|
||||
name = "jsonrpc"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3662a38d341d77efecb73caf01420cfa5aa63c0253fd7bc05289ef9f6616e1bf"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"minreq",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.155"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
||||
|
||||
[[package]]
|
||||
name = "minreq"
|
||||
version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "763d142cdff44aaadd9268bebddb156ef6c65a0e13486bb81673cf2d8739f9b0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dee4364d9f3b902ef14fab8a1ddffb783a1cb6b4bba3bfc1fa3922732c7de97f"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.86"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e0cc0f1cf93f4969faf3ea1c7d8a9faed25918d96affa959720823dfe86d4f3"
|
||||
dependencies = [
|
||||
"bitcoin_hashes",
|
||||
"rand",
|
||||
"secp256k1-sys",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1-sys"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1433bd67156263443f14d603720b082dd3121779323fce20cba2aa07b874bc1b"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.204"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.204"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.122"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "784b6203951c57ff748476b126ccb5e8e2959a5c19e5c617ab1956be3dbc68da"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.72"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc4b9b9bf2add8093d3f2c0204471e951b2285580335de42f9d2534f3ae7a8af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.0+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "854e949ac82d619ee9a14c66a1b674ac730422372ccb759ce0c39cabcf2bf8e6"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "125139de3f6b9d625c39e2efdd73d41bdac468ccd556556440e322be0e1bbd91"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
@@ -1,19 +0,0 @@
|
||||
[package]
|
||||
name = "biter"
|
||||
description = "A very fast Bitcoin block iterator"
|
||||
version = "0.1.1"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/kibo-money/kibo/tree/main/biter"
|
||||
keywords = ["bitcoin", "block", "iterator"]
|
||||
categories = ["cryptography::cryptocurrencies", "encoding"]
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
bitcoin = { version = "0.32.2", features = ["serde"] }
|
||||
rayon = "1.10.0"
|
||||
crossbeam = { version = "0.8.4", features = ["crossbeam-channel"] }
|
||||
serde = { version = "1.0.204", features = ["derive"] }
|
||||
serde_json = "1.0.122"
|
||||
derived-deref = "2.1.0"
|
||||
bitcoincore-rpc = "0.19.0"
|
||||
# tokio = { version = "1.39.2", features = ["rt-multi-thread"] }
|
||||
@@ -1,62 +0,0 @@
|
||||
# Biter
|
||||
|
||||
Biter (Bitcoin Block Iterator) is a very fast and simple Rust library which reads raw block files (*blkXXXXX.dat*) from Bitcoin Core Node and creates an iterator over all the requested blocks in sequential order (0, 1, 2, ...).
|
||||
|
||||
The element returned by the iterator is a tuple which includes the:
|
||||
- Height: `usize`
|
||||
- Block: `Block` (from `bitcoin-rust`)
|
||||
- Block's Hash: `BlockHash` (also from `bitcoin-rust`)
|
||||
|
||||
## Example
|
||||
|
||||
```rust
|
||||
use bitcoincore_rpc::{Auth, Client};
|
||||
|
||||
fn main() {
|
||||
let i = std::time::Instant::now();
|
||||
|
||||
// Path to the Bitcoin data directory
|
||||
let data_dir = "../../bitcoin";
|
||||
|
||||
// Path to the export directory where a mini blk indexer will be exported
|
||||
let export_dir = "./target";
|
||||
|
||||
// Inclusive starting height of the blocks received, `None` for 0
|
||||
let start = Some(850_000);
|
||||
|
||||
// Inclusive ending height of the blocks received, `None` for the last one
|
||||
let end = None;
|
||||
|
||||
// RPC client to filter out forks
|
||||
let url = "http://localhost:8332";
|
||||
let auth = Auth::UserPass("satoshi".to_string(), "nakamoto".to_string());
|
||||
let rpc = Client::new(url, auth).unwrap();
|
||||
|
||||
// Create channel receiver then iterate over the blocks
|
||||
biter::new(data_dir, export_dir, start, end, rpc)
|
||||
.iter()
|
||||
.for_each(|(height, _block, hash)| {
|
||||
println!("{height}: {hash}");
|
||||
});
|
||||
|
||||
dbg!(i.elapsed());
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
Even though it reads *blkXXXXX.dat* files, it **needs** `bitcoind` to run with the RPC server to filter out block forks.
|
||||
|
||||
Peak memory should be around 500MB.
|
||||
|
||||
## Comparaison
|
||||
|
||||
| | [biter](https://crates.io/crates/biter) | [bitcoin-explorer](https://crates.io/crates/bitcoin-explorer) | [blocks_iterator](https://crates.io/crates/blocks_iterator) |
|
||||
| --- | --- | --- | --- |
|
||||
| Run **with** `bitcoind` | Yes ✅ | No ❌ | Yes ✅ |
|
||||
| Run **without** `bitcoind` | No ❌ | Yes ✅ | Yes ✅ |
|
||||
| `0..=855_000` | 16mn40s | 17mn 46s | > 2h |
|
||||
| `800_000..=855_000` | 2mn 53s (16mn40s if first run) | 3mn 2s | > 2h |
|
||||
|
||||
*Benchmarked on a Macbook Pro M3 Pro*
|
||||
@@ -1,105 +0,0 @@
|
||||
use std::{
|
||||
cmp::Ordering,
|
||||
collections::BTreeMap,
|
||||
fs::{self, File},
|
||||
io::{BufReader, BufWriter},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use derived_deref::{Deref, DerefMut};
|
||||
|
||||
use crate::{blk_recap::BlkRecap, BlkMetadataAndBlock};
|
||||
|
||||
#[derive(Deref, DerefMut, Debug)]
|
||||
pub struct BlkIndexToBlkRecap {
|
||||
path: String,
|
||||
#[target]
|
||||
tree: BTreeMap<usize, BlkRecap>,
|
||||
}
|
||||
|
||||
impl BlkIndexToBlkRecap {
|
||||
pub fn import(blocks_dir: &BTreeMap<usize, PathBuf>, export_dir: &str) -> Self {
|
||||
let path = format!("{export_dir}/blk_index_to_blk_recap.json");
|
||||
|
||||
let tree = {
|
||||
fs::create_dir_all(export_dir).unwrap();
|
||||
|
||||
if let Ok(file) = File::open(&path) {
|
||||
let reader = BufReader::new(file);
|
||||
serde_json::from_reader(reader).unwrap_or_default()
|
||||
} else {
|
||||
BTreeMap::default()
|
||||
}
|
||||
};
|
||||
|
||||
let mut this = Self { path, tree };
|
||||
|
||||
this.clean_outdated(blocks_dir);
|
||||
|
||||
this
|
||||
}
|
||||
|
||||
pub fn clean_outdated(&mut self, blocks_dir: &BTreeMap<usize, PathBuf>) {
|
||||
blocks_dir.iter().for_each(|(blk_index, blk_path)| {
|
||||
if let Some(blk_recap) = self.get(blk_index) {
|
||||
if blk_recap.has_different_modified_time(blk_path) {
|
||||
self.remove(blk_index);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn get_start_recap(&self, start: Option<usize>) -> Option<(usize, BlkRecap)> {
|
||||
if let Some(start) = start {
|
||||
let (last_key, last_value) = self.last_key_value()?;
|
||||
|
||||
if last_value.height() < start {
|
||||
return Some((*last_key, *last_value));
|
||||
} else if let Some((blk_index, _)) = self
|
||||
.iter()
|
||||
.find(|(_, blk_recap)| blk_recap.is_younger_than(start))
|
||||
{
|
||||
if *blk_index != 0 {
|
||||
let blk_index = *blk_index - 1;
|
||||
return Some((blk_index, *self.get(&blk_index).unwrap()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn update(&mut self, blk_metadata_and_block: &BlkMetadataAndBlock, height: usize) {
|
||||
let blk_index = blk_metadata_and_block.blk_metadata.index;
|
||||
|
||||
if let Some(last_entry) = self.last_entry() {
|
||||
// if last_entry.get().is_older_than(height) {
|
||||
match last_entry.key().cmp(&blk_index) {
|
||||
Ordering::Greater => {
|
||||
last_entry.remove_entry();
|
||||
}
|
||||
Ordering::Less => {
|
||||
self.insert(blk_index, BlkRecap::from(height, blk_metadata_and_block));
|
||||
}
|
||||
Ordering::Equal => {}
|
||||
};
|
||||
// }
|
||||
} else {
|
||||
if blk_index != 0 || height != 0 {
|
||||
// dbg!(blk_index, height);
|
||||
unreachable!();
|
||||
}
|
||||
|
||||
self.insert(blk_index, BlkRecap::first(blk_metadata_and_block));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn export(&self) {
|
||||
let file = File::create(&self.path).unwrap_or_else(|_| {
|
||||
dbg!(&self.path);
|
||||
panic!("No such file or directory")
|
||||
});
|
||||
|
||||
serde_json::to_writer_pretty(&mut BufWriter::new(file), &self.tree).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::path_to_modified_time;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct BlkMetadata {
|
||||
pub index: usize,
|
||||
pub modified_time: u64,
|
||||
}
|
||||
|
||||
impl BlkMetadata {
|
||||
pub fn new(index: usize, path: &PathBuf) -> Self {
|
||||
Self {
|
||||
index,
|
||||
modified_time: path_to_modified_time(path),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
use bitcoin::Block;
|
||||
|
||||
use crate::BlkMetadata;
|
||||
|
||||
pub struct BlkMetadataAndBlock {
|
||||
pub blk_metadata: BlkMetadata,
|
||||
pub block: Block,
|
||||
}
|
||||
|
||||
impl BlkMetadataAndBlock {
|
||||
pub fn new(blk_metadata: BlkMetadata, block: Block) -> Self {
|
||||
Self {
|
||||
blk_metadata,
|
||||
block,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bitcoin::{hashes::Hash, BlockHash};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{path_to_modified_time, BlkMetadataAndBlock};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct BlkRecap {
|
||||
min_continuous_height: usize,
|
||||
min_continuous_prev_hash: BlockHash,
|
||||
modified_time: u64,
|
||||
}
|
||||
|
||||
impl BlkRecap {
|
||||
pub fn first(blk_metadata_and_block: &BlkMetadataAndBlock) -> Self {
|
||||
Self {
|
||||
min_continuous_height: 0,
|
||||
min_continuous_prev_hash: BlockHash::all_zeros(),
|
||||
modified_time: blk_metadata_and_block.blk_metadata.modified_time,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from(height: usize, blk_metadata_and_block: &BlkMetadataAndBlock) -> Self {
|
||||
Self {
|
||||
min_continuous_height: height,
|
||||
min_continuous_prev_hash: blk_metadata_and_block.block.header.prev_blockhash,
|
||||
modified_time: blk_metadata_and_block.blk_metadata.modified_time,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_different_modified_time(&self, blk_path: &PathBuf) -> bool {
|
||||
self.modified_time != path_to_modified_time(blk_path)
|
||||
}
|
||||
|
||||
pub fn is_younger_than(&self, height: usize) -> bool {
|
||||
self.min_continuous_height > height
|
||||
}
|
||||
|
||||
pub fn height(&self) -> usize {
|
||||
self.min_continuous_height
|
||||
}
|
||||
|
||||
pub fn prev_hash(&self) -> &BlockHash {
|
||||
&self.min_continuous_prev_hash
|
||||
}
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, VecDeque},
|
||||
fs::{self},
|
||||
ops::ControlFlow,
|
||||
thread,
|
||||
};
|
||||
|
||||
use bitcoin::{
|
||||
consensus::{Decodable, ReadExt},
|
||||
hashes::Hash,
|
||||
io::{Cursor, Read},
|
||||
Block, BlockHash,
|
||||
};
|
||||
use bitcoincore_rpc::RpcApi;
|
||||
use crossbeam::channel::{bounded, Receiver};
|
||||
use rayon::prelude::*;
|
||||
|
||||
pub use bitcoin;
|
||||
pub use bitcoincore_rpc;
|
||||
|
||||
mod blk_index_to_blk_recap;
|
||||
mod blk_metadata;
|
||||
mod blk_metadata_and_block;
|
||||
mod blk_recap;
|
||||
mod utils;
|
||||
|
||||
use blk_index_to_blk_recap::*;
|
||||
use blk_metadata::*;
|
||||
use blk_metadata_and_block::*;
|
||||
use utils::*;
|
||||
|
||||
pub const NUMBER_OF_UNSAFE_BLOCKS: usize = 100;
|
||||
const MAGIC_BYTES: [u8; 4] = [249, 190, 180, 217];
|
||||
const BOUND_CAP: usize = 210;
|
||||
|
||||
enum BlockState {
|
||||
Raw(Vec<u8>),
|
||||
Decoded(Block),
|
||||
}
|
||||
|
||||
///
|
||||
/// Returns a crossbeam channel receiver that receives `(usize, Block, BlockHash)` tuples (with `usize` being the height) in sequential order.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `data_dir` - Path to the Bitcoin data directory
|
||||
/// * `export_dir` - Path to the export directory where a mini blk indexer will be exported
|
||||
/// * `start` - Inclusive starting height of the blocks received, `None` for 0
|
||||
/// * `end` - Inclusive ending height of the blocks received, `None` for the last one
|
||||
/// * `rpc` - RPC client to filter out forks
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use bitcoincore_rpc::{Auth, Client};
|
||||
///
|
||||
/// fn main() {
|
||||
/// let i = std::time::Instant::now();
|
||||
///
|
||||
/// let url = "http://localhost:8332";
|
||||
/// let auth = Auth::UserPass("satoshi".to_string(), "nakamoto".to_string());
|
||||
/// let rpc = Client::new(url, auth).unwrap();
|
||||
///
|
||||
/// let data_dir = "../../bitcoin";
|
||||
/// let export_dir = "./target";
|
||||
/// let start = Some(850_000);
|
||||
/// let end = None;
|
||||
///
|
||||
/// biter::new(data_dir, export_dir, start, end, rpc)
|
||||
/// .iter()
|
||||
/// .for_each(|(height, _block, hash)| {
|
||||
/// println!("{height}: {hash}");
|
||||
/// });
|
||||
///
|
||||
/// dbg!(i.elapsed());
|
||||
///}
|
||||
/// ```
|
||||
///
|
||||
pub fn new(
|
||||
data_dir: &str,
|
||||
export_dir: &str,
|
||||
start: Option<usize>,
|
||||
end: Option<usize>,
|
||||
rpc: bitcoincore_rpc::Client,
|
||||
) -> Receiver<(usize, Block, BlockHash)> {
|
||||
let (send_block_reader, recv_block_reader) = bounded(BOUND_CAP);
|
||||
let (send_block, recv_block) = bounded(BOUND_CAP);
|
||||
let (send_height_block_hash, recv_height_block_hash) = bounded(BOUND_CAP);
|
||||
|
||||
let blocks_dir = scan_blocks_dir(data_dir);
|
||||
|
||||
let mut blk_index_to_blk_recap = BlkIndexToBlkRecap::import(&blocks_dir, export_dir);
|
||||
|
||||
let start_recap = blk_index_to_blk_recap.get_start_recap(start);
|
||||
let starting_blk_index = start_recap.as_ref().map_or(0, |(index, _)| *index);
|
||||
|
||||
thread::spawn(move || {
|
||||
blocks_dir
|
||||
.into_iter()
|
||||
.filter(|(blk_index, _)| blk_index >= &starting_blk_index)
|
||||
.try_for_each(move |(blk_index, blk_path)| {
|
||||
let blk_metadata = BlkMetadata::new(blk_index, &blk_path);
|
||||
|
||||
let blk_bytes = fs::read(&blk_path).unwrap();
|
||||
let blk_bytes_len = blk_bytes.len() as u64;
|
||||
|
||||
let mut cursor = Cursor::new(blk_bytes.as_slice());
|
||||
|
||||
let mut current_4bytes = [0; 4];
|
||||
|
||||
'parent: loop {
|
||||
if cursor.position() == blk_bytes_len {
|
||||
break;
|
||||
}
|
||||
|
||||
// Read until we find a valid suite of MAGIC_BYTES
|
||||
loop {
|
||||
current_4bytes.rotate_left(1);
|
||||
|
||||
if let Ok(byte) = cursor.read_u8() {
|
||||
current_4bytes[3] = byte;
|
||||
} else {
|
||||
break 'parent;
|
||||
}
|
||||
|
||||
if current_4bytes == MAGIC_BYTES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let block_size = cursor.read_u32().unwrap();
|
||||
|
||||
let mut raw_block = vec![0u8; block_size as usize];
|
||||
|
||||
cursor.read_exact(&mut raw_block).unwrap();
|
||||
|
||||
if send_block_reader
|
||||
.send((blk_metadata, BlockState::Raw(raw_block)))
|
||||
.is_err()
|
||||
{
|
||||
return ControlFlow::Break(());
|
||||
}
|
||||
}
|
||||
|
||||
ControlFlow::Continue(())
|
||||
})
|
||||
});
|
||||
|
||||
// thread::spawn(move || {
|
||||
// recv_block_reader.iter().par_bridge().try_for_each(
|
||||
// move |(blk_metadata, mut block_state)| {
|
||||
// let raw_block = match block_state {
|
||||
// BlockState::Raw(vec) => vec,
|
||||
// _ => unreachable!(),
|
||||
// };
|
||||
|
||||
// let mut cursor = Cursor::new(raw_block);
|
||||
|
||||
// block_state = BlockState::Decoded(Block::consensus_decode(&mut cursor).unwrap());
|
||||
|
||||
// if send_block
|
||||
// .send(BlkMetadataAndBlock::new(
|
||||
// blk_metadata,
|
||||
// match block_state {
|
||||
// BlockState::Decoded(block) => block,
|
||||
// _ => unreachable!(),
|
||||
// },
|
||||
// ))
|
||||
// .is_err()
|
||||
// {
|
||||
// return ControlFlow::Break(());
|
||||
// }
|
||||
|
||||
// ControlFlow::Continue(())
|
||||
// },
|
||||
// );
|
||||
// });
|
||||
|
||||
// Can't use the previous code because .send() blocks all the threads if full
|
||||
// And other .par_iter() are also stuck because of that
|
||||
thread::spawn(move || {
|
||||
let mut bulk = vec![];
|
||||
|
||||
let drain_and_send = |bulk: &mut Vec<_>| {
|
||||
// Using a vec and sending after to not end up with stuck threads in par iter
|
||||
bulk.par_iter_mut().for_each(|(_, block_state)| {
|
||||
let raw_block = match block_state {
|
||||
BlockState::Raw(vec) => vec,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let mut cursor = Cursor::new(raw_block);
|
||||
|
||||
*block_state = BlockState::Decoded(Block::consensus_decode(&mut cursor).unwrap());
|
||||
});
|
||||
|
||||
bulk.drain(..).try_for_each(|(blk_metadata, block_state)| {
|
||||
let block = match block_state {
|
||||
BlockState::Decoded(block) => block,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
if send_block
|
||||
.send(BlkMetadataAndBlock::new(blk_metadata, block))
|
||||
.is_err()
|
||||
{
|
||||
return ControlFlow::Break(());
|
||||
}
|
||||
|
||||
ControlFlow::Continue(())
|
||||
})
|
||||
};
|
||||
|
||||
recv_block_reader.iter().try_for_each(|tuple| {
|
||||
bulk.push(tuple);
|
||||
|
||||
if bulk.len() < BOUND_CAP / 2 {
|
||||
return ControlFlow::Continue(());
|
||||
}
|
||||
|
||||
drain_and_send(&mut bulk)
|
||||
});
|
||||
|
||||
drain_and_send(&mut bulk)
|
||||
});
|
||||
|
||||
// Tokio version: 1022s
|
||||
// Slighlty slower than rayon version
|
||||
// thread::spawn(move || {
|
||||
// let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
// let _guard = rt.enter();
|
||||
|
||||
// let mut tasks = VecDeque::with_capacity(BOUND);
|
||||
|
||||
// recv_block_reader
|
||||
// .iter()
|
||||
// .try_for_each(move |(blk_metadata, block_state)| {
|
||||
// let raw_block = match block_state {
|
||||
// BlockState::Raw(vec) => vec,
|
||||
// _ => unreachable!(),
|
||||
// };
|
||||
|
||||
// tasks.push_back(tokio::task::spawn(async move {
|
||||
// let block = Block::consensus_decode(&mut Cursor::new(raw_block)).unwrap();
|
||||
|
||||
// (blk_metadata, block)
|
||||
// }));
|
||||
|
||||
// while tasks.len() > BOUND {
|
||||
// let (blk_metadata, block) = rt.block_on(tasks.pop_front().unwrap()).unwrap();
|
||||
|
||||
// if send_block
|
||||
// .send(BlkMetadataAndBlock::new(blk_metadata, block))
|
||||
// .is_err()
|
||||
// {
|
||||
// return ControlFlow::Break(());
|
||||
// }
|
||||
// }
|
||||
|
||||
// ControlFlow::Continue(())
|
||||
// });
|
||||
//
|
||||
// todo!("Send the rest")
|
||||
// });
|
||||
|
||||
thread::spawn(move || {
|
||||
let mut height = start_recap.map_or(0, |(_, recap)| recap.height());
|
||||
|
||||
let mut future_blocks = BTreeMap::default();
|
||||
let mut recent_chain: VecDeque<(BlockHash, BlkMetadataAndBlock)> = VecDeque::default();
|
||||
let mut recent_hashes: BTreeSet<BlockHash> = BTreeSet::default();
|
||||
|
||||
let mut prev_hash =
|
||||
start_recap.map_or_else(BlockHash::all_zeros, |(_, recap)| *recap.prev_hash());
|
||||
|
||||
let mut prepare_and_send = |(hash, tuple): (BlockHash, BlkMetadataAndBlock)| {
|
||||
blk_index_to_blk_recap.update(&tuple, height);
|
||||
|
||||
if start.map_or(true, |start| start <= height) {
|
||||
send_height_block_hash
|
||||
.send((height, tuple.block, hash))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
if end.map_or(false, |end| height == end) {
|
||||
return ControlFlow::Break(());
|
||||
}
|
||||
|
||||
height += 1;
|
||||
|
||||
ControlFlow::Continue(())
|
||||
};
|
||||
|
||||
let mut update_tip = |prev_hash: &mut BlockHash,
|
||||
recent_hashes: &mut BTreeSet<BlockHash>,
|
||||
recent_chain: &mut VecDeque<(BlockHash, BlkMetadataAndBlock)>,
|
||||
future_blocks: &mut BTreeMap<BlockHash, BlkMetadataAndBlock>,
|
||||
tuple: BlkMetadataAndBlock| {
|
||||
let mut tuple = Some(tuple);
|
||||
|
||||
while let Some(tuple) = tuple.take().or_else(|| future_blocks.remove(prev_hash)) {
|
||||
let hash = tuple.block.block_hash();
|
||||
|
||||
*prev_hash = hash;
|
||||
recent_hashes.insert(hash);
|
||||
recent_chain.push_back((hash, tuple));
|
||||
}
|
||||
|
||||
while recent_chain.len() > NUMBER_OF_UNSAFE_BLOCKS {
|
||||
let (hash, tuple) = recent_chain.pop_front().unwrap();
|
||||
|
||||
recent_hashes.remove(&hash);
|
||||
|
||||
if prepare_and_send((hash, tuple)).is_break() {
|
||||
return ControlFlow::Break(());
|
||||
}
|
||||
}
|
||||
|
||||
ControlFlow::Continue(())
|
||||
};
|
||||
|
||||
let flow = recv_block.iter().try_for_each(|tuple| {
|
||||
// block isn't next after current tip
|
||||
if prev_hash != tuple.block.header.prev_blockhash {
|
||||
let is_block_active =
|
||||
|hash| rpc.get_block_header_info(hash).unwrap().confirmations > 0;
|
||||
|
||||
// block prev has already been processed
|
||||
if recent_hashes.contains(&tuple.block.header.prev_blockhash) {
|
||||
let hash = tuple.block.block_hash();
|
||||
|
||||
if is_block_active(&hash) {
|
||||
let prev_index = recent_chain
|
||||
.iter()
|
||||
.position(|(hash, ..)| hash == &tuple.block.header.prev_blockhash)
|
||||
.unwrap();
|
||||
|
||||
let bad_index_start = prev_index + 1;
|
||||
|
||||
recent_chain.drain(bad_index_start..).for_each(|(hash, _)| {
|
||||
recent_hashes.remove(&hash);
|
||||
});
|
||||
|
||||
return update_tip(
|
||||
&mut prev_hash,
|
||||
&mut recent_hashes,
|
||||
&mut recent_chain,
|
||||
&mut future_blocks,
|
||||
tuple,
|
||||
);
|
||||
}
|
||||
// Check if there was already a future block with the same prev hash
|
||||
} else if let Some(prev_tuple) =
|
||||
future_blocks.insert(tuple.block.header.prev_blockhash, tuple)
|
||||
{
|
||||
// If the previous was the active one
|
||||
if is_block_active(&prev_tuple.block.block_hash()) {
|
||||
// Rollback the insert
|
||||
future_blocks.insert(prev_tuple.block.header.prev_blockhash, prev_tuple);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return update_tip(
|
||||
&mut prev_hash,
|
||||
&mut recent_hashes,
|
||||
&mut recent_chain,
|
||||
&mut future_blocks,
|
||||
tuple,
|
||||
);
|
||||
}
|
||||
|
||||
ControlFlow::Continue(())
|
||||
});
|
||||
|
||||
if flow.is_continue() {
|
||||
// Send the last (up to 100) blocks
|
||||
recent_chain.into_iter().try_for_each(prepare_and_send);
|
||||
}
|
||||
|
||||
blk_index_to_blk_recap.export();
|
||||
});
|
||||
|
||||
recv_height_block_hash
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
use bitcoincore_rpc::{Auth, Client};
|
||||
|
||||
fn main() {
|
||||
let i = std::time::Instant::now();
|
||||
|
||||
let url = "http://localhost:8332";
|
||||
let auth = Auth::UserPass("satoshi".to_string(), "nakamoto".to_string());
|
||||
let rpc = Client::new(url, auth).unwrap();
|
||||
|
||||
let data_dir = "../bitcoin";
|
||||
let export_dir = "./target";
|
||||
let start = None;
|
||||
let end = None;
|
||||
|
||||
biter::new(data_dir, export_dir, start, end, rpc)
|
||||
.iter()
|
||||
.for_each(|(height, _block, hash)| {
|
||||
println!("{height}: {hash}");
|
||||
});
|
||||
|
||||
dbg!(i.elapsed());
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
use std::{collections::BTreeMap, fs, path::PathBuf, time::UNIX_EPOCH};
|
||||
|
||||
const BLK: &str = "blk";
|
||||
const DAT: &str = ".dat";
|
||||
|
||||
pub fn scan_blocks_dir(data_dir_path: &str) -> BTreeMap<usize, PathBuf> {
|
||||
let blocks_dir_path = &format!("{data_dir_path}/blocks");
|
||||
|
||||
fs::read_dir(blocks_dir_path)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().path())
|
||||
.filter(|path| {
|
||||
let is_file = path.is_file();
|
||||
|
||||
if is_file {
|
||||
let file_name = path.file_name().unwrap().to_str().unwrap();
|
||||
|
||||
file_name.starts_with(BLK) && file_name.ends_with(DAT)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.map(|path| {
|
||||
let file_name = path.file_name().unwrap().to_str().unwrap();
|
||||
|
||||
let blk_index = file_name[BLK.len()..(file_name.len() - DAT.len())]
|
||||
.parse::<usize>()
|
||||
.unwrap();
|
||||
|
||||
(blk_index, path)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
}
|
||||
|
||||
pub fn path_to_modified_time(path: &PathBuf) -> u64 {
|
||||
fs::metadata(path)
|
||||
.unwrap()
|
||||
.modified()
|
||||
.unwrap()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
*.md
|
||||
!README.md
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "blk"
|
||||
description = "A CLI to inspect Bitcoin Core blocks"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
bitcoin = { workspace = true }
|
||||
brk_error = { workspace = true }
|
||||
brk_reader = { workspace = true }
|
||||
brk_rpc = { workspace = true }
|
||||
brk_types = { workspace = true }
|
||||
owo-colors = { workspace = true, features = ["supports-colors"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "blk"
|
||||
path = "src/main.rs"
|
||||
@@ -0,0 +1,27 @@
|
||||
# blk
|
||||
|
||||
A CLI to inspect Bitcoin Core blocks.
|
||||
|
||||
Reads `blk*.dat` files directly via [`brk_reader`](../brk_reader) and resolves
|
||||
the chain tip / heights via the Bitcoin Core RPC. Output is shell-friendly:
|
||||
bare values, NDJSON, pretty JSON, or TSV.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
cargo install --path crates/blk
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
blk 800000 hash # bare hash
|
||||
blk 800000 height hash time # one compact JSON line
|
||||
blk 800000 tx.0.vout.0.value # coinbase output 0 sats
|
||||
blk 0..2 hash tx.0.txid # 3 NDJSON lines
|
||||
blk tip tx.0 # whole coinbase tx as JSON
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
Run `blk --help` for the full field/selector/option reference.
|
||||
@@ -0,0 +1,132 @@
|
||||
use std::{collections::HashSet, path::PathBuf};
|
||||
|
||||
use brk_error::{Error, Result};
|
||||
use brk_rpc::{Auth, Client};
|
||||
|
||||
use crate::path::Path;
|
||||
|
||||
pub struct Args {
|
||||
pub selector: String,
|
||||
pub paths: Vec<Path>,
|
||||
pub pretty: bool,
|
||||
pub compact: bool,
|
||||
bitcoindir: Option<PathBuf>,
|
||||
blocksdir: Option<PathBuf>,
|
||||
rpcconnect: Option<String>,
|
||||
rpcport: Option<u16>,
|
||||
rpccookiefile: Option<PathBuf>,
|
||||
rpcuser: Option<String>,
|
||||
rpcpassword: Option<String>,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
pub fn parse(raw: Vec<String>) -> Result<Self> {
|
||||
let mut pretty = false;
|
||||
let mut compact = false;
|
||||
let mut bitcoindir = None;
|
||||
let mut blocksdir = None;
|
||||
let mut rpcconnect = None;
|
||||
let mut rpcport = None;
|
||||
let mut rpccookiefile = None;
|
||||
let mut rpcuser = None;
|
||||
let mut rpcpassword = None;
|
||||
let mut positional: Vec<String> = Vec::new();
|
||||
let mut iter = raw.into_iter();
|
||||
while let Some(a) = iter.next() {
|
||||
if a == "-p" || a == "--pretty" {
|
||||
pretty = true;
|
||||
continue;
|
||||
}
|
||||
if a == "-c" || a == "--compact" {
|
||||
compact = true;
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = a.strip_prefix("--") {
|
||||
let (key, value) = match rest.split_once('=') {
|
||||
Some((k, v)) => (k.to_string(), v.to_string()),
|
||||
None => (
|
||||
rest.to_string(),
|
||||
iter.next()
|
||||
.ok_or_else(|| Error::Parse(format!("--{rest} requires a value")))?,
|
||||
),
|
||||
};
|
||||
match key.as_str() {
|
||||
"bitcoindir" => bitcoindir = Some(PathBuf::from(value)),
|
||||
"blocksdir" => blocksdir = Some(PathBuf::from(value)),
|
||||
"rpcconnect" => rpcconnect = Some(value),
|
||||
"rpcport" => {
|
||||
rpcport = Some(value.parse().map_err(|_| {
|
||||
Error::Parse(format!("--rpcport: '{value}' is not a valid port"))
|
||||
})?);
|
||||
}
|
||||
"rpccookiefile" => rpccookiefile = Some(PathBuf::from(value)),
|
||||
"rpcuser" => rpcuser = Some(value),
|
||||
"rpcpassword" => rpcpassword = Some(value),
|
||||
other => return Err(Error::Parse(format!("unknown flag --{other}"))),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if a.starts_with('-') {
|
||||
return Err(Error::Parse(format!("unknown flag {a}")));
|
||||
}
|
||||
positional.push(a);
|
||||
}
|
||||
|
||||
let mut iter = positional.into_iter();
|
||||
let selector = iter
|
||||
.next()
|
||||
.ok_or_else(|| Error::Parse("missing selector".into()))?;
|
||||
let paths: Vec<Path> = iter.map(|f| Path::parse(&f)).collect::<Result<_>>()?;
|
||||
let mut seen = HashSet::with_capacity(paths.len());
|
||||
for p in &paths {
|
||||
if !seen.insert(p.raw.as_str()) {
|
||||
return Err(Error::Parse(format!("duplicate field '{}'", p.raw)));
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
selector,
|
||||
paths,
|
||||
pretty,
|
||||
compact,
|
||||
bitcoindir,
|
||||
blocksdir,
|
||||
rpcconnect,
|
||||
rpcport,
|
||||
rpccookiefile,
|
||||
rpcuser,
|
||||
rpcpassword,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn bitcoin_dir(&self) -> PathBuf {
|
||||
self.bitcoindir
|
||||
.clone()
|
||||
.unwrap_or_else(Client::default_bitcoin_path)
|
||||
}
|
||||
|
||||
pub fn blocks_dir(&self) -> PathBuf {
|
||||
self.blocksdir
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.bitcoin_dir().join("blocks"))
|
||||
}
|
||||
|
||||
pub fn rpc(&self) -> Result<Client> {
|
||||
let host = self.rpcconnect.as_deref().unwrap_or("localhost");
|
||||
let port = self.rpcport.unwrap_or(8332);
|
||||
let url = format!("http://{host}:{port}");
|
||||
let cookie = self
|
||||
.rpccookiefile
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.bitcoin_dir().join(".cookie"));
|
||||
let auth = if cookie.is_file() {
|
||||
Auth::CookieFile(cookie)
|
||||
} else if let (Some(u), Some(p)) = (self.rpcuser.as_deref(), self.rpcpassword.as_deref()) {
|
||||
Auth::UserPass(u.to_string(), p.to_string())
|
||||
} else {
|
||||
return Err(Error::Parse(
|
||||
"no RPC auth: cookie file missing and --rpcuser/--rpcpassword not set".into(),
|
||||
));
|
||||
};
|
||||
Client::new(&url, auth)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
use std::cell::OnceCell;
|
||||
|
||||
use bitcoin::{
|
||||
Address, Block, Network, ScriptBuf, Transaction, TxIn, TxOut, consensus::encode::serialize_hex,
|
||||
hex::DisplayHex,
|
||||
};
|
||||
use brk_error::{Error, Result};
|
||||
use brk_types::ReadBlock;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::path::{Path, Step};
|
||||
|
||||
// `hex` is intentionally absent: matches `bitcoin-cli getblock <hash> 2`
|
||||
// and keeps NDJSON dumps tractable. Still reachable explicitly via `blk N hex`.
|
||||
const BLOCK_FIELDS: &[&str] = &[
|
||||
"height",
|
||||
"hash",
|
||||
"version",
|
||||
"version_hex",
|
||||
"merkle",
|
||||
"time",
|
||||
"nonce",
|
||||
"bits",
|
||||
"difficulty",
|
||||
"prev",
|
||||
"txs",
|
||||
"n_inputs",
|
||||
"n_outputs",
|
||||
"witness_txs",
|
||||
"size",
|
||||
"strippedsize",
|
||||
"weight",
|
||||
"subsidy",
|
||||
"coinbase",
|
||||
"coinbase_hex",
|
||||
"header_hex",
|
||||
"tx",
|
||||
];
|
||||
|
||||
const TX_FIELDS: &[&str] = &[
|
||||
"txid",
|
||||
"wtxid",
|
||||
"version",
|
||||
"locktime",
|
||||
"size",
|
||||
"base_size",
|
||||
"vsize",
|
||||
"weight",
|
||||
"inputs",
|
||||
"outputs",
|
||||
"is_coinbase",
|
||||
"has_witness",
|
||||
"is_rbf",
|
||||
"total_out",
|
||||
"hex",
|
||||
"vin",
|
||||
"vout",
|
||||
];
|
||||
|
||||
const VIN_FIELDS: &[&str] = &[
|
||||
"prev_txid",
|
||||
"prev_vout",
|
||||
"sequence",
|
||||
"script_sig",
|
||||
"script_sig_asm",
|
||||
"witness",
|
||||
"has_witness",
|
||||
"is_rbf",
|
||||
"coinbase",
|
||||
];
|
||||
|
||||
const VOUT_FIELDS: &[&str] = &[
|
||||
"value",
|
||||
"script_pubkey",
|
||||
"script_pubkey_asm",
|
||||
"type",
|
||||
"address",
|
||||
];
|
||||
|
||||
pub struct Ctx<'a> {
|
||||
block: &'a ReadBlock,
|
||||
network: Network,
|
||||
size_weight: OnceCell<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
pub fn new(block: &'a ReadBlock, network: Network) -> Self {
|
||||
Self {
|
||||
block,
|
||||
network,
|
||||
size_weight: OnceCell::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve(&self, path: &Path) -> Result<Value> {
|
||||
let (step, rest) = pop(&path.steps)?;
|
||||
self.block_field(&step.name, step.index, rest)
|
||||
}
|
||||
|
||||
pub fn resolve_str(&self, path: &Path) -> Result<String> {
|
||||
Ok(match self.resolve(path)? {
|
||||
Value::String(s) => s,
|
||||
other => other.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn full(&self) -> Value {
|
||||
let mut obj = Map::with_capacity(BLOCK_FIELDS.len());
|
||||
for &name in BLOCK_FIELDS {
|
||||
obj.insert(
|
||||
name.into(),
|
||||
self.block_field(name, None, &[]).expect("known block field"),
|
||||
);
|
||||
}
|
||||
Value::Object(obj)
|
||||
}
|
||||
|
||||
fn size_and_weight(&self) -> (usize, usize) {
|
||||
*self
|
||||
.size_weight
|
||||
.get_or_init(|| self.block.total_size_and_weight())
|
||||
}
|
||||
|
||||
fn block_field(&self, name: &str, index: Option<usize>, rest: &[Step]) -> Result<Value> {
|
||||
let b = self.block;
|
||||
let raw: &Block = b;
|
||||
let scalar = |v| scalar_leaf(v, name, index, rest);
|
||||
match name {
|
||||
"height" => scalar(json!(*b.height())),
|
||||
"hash" => scalar(json!(b.hash().to_string())),
|
||||
"time" => scalar(json!(b.header.time)),
|
||||
"version" => scalar(json!(b.header.version.to_consensus())),
|
||||
"version_hex" => scalar(json!(format!(
|
||||
"{:08x}",
|
||||
b.header.version.to_consensus() as u32
|
||||
))),
|
||||
"bits" => scalar(json!(format!("{:08x}", b.header.bits.to_consensus()))),
|
||||
"nonce" => scalar(json!(b.header.nonce)),
|
||||
"prev" => scalar(json!(b.header.prev_blockhash.to_string())),
|
||||
"merkle" => scalar(json!(b.header.merkle_root.to_string())),
|
||||
"difficulty" => scalar(json!(b.header.difficulty_float())),
|
||||
"txs" => scalar(json!(b.txdata.len())),
|
||||
"n_inputs" => scalar(json!(
|
||||
b.txdata.iter().map(|tx| tx.input.len()).sum::<usize>()
|
||||
)),
|
||||
"n_outputs" => scalar(json!(
|
||||
b.txdata.iter().map(|tx| tx.output.len()).sum::<usize>()
|
||||
)),
|
||||
"witness_txs" => scalar(json!(
|
||||
b.txdata.iter().filter(|tx| tx_has_witness(tx)).count()
|
||||
)),
|
||||
"size" => scalar(json!(self.size_and_weight().0)),
|
||||
"weight" => scalar(json!(self.size_and_weight().1)),
|
||||
"strippedsize" => {
|
||||
let (size, weight) = self.size_and_weight();
|
||||
scalar(json!((weight - size) / 3))
|
||||
}
|
||||
"subsidy" => scalar(json!(subsidy_sats(*b.height()))),
|
||||
"header_hex" => scalar(json!(serialize_hex(&b.header))),
|
||||
"hex" => scalar(json!(serialize_hex(raw))),
|
||||
"coinbase" => scalar(json!(b.coinbase_tag().as_str())),
|
||||
"coinbase_hex" => {
|
||||
debug_assert!(
|
||||
!b.txdata.is_empty() && !b.txdata[0].input.is_empty(),
|
||||
"consensus-valid block has a coinbase tx with at least one input"
|
||||
);
|
||||
scalar(json!(b.txdata[0].input[0].script_sig.to_hex_string()))
|
||||
}
|
||||
"tx" => pick(&b.txdata, name, index, |i, tx| {
|
||||
self.resolve_tx(tx, i == 0, rest)
|
||||
}),
|
||||
other => Err(unknown("block", other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_tx(&self, tx: &Transaction, is_coinbase: bool, steps: &[Step]) -> Result<Value> {
|
||||
if steps.is_empty() {
|
||||
let mut obj = Map::with_capacity(TX_FIELDS.len());
|
||||
for &name in TX_FIELDS {
|
||||
obj.insert(
|
||||
name.into(),
|
||||
self.tx_field(tx, is_coinbase, name, None, &[])
|
||||
.expect("known tx field"),
|
||||
);
|
||||
}
|
||||
return Ok(Value::Object(obj));
|
||||
}
|
||||
let (step, rest) = pop(steps)?;
|
||||
self.tx_field(tx, is_coinbase, &step.name, step.index, rest)
|
||||
}
|
||||
|
||||
fn tx_field(
|
||||
&self,
|
||||
tx: &Transaction,
|
||||
is_coinbase: bool,
|
||||
name: &str,
|
||||
index: Option<usize>,
|
||||
rest: &[Step],
|
||||
) -> Result<Value> {
|
||||
let scalar = |v| scalar_leaf(v, name, index, rest);
|
||||
match name {
|
||||
"txid" => scalar(json!(tx.compute_txid().to_string())),
|
||||
"wtxid" => scalar(json!(tx.compute_wtxid().to_string())),
|
||||
"version" => scalar(json!(tx.version.0)),
|
||||
"locktime" => scalar(json!(tx.lock_time.to_consensus_u32())),
|
||||
"size" => scalar(json!(tx.total_size())),
|
||||
"base_size" => scalar(json!(tx.base_size())),
|
||||
"vsize" => scalar(json!(tx.vsize())),
|
||||
"weight" => scalar(json!(tx.weight().to_wu())),
|
||||
"inputs" => scalar(json!(tx.input.len())),
|
||||
"outputs" => scalar(json!(tx.output.len())),
|
||||
"is_coinbase" => scalar(json!(is_coinbase)),
|
||||
"has_witness" => scalar(json!(tx_has_witness(tx))),
|
||||
"is_rbf" => scalar(json!(tx_is_rbf(tx))),
|
||||
"total_out" => scalar(json!(tx_total_out(tx))),
|
||||
"hex" => scalar(json!(serialize_hex(tx))),
|
||||
"vin" => pick(&tx.input, name, index, |j, vin| {
|
||||
resolve_vin(vin, is_coinbase && j == 0, rest)
|
||||
}),
|
||||
"vout" => pick(&tx.output, name, index, |_, vout| {
|
||||
self.resolve_vout(vout, rest)
|
||||
}),
|
||||
other => Err(unknown("tx", other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_vout(&self, vout: &TxOut, steps: &[Step]) -> Result<Value> {
|
||||
if steps.is_empty() {
|
||||
let mut obj = Map::with_capacity(VOUT_FIELDS.len());
|
||||
for &name in VOUT_FIELDS {
|
||||
obj.insert(
|
||||
name.into(),
|
||||
self.vout_field(vout, name, None, &[])
|
||||
.expect("known vout field"),
|
||||
);
|
||||
}
|
||||
return Ok(Value::Object(obj));
|
||||
}
|
||||
let (step, rest) = pop(steps)?;
|
||||
self.vout_field(vout, &step.name, step.index, rest)
|
||||
}
|
||||
|
||||
fn vout_field(
|
||||
&self,
|
||||
vout: &TxOut,
|
||||
name: &str,
|
||||
index: Option<usize>,
|
||||
rest: &[Step],
|
||||
) -> Result<Value> {
|
||||
let scalar = |v| scalar_leaf(v, name, index, rest);
|
||||
match name {
|
||||
"value" => scalar(json!(vout.value.to_sat())),
|
||||
"script_pubkey" => scalar(json!(vout.script_pubkey.to_hex_string())),
|
||||
"script_pubkey_asm" => scalar(json!(vout.script_pubkey.to_asm_string())),
|
||||
"type" => scalar(json!(script_type(&vout.script_pubkey))),
|
||||
"address" => scalar(self.address_value(&vout.script_pubkey)),
|
||||
other => Err(unknown("vout", other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn address_value(&self, s: &ScriptBuf) -> Value {
|
||||
Address::from_script(s, self.network)
|
||||
.map(|a| Value::String(a.to_string()))
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_vin(vin: &TxIn, is_coinbase: bool, steps: &[Step]) -> Result<Value> {
|
||||
if steps.is_empty() {
|
||||
let mut obj = Map::with_capacity(VIN_FIELDS.len());
|
||||
for &name in VIN_FIELDS {
|
||||
obj.insert(
|
||||
name.into(),
|
||||
vin_field(vin, is_coinbase, name, None, &[]).expect("known vin field"),
|
||||
);
|
||||
}
|
||||
return Ok(Value::Object(obj));
|
||||
}
|
||||
let (step, rest) = pop(steps)?;
|
||||
vin_field(vin, is_coinbase, &step.name, step.index, rest)
|
||||
}
|
||||
|
||||
fn vin_field(
|
||||
vin: &TxIn,
|
||||
is_coinbase: bool,
|
||||
name: &str,
|
||||
index: Option<usize>,
|
||||
rest: &[Step],
|
||||
) -> Result<Value> {
|
||||
let scalar = |v| scalar_leaf(v, name, index, rest);
|
||||
match name {
|
||||
"prev_txid" => scalar(json!(vin.previous_output.txid.to_string())),
|
||||
"prev_vout" => scalar(json!(vin.previous_output.vout)),
|
||||
"sequence" => scalar(json!(vin.sequence.0)),
|
||||
"script_sig" => scalar(json!(vin.script_sig.to_hex_string())),
|
||||
"script_sig_asm" => scalar(json!(vin.script_sig.to_asm_string())),
|
||||
"witness" => {
|
||||
if !rest.is_empty() {
|
||||
return Err(Error::Parse(
|
||||
"'witness' element has no fields to drill into".into(),
|
||||
));
|
||||
}
|
||||
let items: Vec<String> = vin
|
||||
.witness
|
||||
.iter()
|
||||
.map(|w| w.to_lower_hex_string())
|
||||
.collect();
|
||||
pick(&items, name, index, |_, hex| Ok(Value::String(hex.clone())))
|
||||
}
|
||||
"has_witness" => scalar(json!(!vin.witness.is_empty())),
|
||||
"is_rbf" => scalar(json!(vin.sequence.is_rbf())),
|
||||
"coinbase" => scalar(json!(is_coinbase)),
|
||||
other => Err(unknown("vin", other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn pick<T>(
|
||||
items: &[T],
|
||||
name: &str,
|
||||
index: Option<usize>,
|
||||
mut resolve: impl FnMut(usize, &T) -> Result<Value>,
|
||||
) -> Result<Value> {
|
||||
match index {
|
||||
Some(i) => {
|
||||
let item = items
|
||||
.get(i)
|
||||
.ok_or_else(|| out_of_range(name, i, items.len()))?;
|
||||
resolve(i, item)
|
||||
}
|
||||
None => Ok(Value::Array(
|
||||
items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| resolve(i, item))
|
||||
.collect::<Result<_>>()?,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn pop(steps: &[Step]) -> Result<(&Step, &[Step])> {
|
||||
steps
|
||||
.split_first()
|
||||
.ok_or_else(|| Error::Parse("empty path segment".into()))
|
||||
}
|
||||
|
||||
fn scalar_leaf(v: Value, name: &str, index: Option<usize>, rest: &[Step]) -> Result<Value> {
|
||||
if index.is_some() {
|
||||
return Err(Error::Parse(format!("'{name}' is not an array")));
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
return Err(Error::Parse(format!(
|
||||
"'{name}' has no fields to drill into"
|
||||
)));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn out_of_range(name: &str, i: usize, len: usize) -> Error {
|
||||
Error::Parse(format!("{name}.{i} out of range (len {len})"))
|
||||
}
|
||||
|
||||
fn unknown(level: &str, name: &str) -> Error {
|
||||
Error::Parse(format!(
|
||||
"unknown {level} field '{name}' (run `blk --help` for the list)"
|
||||
))
|
||||
}
|
||||
|
||||
fn tx_has_witness(tx: &Transaction) -> bool {
|
||||
tx.input.iter().any(|i| !i.witness.is_empty())
|
||||
}
|
||||
|
||||
fn tx_is_rbf(tx: &Transaction) -> bool {
|
||||
tx.input.iter().any(|i| i.sequence.is_rbf())
|
||||
}
|
||||
|
||||
fn tx_total_out(tx: &Transaction) -> u64 {
|
||||
tx.output.iter().map(|o| o.value.to_sat()).sum()
|
||||
}
|
||||
|
||||
fn subsidy_sats(height: u32) -> u64 {
|
||||
let halvings = height / 210_000;
|
||||
if halvings >= 64 {
|
||||
0
|
||||
} else {
|
||||
(50 * 100_000_000u64) >> halvings
|
||||
}
|
||||
}
|
||||
|
||||
fn script_type(s: &ScriptBuf) -> &'static str {
|
||||
if s.is_p2pkh() {
|
||||
"p2pkh"
|
||||
} else if s.is_p2sh() {
|
||||
"p2sh"
|
||||
} else if s.is_p2wpkh() {
|
||||
"p2wpkh"
|
||||
} else if s.is_p2wsh() {
|
||||
"p2wsh"
|
||||
} else if s.is_p2tr() {
|
||||
"p2tr"
|
||||
} else if s.is_op_return() {
|
||||
"op_return"
|
||||
} else if s.is_p2pk() {
|
||||
"p2pk"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use brk_error::Result;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::{fields::Ctx, mode::Mode, path::Path};
|
||||
|
||||
pub struct Formatter {
|
||||
mode: Mode,
|
||||
fields: Vec<Path>,
|
||||
}
|
||||
|
||||
impl Formatter {
|
||||
pub fn new(mode: Mode, fields: Vec<Path>) -> Self {
|
||||
Self { mode, fields }
|
||||
}
|
||||
|
||||
pub fn format(&self, ctx: &Ctx) -> Result<String> {
|
||||
match self.mode {
|
||||
Mode::Bare => self.bare(ctx, false),
|
||||
Mode::Tsv => self.tsv(ctx),
|
||||
Mode::Json => Ok(serde_json::to_string(&self.object(ctx)?)?),
|
||||
Mode::Pretty if self.fields.len() == 1 => self.bare(ctx, true),
|
||||
Mode::Pretty => Ok(serde_json::to_string_pretty(&self.object(ctx)?)?),
|
||||
}
|
||||
}
|
||||
|
||||
fn bare(&self, ctx: &Ctx, pretty: bool) -> Result<String> {
|
||||
Ok(match ctx.resolve(&self.fields[0])? {
|
||||
Value::String(s) => s,
|
||||
other if pretty => serde_json::to_string_pretty(&other)?,
|
||||
other => other.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn tsv(&self, ctx: &Ctx) -> Result<String> {
|
||||
let mut row = String::new();
|
||||
for (i, path) in self.fields.iter().enumerate() {
|
||||
if i > 0 {
|
||||
row.push('\t');
|
||||
}
|
||||
for c in ctx.resolve_str(path)?.chars() {
|
||||
row.push(if matches!(c, '\t' | '\n' | '\r') {
|
||||
' '
|
||||
} else {
|
||||
c
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
fn object(&self, ctx: &Ctx) -> Result<Value> {
|
||||
if self.fields.is_empty() {
|
||||
return Ok(ctx.full());
|
||||
}
|
||||
let mut obj = Map::with_capacity(self.fields.len());
|
||||
for path in &self.fields {
|
||||
obj.insert(path.raw.clone(), ctx.resolve(path)?);
|
||||
}
|
||||
Ok(Value::Object(obj))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
mod args;
|
||||
mod fields;
|
||||
mod formatter;
|
||||
mod mode;
|
||||
mod path;
|
||||
mod selector;
|
||||
mod usage;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use brk_error::Result;
|
||||
use brk_reader::Reader;
|
||||
|
||||
use args::Args;
|
||||
use fields::Ctx;
|
||||
use formatter::Formatter;
|
||||
use mode::Mode;
|
||||
use selector::Selector;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("blk: {e}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<()> {
|
||||
let raw: Vec<String> = std::env::args().skip(1).collect();
|
||||
if raw.is_empty() || raw.iter().any(|a| matches!(a.as_str(), "-h" | "--help")) {
|
||||
usage::print();
|
||||
return Ok(());
|
||||
}
|
||||
let args = Args::parse(raw)?;
|
||||
|
||||
let client = args.rpc()?;
|
||||
let (start, end) = Selector::parse(&args.selector, &client)?;
|
||||
let network = client.get_network()?;
|
||||
|
||||
let mode = Mode::pick(args.pretty, args.compact, args.paths.len())?;
|
||||
let reader = Reader::new(args.blocks_dir(), &client);
|
||||
let formatter = Formatter::new(mode, args.paths);
|
||||
let parser_threads = (std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(2)
|
||||
/ 2)
|
||||
.max(1);
|
||||
for block in reader.range_with(start, end, parser_threads)?.iter() {
|
||||
let block = block?;
|
||||
let line = formatter.format(&Ctx::new(&block, network))?;
|
||||
if !line.is_empty() {
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use brk_error::{Error, Result};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Mode {
|
||||
Bare,
|
||||
Tsv,
|
||||
Json,
|
||||
Pretty,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
pub fn pick(pretty: bool, compact: bool, n_fields: usize) -> Result<Self> {
|
||||
if pretty && compact {
|
||||
return Err(Error::Parse(
|
||||
"--pretty and --compact are mutually exclusive".into(),
|
||||
));
|
||||
}
|
||||
if compact && n_fields == 0 {
|
||||
return Err(Error::Parse(
|
||||
"--compact requires at least one field".into(),
|
||||
));
|
||||
}
|
||||
Ok(if pretty {
|
||||
Self::Pretty
|
||||
} else if n_fields == 0 {
|
||||
Self::Json
|
||||
} else if n_fields == 1 {
|
||||
Self::Bare
|
||||
} else if compact {
|
||||
Self::Tsv
|
||||
} else {
|
||||
Self::Json
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use brk_error::{Error, Result};
|
||||
|
||||
pub struct Step {
|
||||
pub name: String,
|
||||
pub index: Option<usize>,
|
||||
}
|
||||
|
||||
pub struct Path {
|
||||
pub raw: String,
|
||||
pub steps: Vec<Step>,
|
||||
}
|
||||
|
||||
impl Path {
|
||||
pub fn parse(s: &str) -> Result<Self> {
|
||||
let parts: Vec<&str> = s.split('.').collect();
|
||||
let mut steps = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < parts.len() {
|
||||
let name = parts[i];
|
||||
if name.is_empty() {
|
||||
return Err(Error::Parse(format!("bad path '{s}': empty segment")));
|
||||
}
|
||||
if name.parse::<usize>().is_ok() {
|
||||
return Err(Error::Parse(format!(
|
||||
"bad path '{s}': '{name}' must follow a field name"
|
||||
)));
|
||||
}
|
||||
let index = parts.get(i + 1).and_then(|p| p.parse::<usize>().ok());
|
||||
steps.push(Step {
|
||||
name: name.to_string(),
|
||||
index,
|
||||
});
|
||||
i += if index.is_some() { 2 } else { 1 };
|
||||
}
|
||||
Ok(Self {
|
||||
raw: s.to_string(),
|
||||
steps,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use brk_error::{Error, Result};
|
||||
use brk_rpc::Client;
|
||||
use brk_types::{CheckedSub, Height};
|
||||
|
||||
pub struct Selector;
|
||||
|
||||
impl Selector {
|
||||
pub fn parse(s: &str, client: &Client) -> Result<(Height, Height)> {
|
||||
let (a, b) = s.split_once("..").unwrap_or((s, s));
|
||||
let needs_tip = |p: &str| p == "tip" || p.starts_with("tip-");
|
||||
let tip = if needs_tip(a) || needs_tip(b) {
|
||||
Some(client.get_last_height()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let start = Self::endpoint(a, tip)?;
|
||||
let end = Self::endpoint(b, tip)?;
|
||||
if end < start {
|
||||
return Err(Error::Parse(format!(
|
||||
"range end {end} before start {start}"
|
||||
)));
|
||||
}
|
||||
Ok((start, end))
|
||||
}
|
||||
|
||||
fn endpoint(s: &str, tip: Option<Height>) -> Result<Height> {
|
||||
if s == "tip" {
|
||||
return Ok(tip.expect("tip pre-resolved when input contains 'tip'"));
|
||||
}
|
||||
if let Some(rest) = s.strip_prefix("tip-") {
|
||||
let n: u32 = rest
|
||||
.parse()
|
||||
.map_err(|_| Error::Parse(format!("bad tip offset: {s}")))?;
|
||||
let tip = tip.expect("tip pre-resolved when input contains 'tip'");
|
||||
return tip
|
||||
.checked_sub(n)
|
||||
.ok_or_else(|| Error::Parse(format!("tip-{n} underflows genesis")));
|
||||
}
|
||||
let n: u32 = s
|
||||
.parse()
|
||||
.map_err(|_| Error::Parse(format!("bad height: {s}")))?;
|
||||
Ok(Height::new(n))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
use owo_colors::{OwoColorize, Stream};
|
||||
|
||||
const SEL_W: usize = 5; // longest selector token: "tip-N"
|
||||
const LABEL_W: usize = 28; // longest label across OUTPUT/OPTIONS/EXAMPLES (= example cmd "blk 800000 tx.0.vout.0.value")
|
||||
const FLAG_W: usize = 15; // longest flag: "--rpccookiefile"
|
||||
const PH_W: usize = LABEL_W - FLAG_W - 1; // placeholder column width so flag+ph total = LABEL_W
|
||||
const GAP: usize = 4;
|
||||
|
||||
pub fn print() {
|
||||
println!("{} - inspect a Bitcoin Core block", bold("blk"));
|
||||
println!();
|
||||
|
||||
section("USAGE");
|
||||
println!(
|
||||
" blk {} [{} ...] [OPTIONS]",
|
||||
dim("<selector>"),
|
||||
dim("<field>")
|
||||
);
|
||||
println!(
|
||||
" {}",
|
||||
dim("no fields = full block as JSON (analog of `bitcoin-cli getblock <hash> 2`)")
|
||||
);
|
||||
println!();
|
||||
|
||||
section("SELECTOR");
|
||||
sel("<n>", "single height (e.g. 800000)");
|
||||
sel("tip", "current chain tip");
|
||||
sel("tip-N", "tip minus N");
|
||||
sel("a..b", "inclusive range, endpoints can be height/tip/tip-N");
|
||||
println!();
|
||||
|
||||
section("FIELDS");
|
||||
println!(
|
||||
" {}",
|
||||
dim("dotted paths drill into nested data, omit an index for arrays")
|
||||
);
|
||||
println!();
|
||||
group("block");
|
||||
fields(&[
|
||||
"height, hash, time, version, version_hex, bits, nonce,",
|
||||
"prev, merkle, difficulty, txs, n_inputs, n_outputs,",
|
||||
"witness_txs, size, strippedsize, weight, subsidy,",
|
||||
"coinbase, coinbase_hex, header_hex, hex",
|
||||
]);
|
||||
println!();
|
||||
group_note("tx.i", "omit i for all txs");
|
||||
fields(&[
|
||||
"txid, wtxid, version, locktime, size, base_size, vsize,",
|
||||
"weight, inputs, outputs, is_coinbase, has_witness, is_rbf,",
|
||||
"total_out, hex",
|
||||
]);
|
||||
println!();
|
||||
group_note("tx.i.vin.j", "omit j for all inputs");
|
||||
fields(&[
|
||||
"prev_txid, prev_vout, sequence, script_sig, script_sig_asm,",
|
||||
"witness, has_witness, is_rbf, coinbase",
|
||||
]);
|
||||
println!();
|
||||
group_note("tx.i.vout.j", "omit j for all outputs");
|
||||
fields(&["value, script_pubkey, script_pubkey_asm, type, address"]);
|
||||
println!();
|
||||
println!(
|
||||
" {}",
|
||||
dim("Naked tx / tx.i / vin / vout returns the whole sub-object as JSON.")
|
||||
);
|
||||
println!();
|
||||
|
||||
section("OUTPUT");
|
||||
out("no fields", "full block JSON object, one per line (NDJSON)");
|
||||
out("1 field", "bare value, one per line");
|
||||
out("2+ fields", "JSON object, one per line (NDJSON)");
|
||||
out("-p, --pretty", "pretty JSON object instead");
|
||||
out(
|
||||
"-c, --compact",
|
||||
"tab-separated values, no field names (TSV)",
|
||||
);
|
||||
println!();
|
||||
|
||||
section("OPTIONS");
|
||||
opt(
|
||||
"--bitcoindir",
|
||||
"<PATH>",
|
||||
"Bitcoin directory",
|
||||
Some("[OS default]"),
|
||||
);
|
||||
opt(
|
||||
"--blocksdir",
|
||||
"<PATH>",
|
||||
"Blocks directory",
|
||||
Some("[<bitcoindir>/blocks]"),
|
||||
);
|
||||
opt("--rpcconnect", "<IP>", "RPC host", Some("[localhost]"));
|
||||
opt("--rpcport", "<PORT>", "RPC port", Some("[8332]"));
|
||||
opt(
|
||||
"--rpccookiefile",
|
||||
"<PATH>",
|
||||
"RPC cookie file",
|
||||
Some("[<bitcoindir>/.cookie]"),
|
||||
);
|
||||
opt("--rpcuser", "<USERNAME>", "RPC username", None);
|
||||
opt("--rpcpassword", "<PASSWORD>", "RPC password", None);
|
||||
println!();
|
||||
|
||||
section("EXAMPLES");
|
||||
ex("blk 800000", "full block as JSON");
|
||||
ex("blk 800000 hash", "bare hash");
|
||||
ex("blk 800000 height hash time", "one compact JSON line");
|
||||
ex("blk 800000 tx.0.txid", "coinbase txid");
|
||||
ex("blk 800000 tx.txid", "all txids in block (array)");
|
||||
ex("blk 800000 tx.0.vout.0.value", "coinbase output 0 sats");
|
||||
ex("blk 800000 tx.0.vout.value", "all output sats for tx 0");
|
||||
ex("blk 800000 tx.vout.value", "array of arrays (per tx)");
|
||||
ex("blk 0..2 hash tx.0.txid", "3 NDJSON lines");
|
||||
ex("blk tip tx.0", "whole coinbase tx as JSON");
|
||||
}
|
||||
|
||||
fn section(name: &str) {
|
||||
println!("{}", bold(&format!("{name}:")));
|
||||
}
|
||||
|
||||
fn group(name: &str) {
|
||||
println!(" {}", bold(&format!("{name}:")));
|
||||
}
|
||||
|
||||
fn group_note(name: &str, note: &str) {
|
||||
println!(
|
||||
" {} {}",
|
||||
bold(&format!("{name}:")),
|
||||
dim(&format!("({note})"))
|
||||
);
|
||||
}
|
||||
|
||||
fn fields(lines: &[&str]) {
|
||||
for line in lines {
|
||||
println!(" {line}");
|
||||
}
|
||||
}
|
||||
|
||||
fn pad(s: &str, width: usize) -> String {
|
||||
" ".repeat(width.saturating_sub(s.len()))
|
||||
}
|
||||
|
||||
fn sel(token: &str, desc: &str) {
|
||||
println!(
|
||||
" {}{}{}{desc}",
|
||||
dim(token),
|
||||
pad(token, SEL_W),
|
||||
" ".repeat(GAP),
|
||||
);
|
||||
}
|
||||
|
||||
fn out(label: &str, desc: &str) {
|
||||
println!(
|
||||
" {label}{}{}{desc}",
|
||||
pad(label, LABEL_W),
|
||||
" ".repeat(GAP)
|
||||
);
|
||||
}
|
||||
|
||||
fn opt(flag: &str, ph: &str, desc: &str, default: Option<&str>) {
|
||||
let head = format!(
|
||||
" {flag}{} {}{}{}",
|
||||
pad(flag, FLAG_W),
|
||||
dim(ph),
|
||||
pad(ph, PH_W),
|
||||
" ".repeat(GAP),
|
||||
);
|
||||
match default {
|
||||
Some(d) => println!("{head}{desc} {}", dim(d)),
|
||||
None => println!("{head}{desc}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn ex(cmd: &str, note: &str) {
|
||||
println!(
|
||||
" {cmd}{}{}{}",
|
||||
pad(cmd, LABEL_W),
|
||||
" ".repeat(GAP),
|
||||
dim(&format!("# {note}"))
|
||||
);
|
||||
}
|
||||
|
||||
fn bold(s: &str) -> String {
|
||||
s.if_supports_color(Stream::Stdout, |t| t.bold()).to_string()
|
||||
}
|
||||
|
||||
fn dim(s: &str) -> String {
|
||||
s.if_supports_color(Stream::Stdout, |t| t.bright_black())
|
||||
.to_string()
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
[package]
|
||||
name = "brk"
|
||||
description.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[features]
|
||||
full = [
|
||||
"bencher",
|
||||
"bindgen",
|
||||
"client",
|
||||
"computer",
|
||||
"error",
|
||||
"fetcher",
|
||||
"cohort",
|
||||
"indexer",
|
||||
"iterator",
|
||||
"logger",
|
||||
"mempool",
|
||||
"oracle",
|
||||
"query",
|
||||
"reader",
|
||||
"rpc",
|
||||
"server",
|
||||
"store",
|
||||
"traversable",
|
||||
"types",
|
||||
]
|
||||
bencher = ["brk_bencher"]
|
||||
bindgen = ["brk_bindgen"]
|
||||
client = ["brk_client"]
|
||||
computer = ["brk_computer"]
|
||||
error = ["brk_error"]
|
||||
fetcher = ["brk_fetcher"]
|
||||
cohort = ["brk_cohort"]
|
||||
indexer = ["brk_indexer"]
|
||||
iterator = ["brk_iterator"]
|
||||
logger = ["brk_logger"]
|
||||
mempool = ["brk_mempool"]
|
||||
oracle = ["brk_oracle"]
|
||||
query = ["brk_query"]
|
||||
reader = ["brk_reader"]
|
||||
rpc = ["brk_rpc"]
|
||||
server = ["brk_server"]
|
||||
store = ["brk_store"]
|
||||
traversable = ["brk_traversable"]
|
||||
types = ["brk_types"]
|
||||
|
||||
[dependencies]
|
||||
brk_bencher = { workspace = true, optional = true }
|
||||
brk_bindgen = { workspace = true, optional = true }
|
||||
brk_client = { workspace = true, optional = true }
|
||||
brk_computer = { workspace = true, optional = true }
|
||||
brk_error = { workspace = true, optional = true }
|
||||
brk_fetcher = { workspace = true, optional = true }
|
||||
brk_cohort = { workspace = true, optional = true }
|
||||
brk_indexer = { workspace = true, optional = true }
|
||||
brk_iterator = { workspace = true, optional = true }
|
||||
brk_logger = { workspace = true, optional = true }
|
||||
brk_mempool = { workspace = true, optional = true }
|
||||
brk_oracle = { workspace = true, optional = true }
|
||||
brk_query = { workspace = true, optional = true }
|
||||
brk_reader = { workspace = true, optional = true }
|
||||
brk_rpc = { workspace = true, optional = true }
|
||||
brk_server = { workspace = true, optional = true }
|
||||
brk_store = { workspace = true, optional = true }
|
||||
brk_traversable = { workspace = true, optional = true }
|
||||
brk_types = { workspace = true, optional = true }
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
@@ -0,0 +1,68 @@
|
||||
# brk
|
||||
|
||||
Umbrella crate for the Bitcoin Research Kit.
|
||||
|
||||
[crates.io](https://crates.io/crates/brk) | [docs.rs](https://docs.rs/brk)
|
||||
|
||||
## Usage
|
||||
|
||||
Single dependency to access any BRK component. Enable only what you need via feature flags.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
brk = { version = "0.1", features = ["query", "types"] }
|
||||
```
|
||||
|
||||
```rust,ignore
|
||||
use brk::query::Query;
|
||||
use brk::types::Height;
|
||||
```
|
||||
|
||||
Feature flags match crate names without the `brk_` prefix. Use `full` to enable all:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
brk = { version = "0.1", features = ["full"] }
|
||||
```
|
||||
|
||||
## Crates
|
||||
|
||||
**Core Pipeline**
|
||||
|
||||
| Crate | Description |
|
||||
|-------|-------------|
|
||||
| [brk_reader](https://docs.rs/brk_reader) | Read blocks from `blk*.dat` with parallel parsing and XOR decoding |
|
||||
| [brk_indexer](https://docs.rs/brk_indexer) | Index transactions, addresses, and UTXOs |
|
||||
| [brk_computer](https://docs.rs/brk_computer) | Compute derived metrics (realized cap, MVRV, SOPR, cohorts, etc.) |
|
||||
| [brk_mempool](https://docs.rs/brk_mempool) | Monitor mempool, estimate fees, project upcoming blocks |
|
||||
| [brk_oracle](https://docs.rs/brk_oracle) | Pure on-chain BTC/USD price oracle |
|
||||
| [brk_query](https://docs.rs/brk_query) | Query interface for indexed and computed data |
|
||||
| [brk_server](https://docs.rs/brk_server) | REST API with OpenAPI docs |
|
||||
|
||||
**Data & Storage**
|
||||
|
||||
| Crate | Description |
|
||||
|-------|-------------|
|
||||
| [brk_types](https://docs.rs/brk_types) | Domain types: `Height`, `Sats`, `Txid`, addresses, etc. |
|
||||
| [brk_store](https://docs.rs/brk_store) | Key-value storage (fjall wrapper) |
|
||||
| [brk_fetcher](https://docs.rs/brk_fetcher) | Fetch price data from exchanges |
|
||||
| [brk_rpc](https://docs.rs/brk_rpc) | Bitcoin Core RPC client |
|
||||
| [brk_iterator](https://docs.rs/brk_iterator) | Unified block iteration with automatic source selection |
|
||||
| [brk_cohort](https://docs.rs/brk_cohort) | UTXO and address cohort filtering |
|
||||
| [brk_traversable](https://docs.rs/brk_traversable) | Navigate hierarchical data structures |
|
||||
|
||||
**Clients & Integration**
|
||||
|
||||
| Crate | Description |
|
||||
|-------|-------------|
|
||||
| [brk_client](https://docs.rs/brk_client) | Generated Rust API client |
|
||||
| [brk_bindgen](https://docs.rs/brk_bindgen) | Generate typed clients (Rust, JavaScript, Python) |
|
||||
|
||||
**Internal**
|
||||
|
||||
| Crate | Description |
|
||||
|-------|-------------|
|
||||
| [brk_cli](https://docs.rs/brk_cli) | CLI binary (`cargo install --locked brk_cli`) |
|
||||
| [brk_error](https://docs.rs/brk_error) | Error types |
|
||||
| [brk_logger](https://docs.rs/brk_logger) | Logging infrastructure |
|
||||
| [brk_bencher](https://docs.rs/brk_bencher) | Benchmarking utilities |
|
||||
@@ -0,0 +1 @@
|
||||
sudo cargo flamegraph --profile profiling --root
|
||||
@@ -0,0 +1,2 @@
|
||||
cargo build --profile profiling
|
||||
samply record ../../target/profiling/brk
|
||||
@@ -0,0 +1,77 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
#[cfg(feature = "bencher")]
|
||||
#[doc(inline)]
|
||||
pub use brk_bencher as bencher;
|
||||
|
||||
#[cfg(feature = "bindgen")]
|
||||
#[doc(inline)]
|
||||
pub use brk_bindgen as bindgen;
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
#[doc(inline)]
|
||||
pub use brk_client as client;
|
||||
|
||||
#[cfg(feature = "cohort")]
|
||||
#[doc(inline)]
|
||||
pub use brk_cohort as cohort;
|
||||
|
||||
#[cfg(feature = "computer")]
|
||||
#[doc(inline)]
|
||||
pub use brk_computer as computer;
|
||||
|
||||
#[cfg(feature = "error")]
|
||||
#[doc(inline)]
|
||||
pub use brk_error as error;
|
||||
|
||||
#[cfg(feature = "fetcher")]
|
||||
#[doc(inline)]
|
||||
pub use brk_fetcher as fetcher;
|
||||
|
||||
#[cfg(feature = "indexer")]
|
||||
#[doc(inline)]
|
||||
pub use brk_indexer as indexer;
|
||||
|
||||
#[cfg(feature = "iterator")]
|
||||
#[doc(inline)]
|
||||
pub use brk_iterator as iterator;
|
||||
|
||||
#[cfg(feature = "logger")]
|
||||
#[doc(inline)]
|
||||
pub use brk_logger as logger;
|
||||
|
||||
#[cfg(feature = "mempool")]
|
||||
#[doc(inline)]
|
||||
pub use brk_mempool as mempool;
|
||||
|
||||
#[cfg(feature = "oracle")]
|
||||
#[doc(inline)]
|
||||
pub use brk_oracle as oracle;
|
||||
|
||||
#[cfg(feature = "query")]
|
||||
#[doc(inline)]
|
||||
pub use brk_query as query;
|
||||
|
||||
#[cfg(feature = "reader")]
|
||||
#[doc(inline)]
|
||||
pub use brk_reader as reader;
|
||||
|
||||
#[cfg(feature = "rpc")]
|
||||
#[doc(inline)]
|
||||
pub use brk_rpc as rpc;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[doc(inline)]
|
||||
pub use brk_server as server;
|
||||
|
||||
#[cfg(feature = "store")]
|
||||
#[doc(inline)]
|
||||
pub use brk_store as store;
|
||||
|
||||
#[cfg(feature = "traversable")]
|
||||
#[doc(inline)]
|
||||
pub use brk_traversable as traversable;
|
||||
|
||||
#[cfg(feature = "types")]
|
||||
#[doc(inline)]
|
||||
pub use brk_types as types;
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "brk_alloc"
|
||||
description = "Global allocator and memory utilities for brk"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
libmimalloc-sys = { version = "0.1.49", features = ["extended"] }
|
||||
mimalloc = { version = "0.1.52" }
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Global allocator and memory utilities for brk.
|
||||
//!
|
||||
//! This crate sets mimalloc as the global allocator and provides
|
||||
//! utilities for monitoring and managing memory.
|
||||
|
||||
use mimalloc::MiMalloc as Allocator;
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: Allocator = Allocator;
|
||||
|
||||
/// Mimalloc allocator utilities
|
||||
pub struct Mimalloc;
|
||||
|
||||
impl Mimalloc {
|
||||
/// Eagerly free memory back to OS.
|
||||
/// Only call at natural pause points.
|
||||
#[inline]
|
||||
pub fn collect() {
|
||||
unsafe { libmimalloc_sys::mi_collect(true) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "brk_bencher"
|
||||
description = "A simple benchmarker for testing other crates."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
brk_error = { workspace = true }
|
||||
brk_logger = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libproc = "0.14"
|
||||
@@ -0,0 +1,43 @@
|
||||
# brk_bencher
|
||||
|
||||
Resource monitoring for long-running Bitcoin indexing operations.
|
||||
|
||||
## What It Enables
|
||||
|
||||
Track disk usage, memory consumption (current + peak), and I/O throughput during indexing runs. Progress tracking hooks into brk_logger to record processing milestones automatically.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Multi-metric monitoring**: Disk, memory (RSS + peak), I/O read/write
|
||||
- **Progress tracking**: Integrates with logging to capture block heights as they're processed
|
||||
- **Run comparison**: Outputs timestamped CSVs for comparing multiple runs
|
||||
- **macOS optimized**: Uses libproc for accurate process metrics on macOS
|
||||
- **Non-blocking**: Monitors in background thread with 5-second sample interval
|
||||
|
||||
## Core API
|
||||
|
||||
```rust,ignore
|
||||
let mut bencher = Bencher::from_cargo_env("brk_indexer", &data_path)?;
|
||||
bencher.start()?;
|
||||
|
||||
// ... run indexing ...
|
||||
|
||||
bencher.stop()?;
|
||||
```
|
||||
|
||||
## Output Structure
|
||||
|
||||
```
|
||||
benches/
|
||||
└── brk_indexer/
|
||||
└── 1703001234/
|
||||
├── disk.csv # timestamp_ms, bytes
|
||||
├── memory.csv # timestamp_ms, current, peak
|
||||
├── io.csv # timestamp_ms, read, written
|
||||
└── progress.csv # timestamp_ms, height
|
||||
```
|
||||
|
||||
## Built On
|
||||
|
||||
- `brk_error` for error handling
|
||||
- `brk_logger` for progress hook integration
|
||||
@@ -0,0 +1,66 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::{self, File},
|
||||
io::{self, Write},
|
||||
os::unix::fs::MetadataExt,
|
||||
path::{Path, PathBuf},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
pub struct DiskMonitor {
|
||||
cache: HashMap<PathBuf, (u64, SystemTime)>, // path -> (bytes_used, mtime)
|
||||
monitored_path: PathBuf,
|
||||
writer: File,
|
||||
}
|
||||
|
||||
impl DiskMonitor {
|
||||
pub fn new(monitored_path: &Path, csv_path: &Path) -> io::Result<Self> {
|
||||
let mut writer = File::create(csv_path)?;
|
||||
writeln!(writer, "timestamp_ms,disk_usage")?;
|
||||
|
||||
Ok(Self {
|
||||
cache: HashMap::new(),
|
||||
monitored_path: monitored_path.to_path_buf(),
|
||||
writer,
|
||||
})
|
||||
}
|
||||
|
||||
/// Record disk usage at the given timestamp
|
||||
pub fn record(&mut self, elapsed_ms: u128) -> io::Result<()> {
|
||||
if let Ok(bytes) = self.scan_recursive(&self.monitored_path.clone()) {
|
||||
writeln!(self.writer, "{},{}", elapsed_ms, bytes)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scan_recursive(&mut self, path: &Path) -> io::Result<u64> {
|
||||
let mut total = 0;
|
||||
|
||||
for entry in fs::read_dir(path)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let metadata = entry.metadata()?;
|
||||
|
||||
if metadata.is_file() {
|
||||
let mtime = metadata.modified()?;
|
||||
|
||||
// Check cache: if mtime unchanged, use cached value
|
||||
if let Some((cached_bytes, cached_mtime)) = self.cache.get(&path)
|
||||
&& *cached_mtime == mtime
|
||||
{
|
||||
total += cached_bytes;
|
||||
continue;
|
||||
}
|
||||
|
||||
// File is new or modified - get actual disk usage
|
||||
let bytes = metadata.blocks() * 512;
|
||||
self.cache.insert(path, (bytes, mtime));
|
||||
total += bytes;
|
||||
} else if metadata.is_dir() {
|
||||
total += self.scan_recursive(&path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(total)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{self, Write},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::fs;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use libproc::pid_rusage::{RUsageInfoV2, pidrusage};
|
||||
|
||||
pub struct IoMonitor {
|
||||
pid: u32,
|
||||
writer: File,
|
||||
}
|
||||
|
||||
impl IoMonitor {
|
||||
pub fn new(pid: u32, csv_path: &Path) -> io::Result<Self> {
|
||||
let mut writer = File::create(csv_path)?;
|
||||
writeln!(writer, "timestamp_ms,bytes_read,bytes_written")?;
|
||||
|
||||
Ok(Self { pid, writer })
|
||||
}
|
||||
|
||||
/// Record I/O usage at the given timestamp
|
||||
pub fn record(&mut self, elapsed_ms: u128) -> io::Result<()> {
|
||||
if let Ok((read, written)) = self.get_io_usage() {
|
||||
writeln!(self.writer, "{},{},{}", elapsed_ms, read, written)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get I/O usage in bytes
|
||||
/// Returns (bytes_read, bytes_written)
|
||||
fn get_io_usage(&self) -> io::Result<(u64, u64)> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
self.get_io_usage_linux()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.get_io_usage_macos()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn get_io_usage_linux(&self) -> io::Result<(u64, u64)> {
|
||||
let io_content = fs::read_to_string(format!("/proc/{}/io", self.pid))?;
|
||||
|
||||
let mut read_bytes = None;
|
||||
let mut write_bytes = None;
|
||||
|
||||
for line in io_content.lines() {
|
||||
if line.starts_with("read_bytes:") {
|
||||
if let Some(value_str) = line.split_whitespace().nth(1) {
|
||||
read_bytes = value_str.parse::<u64>().ok();
|
||||
}
|
||||
} else if line.starts_with("write_bytes:") {
|
||||
if let Some(value_str) = line.split_whitespace().nth(1) {
|
||||
write_bytes = value_str.parse::<u64>().ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (read_bytes, write_bytes) {
|
||||
(Some(r), Some(w)) => Ok((r, w)),
|
||||
_ => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Failed to parse I/O stats from /proc/[pid]/io",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn get_io_usage_macos(&self) -> io::Result<(u64, u64)> {
|
||||
match pidrusage::<RUsageInfoV2>(self.pid as i32) {
|
||||
Ok(info) => Ok((info.ri_diskio_bytesread, info.ri_diskio_byteswritten)),
|
||||
Err(_) => Err(io::Error::other("Failed to get process I/O stats")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use brk_error::{Error, Result};
|
||||
|
||||
mod disk;
|
||||
mod io;
|
||||
mod memory;
|
||||
mod progression;
|
||||
|
||||
use disk::*;
|
||||
use io::*;
|
||||
use memory::*;
|
||||
use parking_lot::Mutex;
|
||||
use progression::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bencher(Arc<BencherInner>);
|
||||
|
||||
struct BencherInner {
|
||||
bench_dir: PathBuf,
|
||||
monitored_path: PathBuf,
|
||||
stop_flag: Arc<AtomicBool>,
|
||||
monitor_thread: Mutex<Option<JoinHandle<Result<()>>>>,
|
||||
progression: Arc<ProgressionMonitor>,
|
||||
}
|
||||
|
||||
impl Bencher {
|
||||
/// Create a new bencher for the given crate name
|
||||
/// Creates directory structure: workspace_root/benches/{crate_name}/{timestamp}/
|
||||
pub fn new(crate_name: &str, workspace_root: &Path, monitored_path: &Path) -> Result<Self> {
|
||||
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||||
|
||||
let bench_dir = workspace_root
|
||||
.join("benches")
|
||||
.join(crate_name)
|
||||
.join(timestamp.to_string());
|
||||
|
||||
fs::create_dir_all(&bench_dir)?;
|
||||
|
||||
let progress_csv = bench_dir.join("progress.csv");
|
||||
let progression = Arc::new(ProgressionMonitor::new(&progress_csv)?);
|
||||
let progression_clone = progression.clone();
|
||||
|
||||
// Register hook with logger
|
||||
brk_logger::register_hook(move |message| {
|
||||
progression_clone.check_and_record(message);
|
||||
})
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::AlreadyExists, e))?;
|
||||
|
||||
Ok(Self(Arc::new(BencherInner {
|
||||
bench_dir,
|
||||
monitored_path: monitored_path.to_path_buf(),
|
||||
stop_flag: Arc::new(AtomicBool::new(false)),
|
||||
progression,
|
||||
monitor_thread: Mutex::new(None),
|
||||
})))
|
||||
}
|
||||
|
||||
/// Create a bencher using CARGO_MANIFEST_DIR to find workspace root
|
||||
pub fn from_cargo_env(crate_name: &str, monitored_path: &Path) -> Result<Self> {
|
||||
let mut current = std::env::current_dir()
|
||||
.map_err(|e| format!("Failed to get current directory: {}", e))
|
||||
.unwrap();
|
||||
|
||||
let workspace_root = loop {
|
||||
let cargo_toml = current.join("Cargo.toml");
|
||||
if cargo_toml.exists() {
|
||||
let contents = std::fs::read_to_string(&cargo_toml)
|
||||
.map_err(|e| format!("Failed to read Cargo.toml: {}", e))
|
||||
.unwrap();
|
||||
if contents.contains("[workspace]") {
|
||||
break current;
|
||||
}
|
||||
}
|
||||
|
||||
current = current
|
||||
.parent()
|
||||
.ok_or(Error::NotFound("Workspace root not found".into()))?
|
||||
.to_path_buf();
|
||||
};
|
||||
|
||||
Self::new(crate_name, &workspace_root, monitored_path)
|
||||
}
|
||||
|
||||
/// Start monitoring disk usage and memory footprint
|
||||
pub fn start(&mut self) -> Result<()> {
|
||||
if self.0.monitor_thread.lock().is_some() {
|
||||
return Err(Error::Internal("Bencher already started"));
|
||||
}
|
||||
|
||||
let stop_flag = self.0.stop_flag.clone();
|
||||
let bench_dir = self.0.bench_dir.clone();
|
||||
let monitored_path = self.0.monitored_path.clone();
|
||||
|
||||
let handle =
|
||||
thread::spawn(move || monitor_resources(&monitored_path, &bench_dir, stop_flag));
|
||||
|
||||
*self.0.monitor_thread.lock() = Some(handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop monitoring and wait for the thread to finish
|
||||
pub fn stop(&self) -> Result<()> {
|
||||
self.0.stop_flag.store(true, Ordering::Relaxed);
|
||||
|
||||
if let Some(handle) = self.0.monitor_thread.lock().take() {
|
||||
handle
|
||||
.join()
|
||||
.map_err(|_| Error::Internal("Monitor thread panicked"))??;
|
||||
}
|
||||
|
||||
self.0.progression.flush()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Bencher {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
fn monitor_resources(
|
||||
monitored_path: &Path,
|
||||
bench_dir: &Path,
|
||||
stop_flag: Arc<AtomicBool>,
|
||||
) -> Result<()> {
|
||||
let pid = std::process::id();
|
||||
let start = Instant::now();
|
||||
|
||||
let mut disk_monitor = DiskMonitor::new(monitored_path, &bench_dir.join("disk.csv"))?;
|
||||
let mut memory_monitor = MemoryMonitor::new(pid, &bench_dir.join("memory.csv"))?;
|
||||
let mut io_monitor = IoMonitor::new(pid, &bench_dir.join("io.csv"))?;
|
||||
|
||||
'l: loop {
|
||||
let elapsed_ms = start.elapsed().as_millis();
|
||||
|
||||
disk_monitor.record(elapsed_ms)?;
|
||||
memory_monitor.record(elapsed_ms)?;
|
||||
io_monitor.record(elapsed_ms)?;
|
||||
|
||||
for _ in 0..50 {
|
||||
// 50 * 100ms = 5 seconds
|
||||
if stop_flag.load(Ordering::Relaxed) {
|
||||
break 'l;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{self, Write},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::fs;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::process::Command;
|
||||
|
||||
pub struct MemoryMonitor {
|
||||
pid: u32,
|
||||
writer: File,
|
||||
}
|
||||
|
||||
impl MemoryMonitor {
|
||||
pub fn new(pid: u32, csv_path: &Path) -> io::Result<Self> {
|
||||
let mut writer = File::create(csv_path)?;
|
||||
writeln!(writer, "timestamp_ms,phys_footprint,phys_footprint_peak")?;
|
||||
|
||||
Ok(Self { pid, writer })
|
||||
}
|
||||
|
||||
/// Record memory usage at the given timestamp
|
||||
pub fn record(&mut self, elapsed_ms: u128) -> io::Result<()> {
|
||||
if let Ok((footprint, peak)) = self.get_memory_usage() {
|
||||
writeln!(self.writer, "{},{},{}", elapsed_ms, footprint, peak)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get memory usage in bytes
|
||||
/// Returns (current_bytes, peak_bytes)
|
||||
fn get_memory_usage(&self) -> io::Result<(u64, u64)> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
self.get_memory_usage_linux()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.get_memory_usage_macos()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn get_memory_usage_linux(&self) -> io::Result<(u64, u64)> {
|
||||
let status_content = fs::read_to_string(format!("/proc/{}/status", self.pid))?;
|
||||
|
||||
let mut vm_rss = None;
|
||||
let mut vm_hwm = None;
|
||||
|
||||
for line in status_content.lines() {
|
||||
if line.starts_with("VmRSS:") {
|
||||
if let Some(value_str) = line.split_whitespace().nth(1) {
|
||||
if let Ok(kb) = value_str.parse::<u64>() {
|
||||
vm_rss = Some(kb * 1024); // KiB to bytes
|
||||
}
|
||||
}
|
||||
} else if line.starts_with("VmHWM:") {
|
||||
if let Some(value_str) = line.split_whitespace().nth(1) {
|
||||
if let Ok(kb) = value_str.parse::<u64>() {
|
||||
vm_hwm = Some(kb * 1024); // KiB to bytes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (vm_rss, vm_hwm) {
|
||||
(Some(rss), Some(hwm)) => Ok((rss, hwm)),
|
||||
_ => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Failed to parse memory info from /proc/[pid]/status",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn get_memory_usage_macos(&self) -> io::Result<(u64, u64)> {
|
||||
let output = Command::new("footprint")
|
||||
.args(["-p", &self.pid.to_string()])
|
||||
.output()?;
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).map_err(|_| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-8 from footprint")
|
||||
})?;
|
||||
|
||||
parse_footprint_output(&stdout).ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Failed to parse footprint output",
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn parse_footprint_output(output: &str) -> Option<(u64, u64)> {
|
||||
let mut phys_footprint = None;
|
||||
let mut phys_footprint_peak = None;
|
||||
|
||||
for line in output.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
if line.starts_with("phys_footprint:") {
|
||||
// Format: "phys_footprint: 7072 KB"
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 3 {
|
||||
// parts[0] = "phys_footprint:"
|
||||
// parts[1] = "7072"
|
||||
// parts[2] = "KB"
|
||||
phys_footprint = parse_size_to_bytes(parts[1], parts[2]);
|
||||
}
|
||||
} else if line.starts_with("phys_footprint_peak:") {
|
||||
// Format: "phys_footprint_peak: 15 MB"
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 3 {
|
||||
phys_footprint_peak = parse_size_to_bytes(parts[1], parts[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (phys_footprint, phys_footprint_peak) {
|
||||
(Some(f), Some(p)) => Some((f, p)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn parse_size_to_bytes(value: &str, unit: &str) -> Option<u64> {
|
||||
let value: f64 = value.parse().ok()?;
|
||||
|
||||
let multiplier = match unit.to_uppercase().as_str() {
|
||||
"KB" => 1024.0, // KiB to bytes
|
||||
"MB" => 1024.0 * 1024.0, // MiB to bytes
|
||||
"GB" => 1024.0 * 1024.0 * 1024.0, // GiB to bytes
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some((value * multiplier) as u64)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, BufWriter, Write},
|
||||
path::Path,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
/// Patterns to match for progress tracking.
|
||||
const PROGRESS_PATTERNS: &[&str] = &[
|
||||
"block ", // "Indexing block 123..."
|
||||
"chain at ", // "Processing chain at 456..."
|
||||
];
|
||||
|
||||
pub struct ProgressionMonitor {
|
||||
csv_file: Mutex<BufWriter<fs::File>>,
|
||||
start_time: Instant,
|
||||
}
|
||||
|
||||
impl ProgressionMonitor {
|
||||
pub fn new(csv_path: &Path) -> io::Result<Self> {
|
||||
let mut csv_file = BufWriter::new(fs::File::create(csv_path)?);
|
||||
writeln!(csv_file, "timestamp_ms,value")?;
|
||||
|
||||
Ok(Self {
|
||||
csv_file: Mutex::new(csv_file),
|
||||
start_time: Instant::now(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check message for progress patterns and record if found
|
||||
#[inline]
|
||||
pub fn check_and_record(&self, message: &str) {
|
||||
let Some(value) = parse_progress(message) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if value % 10 != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let elapsed_ms = self.start_time.elapsed().as_millis();
|
||||
let _ = writeln!(self.csv_file.lock(), "{},{}", elapsed_ms, value);
|
||||
}
|
||||
|
||||
pub fn flush(&self) -> io::Result<()> {
|
||||
self.csv_file.lock().flush()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse progress value from message
|
||||
#[inline]
|
||||
fn parse_progress(message: &str) -> Option<u64> {
|
||||
PROGRESS_PATTERNS
|
||||
.iter()
|
||||
.find_map(|pattern| parse_number_after(message, pattern))
|
||||
}
|
||||
|
||||
/// Extract number immediately following the pattern
|
||||
#[inline]
|
||||
fn parse_number_after(message: &str, pattern: &str) -> Option<u64> {
|
||||
let start = message.find(pattern)?;
|
||||
let after = &message[start + pattern.len()..];
|
||||
|
||||
let end = after
|
||||
.find(|c: char| !c.is_ascii_digit())
|
||||
.unwrap_or(after.len());
|
||||
|
||||
if end == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
after[..end].parse().ok()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "brk_bencher_visualizer"
|
||||
description = "A generator of charts for brk_bencher"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
plotters = "0.3.7"
|
||||
@@ -0,0 +1,34 @@
|
||||
# brk_bencher_visualizer
|
||||
|
||||
SVG chart generation for benchmark visualization.
|
||||
|
||||
## What It Enables
|
||||
|
||||
Turn benchmark CSV data into publication-ready SVG charts showing disk usage, memory (current/peak), progress, and I/O over time. Compare multiple runs side-by-side with automatic color coding.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Multi-run comparison**: Overlay multiple benchmark runs with distinct colors
|
||||
- **Dual-axis charts**: Memory charts show both current and peak usage (solid vs dashed lines)
|
||||
- **Smart scaling**: Automatic unit conversion for bytes (KB/MB/GB) and time (seconds/minutes/hours)
|
||||
- **Per-run trimming**: Aligns data by progress cutoffs for fair comparison
|
||||
- **Dark theme**: Clean, readable charts with monospace fonts
|
||||
|
||||
## Core API
|
||||
|
||||
```rust,ignore
|
||||
let viz = Visualizer::from_cargo_env()?;
|
||||
viz.generate_all_charts()?; // Process all crates in benches/
|
||||
```
|
||||
|
||||
## Chart Types
|
||||
|
||||
- `disk.svg` - Storage consumption over time
|
||||
- `memory.svg` - Current + peak memory usage
|
||||
- `progress.svg` - Processing progress (e.g., blocks indexed)
|
||||
- `io_read.svg` / `io_write.svg` - I/O throughput
|
||||
|
||||
## Input Format
|
||||
|
||||
Reads CSV files from `benches/<crate>/<run_id>/`:
|
||||
- `disk.csv`, `memory.csv`, `progress.csv`, `io.csv`
|
||||
@@ -0,0 +1,285 @@
|
||||
use crate::data::{DataPoint, DualRun, Result, Run};
|
||||
use crate::format;
|
||||
use plotters::prelude::*;
|
||||
use std::path::Path;
|
||||
|
||||
const FONT: &str = "monospace";
|
||||
const FONT_SIZE: i32 = 20;
|
||||
const FONT_SIZE_BIG: i32 = 30;
|
||||
const SIZE: (u32, u32) = (2000, 1000);
|
||||
const TIME_BUFFER_MS: u64 = 10_000;
|
||||
|
||||
const BG_COLOR: RGBColor = RGBColor(18, 18, 24);
|
||||
const TEXT_COLOR: RGBColor = RGBColor(230, 230, 240);
|
||||
const COLORS: [RGBColor; 6] = [
|
||||
RGBColor(255, 99, 132), // Pink/Red
|
||||
RGBColor(54, 162, 235), // Blue
|
||||
RGBColor(75, 192, 192), // Teal
|
||||
RGBColor(255, 206, 86), // Yellow
|
||||
RGBColor(153, 102, 255), // Purple
|
||||
RGBColor(255, 159, 64), // Orange
|
||||
];
|
||||
|
||||
pub enum YAxisFormat {
|
||||
Bytes,
|
||||
Number,
|
||||
}
|
||||
|
||||
pub struct ChartConfig<'a> {
|
||||
pub output_path: &'a Path,
|
||||
pub title: String,
|
||||
pub y_label: String,
|
||||
pub y_format: YAxisFormat,
|
||||
}
|
||||
|
||||
/// Generate a simple line chart from runs
|
||||
pub fn generate(config: ChartConfig, runs: &[Run]) -> Result<()> {
|
||||
if runs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let max_time_ms = runs.iter().map(|r| r.max_timestamp()).max().unwrap_or(1000) + TIME_BUFFER_MS;
|
||||
let max_time_s = max_time_ms as f64 / 1000.0;
|
||||
let max_value = runs.iter().map(|r| r.max_value()).fold(0.0, f64::max);
|
||||
|
||||
let (time_scaled, time_divisor, time_label) = format::time(max_time_s);
|
||||
let (value_scaled, scale_factor, y_label) =
|
||||
scale_y_axis(max_value, &config.y_label, &config.y_format);
|
||||
let x_labels = label_count(time_scaled);
|
||||
|
||||
let root = SVGBackend::new(config.output_path, SIZE).into_drawing_area();
|
||||
root.fill(&BG_COLOR)?;
|
||||
|
||||
let mut chart = ChartBuilder::on(&root)
|
||||
.caption(
|
||||
&config.title,
|
||||
(FONT, FONT_SIZE_BIG).into_font().color(&TEXT_COLOR),
|
||||
)
|
||||
.margin(20)
|
||||
.margin_right(40)
|
||||
.x_label_area_size(50)
|
||||
.margin_left(50)
|
||||
.right_y_label_area_size(75)
|
||||
.build_cartesian_2d(0.0..time_scaled * 1.025, 0.0..value_scaled * 1.1)?;
|
||||
|
||||
configure_mesh(&mut chart, time_label, &y_label, &config.y_format, x_labels)?;
|
||||
|
||||
for (idx, run) in runs.iter().enumerate() {
|
||||
let color = COLORS[idx % COLORS.len()];
|
||||
draw_series(
|
||||
&mut chart,
|
||||
&run.data,
|
||||
&run.id,
|
||||
color,
|
||||
time_divisor,
|
||||
scale_factor,
|
||||
)?;
|
||||
}
|
||||
|
||||
configure_legend(&mut chart)?;
|
||||
root.present()?;
|
||||
println!("Generated: {}", config.output_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a chart with dual series per run (e.g., current + peak memory)
|
||||
pub fn generate_dual(
|
||||
config: ChartConfig,
|
||||
runs: &[DualRun],
|
||||
primary_suffix: &str,
|
||||
secondary_suffix: &str,
|
||||
) -> Result<()> {
|
||||
if runs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let max_time_ms = runs
|
||||
.iter()
|
||||
.flat_map(|r| r.primary.iter().chain(r.secondary.iter()))
|
||||
.map(|d| d.timestamp_ms)
|
||||
.max()
|
||||
.unwrap_or(1000)
|
||||
+ TIME_BUFFER_MS;
|
||||
let max_time_s = max_time_ms as f64 / 1000.0;
|
||||
let max_value = runs.iter().map(|r| r.max_value()).fold(0.0, f64::max);
|
||||
|
||||
let (time_scaled, time_divisor, time_label) = format::time(max_time_s);
|
||||
let (value_scaled, scale_factor, y_label) =
|
||||
scale_y_axis(max_value, &config.y_label, &config.y_format);
|
||||
let x_labels = label_count(time_scaled);
|
||||
|
||||
let root = SVGBackend::new(config.output_path, SIZE).into_drawing_area();
|
||||
root.fill(&BG_COLOR)?;
|
||||
|
||||
let mut chart = ChartBuilder::on(&root)
|
||||
.caption(
|
||||
&config.title,
|
||||
(FONT, FONT_SIZE_BIG).into_font().color(&TEXT_COLOR),
|
||||
)
|
||||
.margin(20)
|
||||
.margin_right(40)
|
||||
.x_label_area_size(50)
|
||||
.margin_left(50)
|
||||
.right_y_label_area_size(75)
|
||||
.build_cartesian_2d(0.0..time_scaled * 1.025, 0.0..value_scaled * 1.1)?;
|
||||
|
||||
configure_mesh(&mut chart, time_label, &y_label, &config.y_format, x_labels)?;
|
||||
|
||||
for (idx, run) in runs.iter().enumerate() {
|
||||
let color = COLORS[idx % COLORS.len()];
|
||||
|
||||
// Primary series (solid)
|
||||
draw_series(
|
||||
&mut chart,
|
||||
&run.primary,
|
||||
&format!("{} {}", run.id, primary_suffix),
|
||||
color,
|
||||
time_divisor,
|
||||
scale_factor,
|
||||
)?;
|
||||
|
||||
// Secondary series (dashed)
|
||||
draw_dashed_series(
|
||||
&mut chart,
|
||||
&run.secondary,
|
||||
&format!("{} {}", run.id, secondary_suffix),
|
||||
color.mix(0.5),
|
||||
time_divisor,
|
||||
scale_factor,
|
||||
)?;
|
||||
}
|
||||
|
||||
configure_legend(&mut chart)?;
|
||||
root.present()?;
|
||||
println!("Generated: {}", config.output_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scale_y_axis(max_value: f64, base_label: &str, y_format: &YAxisFormat) -> (f64, f64, String) {
|
||||
match y_format {
|
||||
YAxisFormat::Bytes => {
|
||||
let (scaled, unit) = format::bytes(max_value);
|
||||
let factor = max_value / scaled;
|
||||
(scaled, factor, format!("{} ({})", base_label, unit))
|
||||
}
|
||||
YAxisFormat::Number => (max_value, 1.0, base_label.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate appropriate label count to avoid duplicates when rounding to integers
|
||||
fn label_count(max_value: f64) -> usize {
|
||||
let max_int = max_value.ceil() as usize;
|
||||
// Don't exceed the range, cap at 12 for readability
|
||||
max_int.clamp(2, 12)
|
||||
}
|
||||
|
||||
type Chart<'a, 'b> = ChartContext<
|
||||
'a,
|
||||
SVGBackend<'b>,
|
||||
Cartesian2d<plotters::coord::types::RangedCoordf64, plotters::coord::types::RangedCoordf64>,
|
||||
>;
|
||||
|
||||
fn configure_mesh(
|
||||
chart: &mut Chart,
|
||||
x_label: &str,
|
||||
y_label: &str,
|
||||
y_format: &YAxisFormat,
|
||||
x_labels: usize,
|
||||
) -> Result<()> {
|
||||
let y_formatter: Box<dyn Fn(&f64) -> String> = match y_format {
|
||||
YAxisFormat::Bytes => Box::new(|y: &f64| {
|
||||
if y.fract() == 0.0 {
|
||||
format!("{:.0}", y)
|
||||
} else {
|
||||
format!("{:.1}", y)
|
||||
}
|
||||
}),
|
||||
YAxisFormat::Number => Box::new(|y: &f64| format::axis_number(*y)),
|
||||
};
|
||||
|
||||
chart
|
||||
.configure_mesh()
|
||||
.disable_mesh()
|
||||
.x_desc(x_label)
|
||||
.y_desc(y_label)
|
||||
.x_label_formatter(&|x| format!("{:.0}", x))
|
||||
.y_label_formatter(&y_formatter)
|
||||
.x_labels(x_labels)
|
||||
.y_labels(10)
|
||||
.x_label_style((FONT, FONT_SIZE).into_font().color(&TEXT_COLOR.mix(0.7)))
|
||||
.y_label_style((FONT, FONT_SIZE).into_font().color(&TEXT_COLOR.mix(0.7)))
|
||||
.axis_style(TEXT_COLOR.mix(0.3))
|
||||
.draw()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw_series(
|
||||
chart: &mut Chart,
|
||||
data: &[DataPoint],
|
||||
label: &str,
|
||||
color: RGBColor,
|
||||
time_divisor: f64,
|
||||
scale_factor: f64,
|
||||
) -> Result<()> {
|
||||
let points = data.iter().map(|d| {
|
||||
(
|
||||
d.timestamp_ms as f64 / 1000.0 / time_divisor,
|
||||
d.value / scale_factor,
|
||||
)
|
||||
});
|
||||
|
||||
chart
|
||||
.draw_series(LineSeries::new(points, color.stroke_width(1)))?
|
||||
.label(label)
|
||||
.legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], color.stroke_width(1)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw_dashed_series(
|
||||
chart: &mut Chart,
|
||||
data: &[DataPoint],
|
||||
label: &str,
|
||||
color: RGBAColor,
|
||||
time_divisor: f64,
|
||||
scale_factor: f64,
|
||||
) -> Result<()> {
|
||||
let points: Vec<_> = data
|
||||
.iter()
|
||||
.map(|d| {
|
||||
(
|
||||
d.timestamp_ms as f64 / 1000.0 / time_divisor,
|
||||
d.value / scale_factor,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Draw dashed line by skipping every other segment
|
||||
chart
|
||||
.draw_series(
|
||||
points
|
||||
.windows(2)
|
||||
.enumerate()
|
||||
.filter(|(i, _)| i % 2 == 0)
|
||||
.map(|(_, w)| PathElement::new(vec![w[0], w[1]], color.stroke_width(2))),
|
||||
)?
|
||||
.label(label)
|
||||
.legend(move |(x, y)| {
|
||||
PathElement::new(
|
||||
vec![(x, y), (x + 10, y), (x + 20, y)],
|
||||
color.stroke_width(2),
|
||||
)
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn configure_legend<'a>(chart: &mut Chart<'a, 'a>) -> Result<()> {
|
||||
chart
|
||||
.configure_series_labels()
|
||||
.position(SeriesLabelPosition::UpperLeft)
|
||||
.label_font((FONT, FONT_SIZE).into_font().color(&TEXT_COLOR.mix(0.9)))
|
||||
.background_style(BG_COLOR.mix(0.98))
|
||||
.border_style(BG_COLOR)
|
||||
.margin(10)
|
||||
.draw()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DataPoint {
|
||||
pub timestamp_ms: u64,
|
||||
pub value: f64,
|
||||
}
|
||||
|
||||
/// Per-run cutoff timestamps for fair comparison
|
||||
pub struct Cutoffs {
|
||||
by_id: HashMap<String, u64>,
|
||||
default: u64,
|
||||
}
|
||||
|
||||
impl Cutoffs {
|
||||
/// Calculate cutoffs from progress runs.
|
||||
/// Finds the common max progress, then returns when each run reached it.
|
||||
pub fn from_progress(progress_runs: &[Run]) -> Self {
|
||||
const TIME_BUFFER_MS: u64 = 10_000;
|
||||
|
||||
if progress_runs.is_empty() {
|
||||
return Self {
|
||||
by_id: HashMap::new(),
|
||||
default: u64::MAX,
|
||||
};
|
||||
}
|
||||
|
||||
// Find the minimum of max progress values (the common point all runs reached)
|
||||
let common_progress = progress_runs
|
||||
.iter()
|
||||
.map(|r| r.max_value())
|
||||
.fold(f64::MAX, f64::min);
|
||||
|
||||
let by_id: HashMap<_, _> = progress_runs
|
||||
.iter()
|
||||
.map(|run| {
|
||||
let cutoff = run
|
||||
.data
|
||||
.iter()
|
||||
.find(|d| d.value >= common_progress)
|
||||
.map(|d| d.timestamp_ms)
|
||||
.unwrap_or_else(|| run.max_timestamp())
|
||||
.saturating_add(TIME_BUFFER_MS);
|
||||
(run.id.clone(), cutoff)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let default = by_id.values().copied().max().unwrap_or(u64::MAX);
|
||||
|
||||
Self { by_id, default }
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> u64 {
|
||||
self.by_id.get(id).copied().unwrap_or(self.default)
|
||||
}
|
||||
|
||||
pub fn trim_runs(&self, runs: &[Run]) -> Vec<Run> {
|
||||
runs.iter().map(|r| r.trimmed(self.get(&r.id))).collect()
|
||||
}
|
||||
|
||||
pub fn trim_dual_runs(&self, runs: &[DualRun]) -> Vec<DualRun> {
|
||||
runs.iter().map(|r| r.trimmed(self.get(&r.id))).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Run {
|
||||
pub id: String,
|
||||
pub data: Vec<DataPoint>,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
pub fn max_timestamp(&self) -> u64 {
|
||||
self.data.iter().map(|d| d.timestamp_ms).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn max_value(&self) -> f64 {
|
||||
self.data.iter().map(|d| d.value).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
pub fn trimmed(&self, max_timestamp_ms: u64) -> Self {
|
||||
Self {
|
||||
id: self.id.clone(),
|
||||
data: self
|
||||
.data
|
||||
.iter()
|
||||
.filter(|d| d.timestamp_ms <= max_timestamp_ms)
|
||||
.cloned()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Two data series from a single run (e.g., memory footprint + peak, or io read + write)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DualRun {
|
||||
pub id: String,
|
||||
pub primary: Vec<DataPoint>,
|
||||
pub secondary: Vec<DataPoint>,
|
||||
}
|
||||
|
||||
impl DualRun {
|
||||
pub fn trimmed(&self, max_timestamp_ms: u64) -> Self {
|
||||
Self {
|
||||
id: self.id.clone(),
|
||||
primary: self
|
||||
.primary
|
||||
.iter()
|
||||
.filter(|d| d.timestamp_ms <= max_timestamp_ms)
|
||||
.cloned()
|
||||
.collect(),
|
||||
secondary: self
|
||||
.secondary
|
||||
.iter()
|
||||
.filter(|d| d.timestamp_ms <= max_timestamp_ms)
|
||||
.cloned()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max_value(&self) -> f64 {
|
||||
self.primary
|
||||
.iter()
|
||||
.chain(self.secondary.iter())
|
||||
.map(|d| d.value)
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_runs(crate_path: &Path, filename: &str) -> Result<Vec<Run>> {
|
||||
let mut runs = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(crate_path)? {
|
||||
let run_path = entry?.path();
|
||||
if !run_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let run_id = run_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or("Invalid run ID")?
|
||||
.to_string();
|
||||
|
||||
// Skip underscore-prefixed or numeric-only directories
|
||||
if run_id.starts_with('_') || run_id.chars().all(|c| c.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let csv_path = run_path.join(filename);
|
||||
if csv_path.exists()
|
||||
&& let Ok(data) = read_csv(&csv_path)
|
||||
{
|
||||
runs.push(Run { id: run_id, data });
|
||||
}
|
||||
}
|
||||
|
||||
Ok(runs)
|
||||
}
|
||||
|
||||
pub fn read_dual_runs(crate_path: &Path, filename: &str) -> Result<Vec<DualRun>> {
|
||||
let mut runs = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(crate_path)? {
|
||||
let run_path = entry?.path();
|
||||
if !run_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let run_id = run_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or("Invalid run ID")?
|
||||
.to_string();
|
||||
|
||||
if run_id.starts_with('_') || run_id.chars().all(|c| c.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let csv_path = run_path.join(filename);
|
||||
if csv_path.exists()
|
||||
&& let Ok((primary, secondary)) = read_dual_csv(&csv_path)
|
||||
{
|
||||
runs.push(DualRun {
|
||||
id: run_id,
|
||||
primary,
|
||||
secondary,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(runs)
|
||||
}
|
||||
|
||||
fn read_csv(path: &Path) -> Result<Vec<DataPoint>> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let data = content
|
||||
.lines()
|
||||
.skip(1) // header
|
||||
.filter_map(|line| {
|
||||
let mut parts = line.split(',');
|
||||
let timestamp_ms = parts.next()?.parse().ok()?;
|
||||
let value = parts.next()?.parse().ok()?;
|
||||
Some(DataPoint {
|
||||
timestamp_ms,
|
||||
value,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
fn read_dual_csv(path: &Path) -> Result<(Vec<DataPoint>, Vec<DataPoint>)> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let mut primary = Vec::new();
|
||||
let mut secondary = Vec::new();
|
||||
|
||||
for line in content.lines().skip(1) {
|
||||
let mut parts = line.split(',');
|
||||
if let (Some(ts), Some(v1), Some(v2)) = (parts.next(), parts.next(), parts.next())
|
||||
&& let (Ok(timestamp_ms), Ok(val1), Ok(val2)) =
|
||||
(ts.parse(), v1.parse::<f64>(), v2.parse::<f64>())
|
||||
{
|
||||
primary.push(DataPoint {
|
||||
timestamp_ms,
|
||||
value: val1,
|
||||
});
|
||||
secondary.push(DataPoint {
|
||||
timestamp_ms,
|
||||
value: val2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok((primary, secondary))
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
const KIB: f64 = 1024.0;
|
||||
const MIB: f64 = KIB * 1024.0;
|
||||
const GIB: f64 = MIB * 1024.0;
|
||||
|
||||
const MINUTE: f64 = 60.0;
|
||||
const HOUR: f64 = 3600.0;
|
||||
|
||||
/// Returns (scaled_value, unit_suffix)
|
||||
pub fn bytes(bytes: f64) -> (f64, &'static str) {
|
||||
if bytes >= GIB {
|
||||
(bytes / GIB, "GiB")
|
||||
} else if bytes >= MIB {
|
||||
(bytes / MIB, "MiB")
|
||||
} else if bytes >= KIB {
|
||||
(bytes / KIB, "KiB")
|
||||
} else {
|
||||
(bytes, "bytes")
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns (scaled_value, divisor, axis_label)
|
||||
pub fn time(seconds: f64) -> (f64, f64, &'static str) {
|
||||
if seconds >= HOUR * 2.0 {
|
||||
(seconds / HOUR, HOUR, "Time (h)")
|
||||
} else if seconds >= MINUTE * 2.0 {
|
||||
(seconds / MINUTE, MINUTE, "Time (min)")
|
||||
} else {
|
||||
(seconds, 1.0, "Time (s)")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn axis_number(value: f64) -> String {
|
||||
if value >= 1000.0 {
|
||||
let k = value / 1000.0;
|
||||
if k.fract() == 0.0 || k >= 100.0 {
|
||||
format!("{:.0}k", k)
|
||||
} else if k >= 10.0 {
|
||||
format!("{:.1}k", k)
|
||||
} else {
|
||||
format!("{:.2}k", k)
|
||||
}
|
||||
} else {
|
||||
format!("{:.0}", value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
mod chart;
|
||||
mod data;
|
||||
mod format;
|
||||
|
||||
use data::{Cutoffs, DualRun, Result, Run, read_dual_runs, read_runs};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
pub struct Visualizer {
|
||||
workspace_root: PathBuf,
|
||||
}
|
||||
|
||||
impl Visualizer {
|
||||
pub fn new(workspace_root: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
workspace_root: workspace_root.as_ref().to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_cargo_env() -> Result<Self> {
|
||||
let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.ok_or("Failed to find workspace root")?
|
||||
.to_path_buf();
|
||||
Ok(Self { workspace_root })
|
||||
}
|
||||
|
||||
pub fn generate_all_charts(&self) -> Result<()> {
|
||||
let benches_dir = self.workspace_root.join("benches");
|
||||
if !benches_dir.exists() {
|
||||
return Err("Benches directory does not exist".into());
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(&benches_dir)? {
|
||||
let path = entry?.path();
|
||||
if path.is_dir() {
|
||||
let crate_name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or("Invalid crate name")?;
|
||||
|
||||
println!("Generating charts for crate: {}", crate_name);
|
||||
self.generate_crate_charts(&path, crate_name)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_crate_charts(&self, crate_path: &Path, crate_name: &str) -> Result<()> {
|
||||
let disk_runs = read_runs(crate_path, "disk.csv")?;
|
||||
let memory_runs = read_dual_runs(crate_path, "memory.csv")?;
|
||||
let progress_runs = read_runs(crate_path, "progress.csv")?;
|
||||
let io_runs = read_dual_runs(crate_path, "io.csv")?;
|
||||
|
||||
// Combined charts (all runs)
|
||||
self.generate_combined_charts(
|
||||
crate_path,
|
||||
crate_name,
|
||||
&disk_runs,
|
||||
&memory_runs,
|
||||
&progress_runs,
|
||||
&io_runs,
|
||||
)?;
|
||||
|
||||
// Individual charts (one per run)
|
||||
self.generate_individual_charts(
|
||||
crate_path,
|
||||
crate_name,
|
||||
&disk_runs,
|
||||
&memory_runs,
|
||||
&progress_runs,
|
||||
&io_runs,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_combined_charts(
|
||||
&self,
|
||||
crate_path: &Path,
|
||||
crate_name: &str,
|
||||
disk_runs: &[Run],
|
||||
memory_runs: &[DualRun],
|
||||
progress_runs: &[Run],
|
||||
io_runs: &[DualRun],
|
||||
) -> Result<()> {
|
||||
let cutoffs = Cutoffs::from_progress(progress_runs);
|
||||
|
||||
// Trim data to per-run cutoffs for fair comparison
|
||||
let disk_trimmed = cutoffs.trim_runs(disk_runs);
|
||||
let memory_trimmed = cutoffs.trim_dual_runs(memory_runs);
|
||||
let io_trimmed = cutoffs.trim_dual_runs(io_runs);
|
||||
|
||||
if !disk_trimmed.is_empty() {
|
||||
chart::generate(
|
||||
chart::ChartConfig {
|
||||
output_path: &crate_path.join("disk.svg"),
|
||||
title: format!("{} — Disk Usage", crate_name),
|
||||
y_label: "Disk Usage".to_string(),
|
||||
y_format: chart::YAxisFormat::Bytes,
|
||||
},
|
||||
&disk_trimmed,
|
||||
)?;
|
||||
}
|
||||
|
||||
if !memory_trimmed.is_empty() {
|
||||
chart::generate_dual(
|
||||
chart::ChartConfig {
|
||||
output_path: &crate_path.join("memory.svg"),
|
||||
title: format!("{} — Memory", crate_name),
|
||||
y_label: "Memory".to_string(),
|
||||
y_format: chart::YAxisFormat::Bytes,
|
||||
},
|
||||
&memory_trimmed,
|
||||
"(current)",
|
||||
"(peak)",
|
||||
)?;
|
||||
}
|
||||
|
||||
if !progress_runs.is_empty() {
|
||||
let progress_trimmed = cutoffs.trim_runs(progress_runs);
|
||||
chart::generate(
|
||||
chart::ChartConfig {
|
||||
output_path: &crate_path.join("progress.svg"),
|
||||
title: format!("{} — Progress", crate_name),
|
||||
y_label: "Progress".to_string(),
|
||||
y_format: chart::YAxisFormat::Number,
|
||||
},
|
||||
&progress_trimmed,
|
||||
)?;
|
||||
}
|
||||
|
||||
if !io_trimmed.is_empty() {
|
||||
// I/O Read (primary column)
|
||||
let io_read: Vec<_> = io_trimmed
|
||||
.iter()
|
||||
.map(|r| Run {
|
||||
id: r.id.clone(),
|
||||
data: r.primary.clone(),
|
||||
})
|
||||
.collect();
|
||||
chart::generate(
|
||||
chart::ChartConfig {
|
||||
output_path: &crate_path.join("io_read.svg"),
|
||||
title: format!("{} — I/O Read", crate_name),
|
||||
y_label: "Bytes Read".to_string(),
|
||||
y_format: chart::YAxisFormat::Bytes,
|
||||
},
|
||||
&io_read,
|
||||
)?;
|
||||
|
||||
// I/O Write (secondary column)
|
||||
let io_write: Vec<_> = io_trimmed
|
||||
.iter()
|
||||
.map(|r| Run {
|
||||
id: r.id.clone(),
|
||||
data: r.secondary.clone(),
|
||||
})
|
||||
.collect();
|
||||
chart::generate(
|
||||
chart::ChartConfig {
|
||||
output_path: &crate_path.join("io_write.svg"),
|
||||
title: format!("{} — I/O Write", crate_name),
|
||||
y_label: "Bytes Written".to_string(),
|
||||
y_format: chart::YAxisFormat::Bytes,
|
||||
},
|
||||
&io_write,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_individual_charts(
|
||||
&self,
|
||||
crate_path: &Path,
|
||||
crate_name: &str,
|
||||
disk_runs: &[Run],
|
||||
memory_runs: &[DualRun],
|
||||
progress_runs: &[Run],
|
||||
io_runs: &[DualRun],
|
||||
) -> Result<()> {
|
||||
for run in disk_runs {
|
||||
let run_path = crate_path.join(&run.id);
|
||||
chart::generate(
|
||||
chart::ChartConfig {
|
||||
output_path: &run_path.join("disk.svg"),
|
||||
title: format!("{} — Disk Usage", crate_name),
|
||||
y_label: "Disk Usage".to_string(),
|
||||
y_format: chart::YAxisFormat::Bytes,
|
||||
},
|
||||
std::slice::from_ref(run),
|
||||
)?;
|
||||
}
|
||||
|
||||
for run in memory_runs {
|
||||
let run_path = crate_path.join(&run.id);
|
||||
chart::generate_dual(
|
||||
chart::ChartConfig {
|
||||
output_path: &run_path.join("memory.svg"),
|
||||
title: format!("{} — Memory", crate_name),
|
||||
y_label: "Memory".to_string(),
|
||||
y_format: chart::YAxisFormat::Bytes,
|
||||
},
|
||||
std::slice::from_ref(run),
|
||||
"(current)",
|
||||
"(peak)",
|
||||
)?;
|
||||
}
|
||||
|
||||
for run in progress_runs {
|
||||
let run_path = crate_path.join(&run.id);
|
||||
chart::generate(
|
||||
chart::ChartConfig {
|
||||
output_path: &run_path.join("progress.svg"),
|
||||
title: format!("{} — Progress", crate_name),
|
||||
y_label: "Progress".to_string(),
|
||||
y_format: chart::YAxisFormat::Number,
|
||||
},
|
||||
std::slice::from_ref(run),
|
||||
)?;
|
||||
}
|
||||
|
||||
for run in io_runs {
|
||||
let run_path = crate_path.join(&run.id);
|
||||
|
||||
let read_run = Run {
|
||||
id: run.id.clone(),
|
||||
data: run.primary.clone(),
|
||||
};
|
||||
chart::generate(
|
||||
chart::ChartConfig {
|
||||
output_path: &run_path.join("io_read.svg"),
|
||||
title: format!("{} — I/O Read", crate_name),
|
||||
y_label: "Bytes Read".to_string(),
|
||||
y_format: chart::YAxisFormat::Bytes,
|
||||
},
|
||||
std::slice::from_ref(&read_run),
|
||||
)?;
|
||||
|
||||
let write_run = Run {
|
||||
id: run.id.clone(),
|
||||
data: run.secondary.clone(),
|
||||
};
|
||||
chart::generate(
|
||||
chart::ChartConfig {
|
||||
output_path: &run_path.join("io_write.svg"),
|
||||
title: format!("{} — I/O Write", crate_name),
|
||||
y_label: "Bytes Written".to_string(),
|
||||
y_format: chart::YAxisFormat::Bytes,
|
||||
},
|
||||
std::slice::from_ref(&write_run),
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
use brk_bencher_visualizer::Visualizer;
|
||||
|
||||
fn main() {
|
||||
let v = Visualizer::from_cargo_env().unwrap();
|
||||
v.generate_all_charts().unwrap();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
clients/
|
||||
/*.json
|
||||
/*.js
|
||||
/*.rs
|
||||
/*.py
|
||||
tests/output/
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "brk_bindgen"
|
||||
description = "A trait-based generator of client bindings for multiple languages"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
brk_cohort = { workspace = true }
|
||||
brk_query = { workspace = true }
|
||||
brk_types = { workspace = true }
|
||||
indexmap = { workspace = true }
|
||||
oas3 = "0.22"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,46 @@
|
||||
# brk_bindgen
|
||||
|
||||
Code generation for BRK client libraries.
|
||||
|
||||
## What It Enables
|
||||
|
||||
Generate typed client libraries for Rust, JavaScript, and Python from the OpenAPI specification. Keeps frontend code in sync with available metrics and API endpoints without manual maintenance.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Multi-language**: Generates Rust, JavaScript, and Python clients
|
||||
- **OpenAPI-driven**: Extracts endpoints and schemas from the OpenAPI spec
|
||||
- **Metric catalog**: Includes all metric IDs and their supported indexes
|
||||
- **Type definitions**: Generates types/interfaces from JSON Schema
|
||||
- **Selective output**: Generate only the languages you need
|
||||
|
||||
## Core API
|
||||
|
||||
```rust,ignore
|
||||
use brk_bindgen::{generate_clients, ClientOutputPaths};
|
||||
|
||||
let paths = ClientOutputPaths::new()
|
||||
.rust("crates/brk_client/src/lib.rs")
|
||||
.javascript("modules/brk-client/index.js")
|
||||
.python("packages/brk_client/brk_client/__init__.py");
|
||||
|
||||
generate_clients(&vecs, &openapi_json, &paths)?;
|
||||
```
|
||||
|
||||
## Generated Clients
|
||||
|
||||
| Language | Contents |
|
||||
|----------|----------|
|
||||
| Rust | Typed API client using `brk_types`, metric catalog |
|
||||
| JavaScript | ES module with JSDoc types, metric catalog, fetch helpers |
|
||||
| Python | Typed client with dataclasses, metric catalog |
|
||||
|
||||
Each client includes:
|
||||
- All REST API endpoints as typed functions
|
||||
- Complete metric catalog with index information
|
||||
- Type definitions for request/response schemas
|
||||
|
||||
## Built On
|
||||
|
||||
- `brk_query` for metric enumeration
|
||||
- `brk_types` for type schemas
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Analysis module for name deconstruction and pattern detection.
|
||||
//!
|
||||
//! This module implements bottom-up analysis of vec names to detect
|
||||
//! common denominators (prefixes/suffixes) and field positions.
|
||||
|
||||
mod names;
|
||||
mod patterns;
|
||||
mod positions;
|
||||
mod tree;
|
||||
|
||||
pub use names::*;
|
||||
pub use patterns::*;
|
||||
pub use positions::*;
|
||||
pub use tree::*;
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Common prefix/suffix detection for series names.
|
||||
//!
|
||||
//! This module provides utilities to find common prefixes and suffixes
|
||||
//! among series names, which is used to detect pattern mode (suffix vs prefix).
|
||||
|
||||
/// Find the longest common prefix among all strings.
|
||||
/// Returns the prefix WITH trailing underscore if found at word boundary.
|
||||
/// Returns None if no common prefix exists.
|
||||
pub fn find_common_prefix(names: &[&str]) -> Option<String> {
|
||||
if names.is_empty() || names.iter().any(|n| n.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let first = names[0];
|
||||
|
||||
// Find character-by-character common prefix
|
||||
let mut prefix_len = 0;
|
||||
for (i, ch) in first.chars().enumerate() {
|
||||
if names.iter().all(|n| n.chars().nth(i) == Some(ch)) {
|
||||
prefix_len = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if prefix_len == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let raw_prefix = &first[..prefix_len];
|
||||
|
||||
// Must end at underscore boundary for semantic coherence
|
||||
if raw_prefix.ends_with('_') {
|
||||
return Some(raw_prefix.to_string());
|
||||
}
|
||||
|
||||
// If raw_prefix equals one of the full names (one name is a prefix of all others),
|
||||
// return it with trailing underscore for proper base detection
|
||||
if names.contains(&raw_prefix) {
|
||||
return Some(format!("{}_", raw_prefix));
|
||||
}
|
||||
|
||||
// Find the last underscore position
|
||||
if let Some(last_underscore) = raw_prefix.rfind('_') {
|
||||
let clean_prefix = &first[..=last_underscore];
|
||||
if names.iter().all(|n| n.starts_with(clean_prefix)) {
|
||||
return Some(clean_prefix.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Find the longest common suffix among all strings.
|
||||
/// Returns the suffix WITH leading underscore if found at word boundary.
|
||||
/// Returns None if no common suffix exists.
|
||||
pub fn find_common_suffix(names: &[&str]) -> Option<String> {
|
||||
if names.is_empty() || names.iter().any(|n| n.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let first = names[0];
|
||||
let first_chars: Vec<char> = first.chars().collect();
|
||||
|
||||
// Find character-by-character common suffix (from the end)
|
||||
let mut suffix_len = 0;
|
||||
for i in 0..first_chars.len() {
|
||||
let idx_from_end = first_chars.len() - 1 - i;
|
||||
let ch = first_chars[idx_from_end];
|
||||
|
||||
let all_match = names.iter().all(|n| {
|
||||
let n_chars: Vec<char> = n.chars().collect();
|
||||
if i >= n_chars.len() {
|
||||
return false;
|
||||
}
|
||||
n_chars[n_chars.len() - 1 - i] == ch
|
||||
});
|
||||
|
||||
if all_match {
|
||||
suffix_len = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if suffix_len == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let raw_suffix = &first[first.len() - suffix_len..];
|
||||
|
||||
// Must start at underscore boundary for semantic coherence
|
||||
if raw_suffix.starts_with('_') {
|
||||
return Some(raw_suffix.to_string());
|
||||
}
|
||||
|
||||
// Check if preceded by underscore in all names (word boundary)
|
||||
let at_word_boundary = names.iter().all(|n| {
|
||||
if *n == raw_suffix {
|
||||
true // Suffix is the whole string
|
||||
} else if let Some(prefix) = n.strip_suffix(raw_suffix) {
|
||||
prefix.ends_with('_')
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
if at_word_boundary {
|
||||
return Some(format!("_{}", raw_suffix));
|
||||
}
|
||||
|
||||
// Find the first underscore position in suffix
|
||||
if let Some(first_underscore) = raw_suffix.find('_') {
|
||||
let clean_suffix = &raw_suffix[first_underscore..];
|
||||
if names.iter().all(|n| n.ends_with(clean_suffix)) {
|
||||
return Some(clean_suffix.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Normalize a prefix string by ensuring it ends with underscore.
|
||||
/// Returns empty string if input is empty.
|
||||
pub fn normalize_prefix(s: &str) -> String {
|
||||
if s.is_empty() {
|
||||
String::new()
|
||||
} else if s.ends_with('_') {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}_", s)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_common_prefix_basic() {
|
||||
let names = vec!["addrs_0sats", "addrs_1sats", "addrs_2sats"];
|
||||
assert_eq!(find_common_prefix(&names), Some("addrs_".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_common_prefix_none() {
|
||||
let names = vec!["foo", "bar", "baz"];
|
||||
assert_eq!(find_common_prefix(&names), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_common_prefix_lth() {
|
||||
let names = vec!["lth_cost_basis_max", "lth_cost_basis_min", "lth_cost_basis"];
|
||||
assert_eq!(
|
||||
find_common_prefix(&names),
|
||||
Some("lth_cost_basis_".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_common_suffix_basic() {
|
||||
let names = vec!["cumulative_supply", "net_supply", "total_supply"];
|
||||
assert_eq!(find_common_suffix(&names), Some("_supply".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_common_prefix_cost_basis() {
|
||||
// With suffix naming convention, cost_basis variants share a common prefix
|
||||
let names = vec!["cost_basis_max", "cost_basis_min", "cost_basis"];
|
||||
assert_eq!(find_common_prefix(&names), Some("cost_basis_".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_common_suffix_none() {
|
||||
let names = vec!["foo", "bar", "baz"];
|
||||
assert_eq!(find_common_suffix(&names), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_common_prefix_one_is_prefix_of_other() {
|
||||
// When one name is a prefix of another (block_count vs block_count_cumulative)
|
||||
let names = vec!["block_count_cumulative", "block_count"];
|
||||
assert_eq!(find_common_prefix(&names), Some("block_count_".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_common_suffix_realized_loss() {
|
||||
let names = vec![
|
||||
"cumulative_realized_loss",
|
||||
"net_realized_loss",
|
||||
"realized_loss",
|
||||
];
|
||||
assert_eq!(
|
||||
find_common_suffix(&names),
|
||||
Some("_realized_loss".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
//! Structural pattern detection using bottom-up analysis.
|
||||
//!
|
||||
//! This module detects repeating tree structures and analyzes them
|
||||
//! using the bottom-up name deconstruction algorithm.
|
||||
|
||||
use std::{
|
||||
cmp::Reverse,
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
};
|
||||
|
||||
use brk_types::{TreeNode, extract_json_type};
|
||||
|
||||
use super::analyze_pattern_modes;
|
||||
use crate::{PatternBaseResult, PatternField, StructuralPattern, to_pascal_case};
|
||||
|
||||
/// Context for pattern detection, holding all intermediate state.
|
||||
struct PatternContext {
|
||||
/// Maps field signatures to pattern names
|
||||
signature_to_pattern: BTreeMap<Vec<PatternField>, String>,
|
||||
/// Counts how many times each signature appears
|
||||
signature_counts: BTreeMap<Vec<PatternField>, usize>,
|
||||
/// Maps normalized signatures to pattern names (for naming consistency)
|
||||
normalized_to_name: BTreeMap<Vec<PatternField>, String>,
|
||||
/// Counts pattern name usage (for unique naming)
|
||||
name_counts: BTreeMap<String, usize>,
|
||||
/// Maps signatures to their child field lists
|
||||
signature_to_child_fields: BTreeMap<Vec<PatternField>, Vec<Vec<PatternField>>>,
|
||||
}
|
||||
|
||||
impl PatternContext {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
signature_to_pattern: BTreeMap::new(),
|
||||
signature_counts: BTreeMap::new(),
|
||||
normalized_to_name: BTreeMap::new(),
|
||||
name_counts: BTreeMap::new(),
|
||||
signature_to_child_fields: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect structural patterns in the tree using a bottom-up approach.
|
||||
///
|
||||
/// Returns (patterns, concrete_to_pattern, concrete_to_type_param, node_bases).
|
||||
/// Each pattern has its `mode` set based on analysis of all instances.
|
||||
/// `node_bases` maps tree paths to their computed PatternBaseResult for use during generation.
|
||||
pub fn detect_structural_patterns(
|
||||
tree: &TreeNode,
|
||||
) -> (
|
||||
Vec<StructuralPattern>,
|
||||
BTreeMap<Vec<PatternField>, String>,
|
||||
BTreeMap<Vec<PatternField>, String>,
|
||||
BTreeMap<String, PatternBaseResult>,
|
||||
) {
|
||||
let mut ctx = PatternContext::new();
|
||||
resolve_branch_patterns(tree, &mut ctx);
|
||||
|
||||
let (generic_patterns, generic_mappings, type_mappings) =
|
||||
detect_generic_patterns(&ctx.signature_to_pattern);
|
||||
|
||||
// Only include patterns that appear 2+ times for the patterns list
|
||||
let mut patterns: Vec<StructuralPattern> = ctx
|
||||
.signature_to_pattern
|
||||
.iter()
|
||||
.filter(|(sig, _)| {
|
||||
ctx.signature_counts.get(*sig).copied().unwrap_or(0) >= 2
|
||||
&& !generic_mappings.contains_key(*sig)
|
||||
})
|
||||
.map(|(fields, name)| {
|
||||
let child_fields_list = ctx.signature_to_child_fields.get(fields);
|
||||
let fields_with_type_params = fields
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| {
|
||||
let type_param = child_fields_list
|
||||
.and_then(|list| list.get(i))
|
||||
.and_then(|cf| type_mappings.get(cf).cloned());
|
||||
PatternField {
|
||||
type_param,
|
||||
..f.clone()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
StructuralPattern {
|
||||
name: name.clone(),
|
||||
fields: fields_with_type_params,
|
||||
mode: None, // Will be determined by analyze_pattern_modes
|
||||
is_generic: false,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Deduplicate patterns by name - different signatures can map to the same name
|
||||
// when their normalized forms match but they can't be unified as generics
|
||||
{
|
||||
let mut seen_names: BTreeSet<String> = BTreeSet::new();
|
||||
patterns.retain(|p| seen_names.insert(p.name.clone()));
|
||||
}
|
||||
|
||||
patterns.extend(generic_patterns);
|
||||
|
||||
// Build pattern lookup for mode analysis (patterns appearing 2+ times)
|
||||
let mut pattern_lookup: BTreeMap<Vec<PatternField>, String> = BTreeMap::new();
|
||||
for (sig, name) in &ctx.signature_to_pattern {
|
||||
if ctx.signature_counts.get(sig).copied().unwrap_or(0) >= 2 {
|
||||
pattern_lookup.insert(sig.clone(), name.clone());
|
||||
}
|
||||
}
|
||||
pattern_lookup.extend(generic_mappings.clone());
|
||||
|
||||
let concrete_to_pattern = pattern_lookup.clone();
|
||||
|
||||
// Analyze pattern modes (suffix vs prefix) from all instances
|
||||
// Also collects node bases for each tree path
|
||||
let node_bases = analyze_pattern_modes(tree, &mut patterns, &pattern_lookup);
|
||||
|
||||
patterns.sort_by_key(|p| Reverse(p.fields.len()));
|
||||
(patterns, concrete_to_pattern, type_mappings, node_bases)
|
||||
}
|
||||
|
||||
/// Detect generic patterns by grouping signatures by their normalized form.
|
||||
fn detect_generic_patterns(
|
||||
signature_to_pattern: &BTreeMap<Vec<PatternField>, String>,
|
||||
) -> (
|
||||
Vec<StructuralPattern>,
|
||||
BTreeMap<Vec<PatternField>, String>,
|
||||
BTreeMap<Vec<PatternField>, String>,
|
||||
) {
|
||||
let mut normalized_groups: BTreeMap<
|
||||
Vec<PatternField>,
|
||||
Vec<(Vec<PatternField>, String, String)>,
|
||||
> = BTreeMap::new();
|
||||
|
||||
for (fields, name) in signature_to_pattern {
|
||||
if let Some((normalized, extracted_type)) = normalize_fields_for_generic(fields) {
|
||||
normalized_groups.entry(normalized).or_default().push((
|
||||
fields.clone(),
|
||||
name.clone(),
|
||||
extracted_type,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let mut patterns = Vec::new();
|
||||
let mut pattern_mappings: BTreeMap<Vec<PatternField>, String> = BTreeMap::new();
|
||||
let mut type_mappings: BTreeMap<Vec<PatternField>, String> = BTreeMap::new();
|
||||
|
||||
for (normalized_fields, group) in normalized_groups {
|
||||
if group.len() >= 2 {
|
||||
let generic_name = group[0].1.clone();
|
||||
for (concrete_fields, _, extracted_type) in &group {
|
||||
pattern_mappings.insert(concrete_fields.clone(), generic_name.clone());
|
||||
type_mappings.insert(concrete_fields.clone(), extracted_type.clone());
|
||||
}
|
||||
patterns.push(StructuralPattern {
|
||||
name: generic_name,
|
||||
fields: normalized_fields,
|
||||
mode: None, // Will be determined by analyze_pattern_modes
|
||||
is_generic: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
(patterns, pattern_mappings, type_mappings)
|
||||
}
|
||||
|
||||
/// Normalize fields by replacing concrete value types with "T".
|
||||
///
|
||||
/// Handles two cases:
|
||||
/// 1. All leaves have identical types (e.g., all `Sats`) -> normalize to `T`
|
||||
/// 2. All leaves have wrapper types with the same inner type (e.g., `Open<Sats>`, `High<Sats>`)
|
||||
/// -> normalize to `Open<T>`, `High<T>`, etc.
|
||||
fn normalize_fields_for_generic(fields: &[PatternField]) -> Option<(Vec<PatternField>, String)> {
|
||||
let leaf_types: Vec<&str> = fields
|
||||
.iter()
|
||||
.filter(|f| f.is_leaf())
|
||||
.map(|f| f.rust_type.as_str())
|
||||
.collect();
|
||||
|
||||
if leaf_types.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let first_type = leaf_types[0];
|
||||
|
||||
// Case 1: All leaf types are identical
|
||||
if leaf_types.iter().all(|t| *t == first_type) {
|
||||
let normalized = fields
|
||||
.iter()
|
||||
.map(|f| {
|
||||
if f.is_branch() {
|
||||
f.clone()
|
||||
} else {
|
||||
PatternField {
|
||||
name: f.name.clone(),
|
||||
rust_type: "T".to_string(),
|
||||
json_type: "T".to_string(),
|
||||
indexes: f.indexes.clone(),
|
||||
type_param: None,
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
return Some((normalized, crate::extract_inner_type(first_type)));
|
||||
}
|
||||
|
||||
// Case 2: Check if all leaves have wrapper types with the same inner type
|
||||
// e.g., Open<Sats>, High<Sats>, Low<Sats>, Close<Sats> all have inner type Sats
|
||||
let inner_types: Vec<String> = leaf_types
|
||||
.iter()
|
||||
.map(|t| crate::extract_inner_type(t))
|
||||
.collect();
|
||||
|
||||
let first_inner = &inner_types[0];
|
||||
|
||||
// Only proceed if inner types differ from originals (meaning they had wrappers)
|
||||
// and all inner types are the same
|
||||
if inner_types.iter().all(|t| t == first_inner)
|
||||
&& inner_types
|
||||
.iter()
|
||||
.zip(leaf_types.iter())
|
||||
.any(|(inner, orig)| inner != *orig)
|
||||
{
|
||||
let normalized = fields
|
||||
.iter()
|
||||
.map(|f| {
|
||||
if f.is_branch() {
|
||||
f.clone()
|
||||
} else {
|
||||
PatternField {
|
||||
name: f.name.clone(),
|
||||
rust_type: replace_inner_type(&f.rust_type, "T"),
|
||||
json_type: replace_inner_type(&f.json_type, "T"),
|
||||
indexes: f.indexes.clone(),
|
||||
type_param: None,
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
return Some((normalized, first_inner.clone()));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Replace the inner type of a wrapper generic with a new type.
|
||||
/// e.g., `Open<Sats>` with replacement `T` -> `Open<T>`
|
||||
fn replace_inner_type(type_str: &str, replacement: &str) -> String {
|
||||
if let Some(start) = type_str.find('<')
|
||||
&& let Some(end) = type_str.rfind('>')
|
||||
&& start < end
|
||||
{
|
||||
format!("{}<{}>", &type_str[..start], replacement)
|
||||
} else {
|
||||
replacement.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively resolve branch patterns bottom-up.
|
||||
fn resolve_branch_patterns(
|
||||
node: &TreeNode,
|
||||
ctx: &mut PatternContext,
|
||||
) -> Option<(String, Vec<PatternField>)> {
|
||||
let TreeNode::Branch(children) = node else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Convert to sorted BTreeMap for consistent pattern detection
|
||||
let sorted_children: BTreeMap<_, _> = children.iter().collect();
|
||||
|
||||
let mut fields: Vec<PatternField> = Vec::new();
|
||||
let mut child_fields_vec: Vec<Vec<PatternField>> = Vec::new();
|
||||
|
||||
for (child_name, child_node) in sorted_children {
|
||||
let (rust_type, json_type, indexes, child_fields) = match child_node {
|
||||
TreeNode::Leaf(leaf) => (
|
||||
leaf.kind().to_string(),
|
||||
extract_json_type(&leaf.schema),
|
||||
leaf.indexes().clone(),
|
||||
Vec::new(),
|
||||
),
|
||||
TreeNode::Branch(_) => {
|
||||
let (pattern_name, child_pattern_fields) = resolve_branch_patterns(child_node, ctx)
|
||||
.unwrap_or_else(|| ("Unknown".to_string(), Vec::new()));
|
||||
(
|
||||
pattern_name.clone(),
|
||||
pattern_name,
|
||||
BTreeSet::new(),
|
||||
child_pattern_fields,
|
||||
)
|
||||
}
|
||||
};
|
||||
fields.push(PatternField {
|
||||
name: child_name.clone(),
|
||||
rust_type,
|
||||
json_type,
|
||||
indexes,
|
||||
type_param: None,
|
||||
});
|
||||
child_fields_vec.push(child_fields);
|
||||
}
|
||||
|
||||
// Fields are already sorted since we iterated over BTreeMap
|
||||
*ctx.signature_counts.entry(fields.clone()).or_insert(0) += 1;
|
||||
|
||||
ctx.signature_to_child_fields
|
||||
.entry(fields.clone())
|
||||
.or_insert(child_fields_vec);
|
||||
|
||||
let pattern_name = if let Some(existing) = ctx.signature_to_pattern.get(&fields) {
|
||||
existing.clone()
|
||||
} else {
|
||||
let normalized = normalize_fields_for_naming(&fields);
|
||||
// Generate stable name from first word of each field (deduped, sorted)
|
||||
let first_words: BTreeSet<String> = fields
|
||||
.iter()
|
||||
.filter_map(|f| f.name.split('_').next())
|
||||
.map(to_pascal_case)
|
||||
.collect();
|
||||
let combined: String = first_words.into_iter().collect();
|
||||
let name = ctx
|
||||
.normalized_to_name
|
||||
.entry(normalized)
|
||||
.or_insert_with(|| generate_pattern_name(&combined, &mut ctx.name_counts))
|
||||
.clone();
|
||||
ctx.signature_to_pattern
|
||||
.insert(fields.clone(), name.clone());
|
||||
name
|
||||
};
|
||||
|
||||
Some((pattern_name, fields))
|
||||
}
|
||||
|
||||
/// Normalize fields for naming (same structure = same name).
|
||||
/// Only erases leaf types when all leaves share the same type — this ensures
|
||||
/// mixed-type signatures (e.g., StoredU32 raw + StoredU64 cumulative) get a
|
||||
/// different name than same-type signatures that can be genericized.
|
||||
fn normalize_fields_for_naming(fields: &[PatternField]) -> Vec<PatternField> {
|
||||
let leaf_types: Vec<&str> = fields
|
||||
.iter()
|
||||
.filter(|f| !f.is_branch())
|
||||
.map(|f| f.rust_type.as_str())
|
||||
.collect();
|
||||
let all_same = !leaf_types.is_empty() && leaf_types.iter().all(|t| *t == leaf_types[0]);
|
||||
|
||||
fields
|
||||
.iter()
|
||||
.map(|f| {
|
||||
if f.is_branch() || !all_same {
|
||||
f.clone()
|
||||
} else {
|
||||
PatternField {
|
||||
name: f.name.clone(),
|
||||
rust_type: "_".to_string(),
|
||||
json_type: "_".to_string(),
|
||||
indexes: f.indexes.clone(),
|
||||
type_param: None,
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate a unique pattern name.
|
||||
fn generate_pattern_name(field_name: &str, name_counts: &mut BTreeMap<String, usize>) -> String {
|
||||
let pascal = to_pascal_case(field_name);
|
||||
let sanitized = if pascal.chars().next().is_some_and(|c| c.is_ascii_digit()) {
|
||||
format!("_{}", pascal)
|
||||
} else {
|
||||
pascal
|
||||
};
|
||||
|
||||
let base_name = format!("{}Pattern", sanitized);
|
||||
let count = name_counts.entry(base_name.clone()).or_insert(0);
|
||||
*count += 1;
|
||||
|
||||
if *count == 1 {
|
||||
base_name
|
||||
} else {
|
||||
format!("{}{}", base_name, count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
//! Tree traversal helpers for pattern analysis.
|
||||
//!
|
||||
//! This module provides utilities for working with the TreeNode structure,
|
||||
//! including leaf name extraction and index pattern detection.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use brk_types::{Index, TreeNode, extract_json_type};
|
||||
use indexmap::IndexMap;
|
||||
|
||||
use crate::{IndexSetPattern, PatternField, child_type_name};
|
||||
|
||||
use super::{find_common_prefix, find_common_suffix, normalize_prefix};
|
||||
|
||||
/// Get the shortest leaf name from a tree node.
|
||||
///
|
||||
/// This is useful for pattern base analysis where we want the "base" case
|
||||
/// (e.g., the leaf without suffix like `_btc` or `_usd`).
|
||||
pub(super) fn get_shortest_leaf_name(node: &TreeNode) -> Option<String> {
|
||||
match node {
|
||||
TreeNode::Leaf(leaf) => Some(leaf.name().to_string()),
|
||||
TreeNode::Branch(children) => children
|
||||
.values()
|
||||
.filter_map(get_shortest_leaf_name)
|
||||
.min_by_key(|name| name.len()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the field signature for a branch node's children.
|
||||
/// Fields are sorted alphabetically for consistent pattern matching.
|
||||
pub fn get_node_fields(
|
||||
children: &IndexMap<String, TreeNode>,
|
||||
pattern_lookup: &BTreeMap<Vec<PatternField>, String>,
|
||||
) -> Vec<PatternField> {
|
||||
let mut fields: Vec<PatternField> = children
|
||||
.iter()
|
||||
.map(|(name, node)| {
|
||||
let (rust_type, json_type, indexes) = match node {
|
||||
TreeNode::Leaf(leaf) => (
|
||||
leaf.kind().to_string(),
|
||||
extract_json_type(&leaf.schema),
|
||||
leaf.indexes().clone(),
|
||||
),
|
||||
TreeNode::Branch(grandchildren) => {
|
||||
let child_fields = get_node_fields(grandchildren, pattern_lookup);
|
||||
let pattern_name = pattern_lookup
|
||||
.get(&child_fields)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
(pattern_name.clone(), pattern_name, BTreeSet::new())
|
||||
}
|
||||
};
|
||||
PatternField {
|
||||
name: name.clone(),
|
||||
rust_type,
|
||||
json_type,
|
||||
indexes,
|
||||
type_param: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// Sort for consistent pattern matching (display order preserved in IndexMap)
|
||||
fields.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
fields
|
||||
}
|
||||
|
||||
/// Detect index patterns (sets of indexes that appear together on series).
|
||||
pub fn detect_index_patterns(tree: &TreeNode) -> Vec<IndexSetPattern> {
|
||||
let mut unique_index_sets: BTreeSet<BTreeSet<Index>> = BTreeSet::new();
|
||||
collect_index_sets_from_tree(tree, &mut unique_index_sets);
|
||||
|
||||
// Sort by count (descending) then by first index name for deterministic ordering
|
||||
let mut sorted_sets: Vec<_> = unique_index_sets
|
||||
.into_iter()
|
||||
.filter(|indexes| !indexes.is_empty())
|
||||
.collect();
|
||||
sorted_sets.sort_by(|a, b| {
|
||||
b.len()
|
||||
.cmp(&a.len())
|
||||
.then_with(|| a.iter().next().cmp(&b.iter().next()))
|
||||
});
|
||||
|
||||
// Assign unique sequential names
|
||||
sorted_sets
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, indexes)| IndexSetPattern {
|
||||
name: format!("SeriesPattern{}", i + 1),
|
||||
indexes,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn collect_index_sets_from_tree(
|
||||
node: &TreeNode,
|
||||
unique_index_sets: &mut BTreeSet<BTreeSet<Index>>,
|
||||
) {
|
||||
match node {
|
||||
TreeNode::Leaf(leaf) => {
|
||||
unique_index_sets.insert(leaf.indexes().clone());
|
||||
}
|
||||
TreeNode::Branch(children) => {
|
||||
for child in children.values() {
|
||||
collect_index_sets_from_tree(child, unique_index_sets);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of analyzing a pattern instance's base.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PatternBaseResult {
|
||||
/// The computed base name for the pattern.
|
||||
pub base: String,
|
||||
/// Whether an outlier child was excluded to find the pattern.
|
||||
/// If true, pattern factory should not be used.
|
||||
pub has_outlier: bool,
|
||||
/// Whether this instance uses suffix mode (common prefix) or prefix mode (common suffix).
|
||||
/// Used to check compatibility with the pattern's mode.
|
||||
pub is_suffix_mode: bool,
|
||||
/// The field parts (suffix in suffix mode, prefix in prefix mode) for each field.
|
||||
/// Used to check if instance field parts match the pattern's field parts.
|
||||
pub field_parts: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl PatternBaseResult {
|
||||
/// Create a default result that forces inlining (has_outlier = true).
|
||||
/// Use when no pattern base could be computed during lookup.
|
||||
pub fn force_inline() -> Self {
|
||||
Self {
|
||||
base: String::new(),
|
||||
has_outlier: true,
|
||||
is_suffix_mode: true,
|
||||
field_parts: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an empty result with no outlier.
|
||||
/// Use for root-level patterns or when children have no common pattern.
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
base: String::new(),
|
||||
has_outlier: false,
|
||||
is_suffix_mode: true,
|
||||
field_parts: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the series base for a pattern instance by analyzing direct children.
|
||||
///
|
||||
/// Uses the shortest leaf names from direct children to find common prefix/suffix.
|
||||
///
|
||||
/// If the initial analysis fails to find a common pattern, it tries excluding
|
||||
/// each child one at a time to detect outliers (e.g., a mismatched "base" field
|
||||
/// from indexer/computed tree merging).
|
||||
///
|
||||
/// Returns both the base and whether an outlier was detected.
|
||||
pub fn get_pattern_instance_base(node: &TreeNode) -> PatternBaseResult {
|
||||
let child_names = get_direct_children_for_analysis(node);
|
||||
if child_names.is_empty() {
|
||||
return PatternBaseResult::empty();
|
||||
}
|
||||
|
||||
// Try to find common base from leaf names
|
||||
if let Some(result) = try_find_base(&child_names, false) {
|
||||
return PatternBaseResult {
|
||||
base: result.base,
|
||||
has_outlier: result.has_outlier,
|
||||
is_suffix_mode: result.is_suffix_mode,
|
||||
field_parts: result.field_parts,
|
||||
};
|
||||
}
|
||||
|
||||
// If no common pattern found and we have enough children, try excluding outliers
|
||||
if child_names.len() > 2 {
|
||||
for i in 0..child_names.len() {
|
||||
let filtered: Vec<_> = child_names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(j, _)| *j != i)
|
||||
.map(|(_, v)| v.clone())
|
||||
.collect();
|
||||
|
||||
if let Some(result) = try_find_base(&filtered, true) {
|
||||
return PatternBaseResult {
|
||||
base: result.base,
|
||||
has_outlier: true,
|
||||
is_suffix_mode: result.is_suffix_mode,
|
||||
field_parts: result.field_parts,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: no common prefix/suffix found - this is a root-level pattern
|
||||
// Return empty base so series names are used directly
|
||||
PatternBaseResult::empty()
|
||||
}
|
||||
|
||||
/// Result of try_find_base: base name, has_outlier flag, is_suffix_mode flag, and field_parts.
|
||||
struct FindBaseResult {
|
||||
base: String,
|
||||
has_outlier: bool,
|
||||
is_suffix_mode: bool,
|
||||
field_parts: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Try to find a common base from child names using prefix/suffix detection.
|
||||
/// Returns Some(FindBaseResult) if found.
|
||||
fn try_find_base(
|
||||
child_names: &[(String, String)],
|
||||
is_outlier_attempt: bool,
|
||||
) -> Option<FindBaseResult> {
|
||||
let leaf_names: Vec<&str> = child_names.iter().map(|(_, n)| n.as_str()).collect();
|
||||
|
||||
// Try common prefix first (suffix mode)
|
||||
if let Some(prefix) = find_common_prefix(&leaf_names) {
|
||||
let base = prefix.trim_end_matches('_').to_string();
|
||||
let mut field_parts = BTreeMap::new();
|
||||
for (field_name, leaf_name) in child_names {
|
||||
// Compute the suffix part for this field
|
||||
let suffix = if leaf_name == &base {
|
||||
String::new()
|
||||
} else {
|
||||
leaf_name
|
||||
.strip_prefix(&prefix)
|
||||
.unwrap_or(leaf_name)
|
||||
.to_string()
|
||||
};
|
||||
field_parts.insert(field_name.clone(), suffix);
|
||||
}
|
||||
return Some(FindBaseResult {
|
||||
base,
|
||||
has_outlier: is_outlier_attempt,
|
||||
is_suffix_mode: true,
|
||||
field_parts,
|
||||
});
|
||||
}
|
||||
|
||||
// Try common suffix (prefix mode)
|
||||
if let Some(suffix) = find_common_suffix(&leaf_names) {
|
||||
let base = suffix.trim_start_matches('_').to_string();
|
||||
let mut field_parts = BTreeMap::new();
|
||||
for (field_name, leaf_name) in child_names {
|
||||
// Compute the prefix part for this field, normalized to end with _
|
||||
let prefix_part = leaf_name
|
||||
.strip_suffix(&suffix)
|
||||
.map(normalize_prefix)
|
||||
.unwrap_or_default();
|
||||
field_parts.insert(field_name.clone(), prefix_part);
|
||||
}
|
||||
return Some(FindBaseResult {
|
||||
base,
|
||||
has_outlier: is_outlier_attempt,
|
||||
is_suffix_mode: false,
|
||||
field_parts,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Get (field_name, shortest_leaf_name) pairs for direct children of a branch node.
|
||||
///
|
||||
/// Uses the shortest leaf name from each child subtree to find the "base" case
|
||||
/// (the leaf without suffix modifiers like `_btc` or `_usd`).
|
||||
fn get_direct_children_for_analysis(node: &TreeNode) -> Vec<(String, String)> {
|
||||
match node {
|
||||
TreeNode::Leaf(leaf) => vec![(leaf.name().to_string(), leaf.name().to_string())],
|
||||
TreeNode::Branch(children) => children
|
||||
.iter()
|
||||
.filter_map(|(field_name, child)| {
|
||||
get_shortest_leaf_name(child).map(|leaf_name| (field_name.clone(), leaf_name))
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Infer the accumulated name for a child node based on a descendant leaf name.
|
||||
pub fn infer_accumulated_name(parent_acc: &str, field_name: &str, descendant_leaf: &str) -> String {
|
||||
if let Some(pos) = descendant_leaf.find(field_name) {
|
||||
if pos == 0 {
|
||||
return field_name.to_string();
|
||||
}
|
||||
if pos > 0 && descendant_leaf.chars().nth(pos - 1) == Some('_') {
|
||||
return if parent_acc.is_empty() {
|
||||
field_name.to_string()
|
||||
} else {
|
||||
format!("{}_{}", parent_acc, field_name)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if parent_acc.is_empty() {
|
||||
field_name.to_string()
|
||||
} else {
|
||||
format!("{}_{}", parent_acc, field_name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get fields with child field information for generic pattern lookup.
|
||||
pub fn get_fields_with_child_info(
|
||||
children: &IndexMap<String, TreeNode>,
|
||||
parent_name: &str,
|
||||
pattern_lookup: &BTreeMap<Vec<PatternField>, String>,
|
||||
) -> Vec<(PatternField, Option<Vec<PatternField>>)> {
|
||||
children
|
||||
.iter()
|
||||
.map(|(name, node)| {
|
||||
let (rust_type, json_type, indexes, child_fields) = match node {
|
||||
TreeNode::Leaf(leaf) => (
|
||||
leaf.kind().to_string(),
|
||||
extract_json_type(&leaf.schema),
|
||||
leaf.indexes().clone(),
|
||||
None,
|
||||
),
|
||||
TreeNode::Branch(grandchildren) => {
|
||||
let child_fields = get_node_fields(grandchildren, pattern_lookup);
|
||||
let pattern_name = pattern_lookup
|
||||
.get(&child_fields)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| child_type_name(parent_name, name));
|
||||
(
|
||||
pattern_name.clone(),
|
||||
pattern_name,
|
||||
BTreeSet::new(),
|
||||
Some(child_fields),
|
||||
)
|
||||
}
|
||||
};
|
||||
(
|
||||
PatternField {
|
||||
name: name.clone(),
|
||||
rust_type,
|
||||
json_type,
|
||||
indexes,
|
||||
type_param: None,
|
||||
},
|
||||
child_fields,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use brk_types::{SeriesLeaf, SeriesLeafWithSchema, TreeNode};
|
||||
|
||||
fn make_leaf(name: &str) -> TreeNode {
|
||||
let leaf = SeriesLeaf {
|
||||
name: name.to_string(),
|
||||
kind: "TestType".to_string(),
|
||||
indexes: BTreeSet::new(),
|
||||
};
|
||||
TreeNode::Leaf(SeriesLeafWithSchema::new(leaf, serde_json::json!({})))
|
||||
}
|
||||
|
||||
fn make_branch(children: Vec<(&str, TreeNode)>) -> TreeNode {
|
||||
let map: IndexMap<String, TreeNode> = children
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v))
|
||||
.collect();
|
||||
TreeNode::Branch(map)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_pattern_instance_base_with_base_field() {
|
||||
// Simulates vbytes tree: has base field with block_vbytes leaf
|
||||
let tree = make_branch(vec![
|
||||
(
|
||||
"base",
|
||||
make_branch(vec![("day1", make_leaf("block_vbytes"))]),
|
||||
),
|
||||
(
|
||||
"average",
|
||||
make_branch(vec![("day1", make_leaf("block_vbytes_average"))]),
|
||||
),
|
||||
(
|
||||
"sum",
|
||||
make_branch(vec![("day1", make_leaf("block_vbytes_sum"))]),
|
||||
),
|
||||
]);
|
||||
|
||||
let result = get_pattern_instance_base(&tree);
|
||||
assert_eq!(result.base, "block_vbytes");
|
||||
assert!(!result.has_outlier);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_pattern_instance_base_without_base_field() {
|
||||
// Simulates weight tree: NO base field, only suffixed series
|
||||
let tree = make_branch(vec![
|
||||
(
|
||||
"average",
|
||||
make_branch(vec![("day1", make_leaf("block_weight_average"))]),
|
||||
),
|
||||
(
|
||||
"sum",
|
||||
make_branch(vec![("day1", make_leaf("block_weight_sum"))]),
|
||||
),
|
||||
(
|
||||
"cumulative",
|
||||
make_branch(vec![("day1", make_leaf("block_weight_cumulative"))]),
|
||||
),
|
||||
(
|
||||
"max",
|
||||
make_branch(vec![("day1", make_leaf("block_weight_max"))]),
|
||||
),
|
||||
(
|
||||
"min",
|
||||
make_branch(vec![("day1", make_leaf("block_weight_min"))]),
|
||||
),
|
||||
]);
|
||||
|
||||
let result = get_pattern_instance_base(&tree);
|
||||
assert_eq!(result.base, "block_weight");
|
||||
assert!(!result.has_outlier);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_pattern_instance_base_with_duplicate_base_field() {
|
||||
// What if there's a "base" field that points to the same leaf as "average"?
|
||||
// This could happen if the tree generation creates a base field that shares leaves with average
|
||||
let tree = make_branch(vec![
|
||||
(
|
||||
"base",
|
||||
make_branch(vec![("day1", make_leaf("block_weight_average"))]),
|
||||
),
|
||||
(
|
||||
"average",
|
||||
make_branch(vec![("day1", make_leaf("block_weight_average"))]),
|
||||
),
|
||||
(
|
||||
"sum",
|
||||
make_branch(vec![("day1", make_leaf("block_weight_sum"))]),
|
||||
),
|
||||
]);
|
||||
|
||||
let result = get_pattern_instance_base(&tree);
|
||||
// Common prefix among all children is "block_weight_"
|
||||
assert_eq!(result.base, "block_weight");
|
||||
assert!(!result.has_outlier);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_pattern_instance_base_with_mismatched_base_name() {
|
||||
// Simulates the actual bug: indexed tree's "base" field has name "weight"
|
||||
// but computed tree's derived series use "block_weight_*" prefix.
|
||||
// After tree merge, we get a base field with mismatched naming.
|
||||
let tree = make_branch(vec![
|
||||
("base", make_leaf("weight")), // Outlier - doesn't match pattern
|
||||
("average", make_leaf("block_weight_average")),
|
||||
("sum", make_leaf("block_weight_sum")),
|
||||
("cumulative", make_leaf("block_weight_cumulative")),
|
||||
("max", make_leaf("block_weight_max")),
|
||||
("min", make_leaf("block_weight_min")),
|
||||
]);
|
||||
|
||||
let result = get_pattern_instance_base(&tree);
|
||||
// Should detect "weight" as outlier and find common prefix from others
|
||||
assert_eq!(result.base, "block_weight");
|
||||
assert!(result.has_outlier); // Pattern factory should NOT be used
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_pattern_instance_base_root_level_no_common_pattern() {
|
||||
// Simulates root-level pattern with series that have no common prefix/suffix.
|
||||
// These names have no shared prefix or suffix, even when excluding any one.
|
||||
// In this case, we should return empty base so series names are used directly.
|
||||
let tree = make_branch(vec![
|
||||
("alpha", make_leaf("foo_series")),
|
||||
("beta", make_leaf("bar_value")),
|
||||
("gamma", make_leaf("baz_count")),
|
||||
]);
|
||||
|
||||
let result = get_pattern_instance_base(&tree);
|
||||
// No common prefix or suffix - return empty base
|
||||
assert_eq!(result.base, "");
|
||||
assert!(!result.has_outlier);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_pattern_instance_base_two_children_no_pattern() {
|
||||
// Two children with no common pattern - should still return empty base
|
||||
let tree = make_branch(vec![
|
||||
("foo", make_leaf("alpha")),
|
||||
("bar", make_leaf("beta")),
|
||||
]);
|
||||
|
||||
let result = get_pattern_instance_base(&tree);
|
||||
assert_eq!(result.base, "");
|
||||
assert!(!result.has_outlier);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_pattern_instance_base_with_outlier_excluded() {
|
||||
// Simulates the realized pattern: adjusted_sopr, sopr, asopr.
|
||||
// When "asopr" is excluded as outlier, "adjusted_sopr" and "sopr" share suffix "_sopr".
|
||||
// The outlier detection should find base="sopr" with has_outlier=true.
|
||||
let tree = make_branch(vec![
|
||||
("adjustedSopr", make_leaf("adjusted_sopr")),
|
||||
("sopr", make_leaf("sopr")),
|
||||
("asopr", make_leaf("asopr")),
|
||||
]);
|
||||
|
||||
let result = get_pattern_instance_base(&tree);
|
||||
// Outlier detected - pattern base found by excluding "asopr"
|
||||
assert_eq!(result.base, "sopr");
|
||||
assert!(result.has_outlier); // Pattern factory should NOT be used (inline instead)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_pattern_instance_base_suffix_mode_price_ago() {
|
||||
// Simulates price_ago pattern: price_24h_ago, price_1w_ago, price_10y_ago
|
||||
// Common prefix is "price_", so this is suffix mode
|
||||
let tree = make_branch(vec![
|
||||
("_24h", make_leaf("price_24h_ago")),
|
||||
("_1w", make_leaf("price_1w_ago")),
|
||||
("_1m", make_leaf("price_1m_ago")),
|
||||
("_10y", make_leaf("price_10y_ago")),
|
||||
]);
|
||||
|
||||
let result = get_pattern_instance_base(&tree);
|
||||
assert_eq!(result.base, "price");
|
||||
assert!(result.is_suffix_mode); // Suffix mode: _m(base, "24h_ago")
|
||||
assert!(!result.has_outlier);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_pattern_instance_base_prefix_mode_price_returns() {
|
||||
// Simulates price_returns pattern: 24h_price_returns, 1w_price_returns, 10y_price_returns
|
||||
// Common suffix is "_price_returns", so this is prefix mode
|
||||
let tree = make_branch(vec![
|
||||
("_24h", make_leaf("24h_price_returns")),
|
||||
("_1w", make_leaf("1w_price_returns")),
|
||||
("_1m", make_leaf("1m_price_returns")),
|
||||
("_10y", make_leaf("10y_price_returns")),
|
||||
]);
|
||||
|
||||
let result = get_pattern_instance_base(&tree);
|
||||
assert_eq!(result.base, "price_returns");
|
||||
assert!(!result.is_suffix_mode); // Prefix mode: _p("24h_", base)
|
||||
assert!(!result.has_outlier);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mode_detection_distinguishes_similar_structures() {
|
||||
// Two patterns with identical structure but different naming conventions
|
||||
// should have different modes detected
|
||||
|
||||
// Suffix mode pattern
|
||||
let suffix_tree = make_branch(vec![
|
||||
("_1y", make_leaf("lump_sum_1y")),
|
||||
("_2y", make_leaf("lump_sum_2y")),
|
||||
("_5y", make_leaf("lump_sum_5y")),
|
||||
]);
|
||||
let suffix_result = get_pattern_instance_base(&suffix_tree);
|
||||
assert_eq!(suffix_result.base, "lump_sum");
|
||||
assert!(suffix_result.is_suffix_mode);
|
||||
|
||||
// Prefix mode pattern (same structure, different naming)
|
||||
let prefix_tree = make_branch(vec![
|
||||
("_1y", make_leaf("1y_returns")),
|
||||
("_2y", make_leaf("2y_returns")),
|
||||
("_5y", make_leaf("5y_returns")),
|
||||
]);
|
||||
let prefix_result = get_pattern_instance_base(&prefix_tree);
|
||||
assert_eq!(prefix_result.base, "returns");
|
||||
assert!(!prefix_result.is_suffix_mode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! JavaScript language syntax implementation.
|
||||
|
||||
use crate::{GenericSyntax, LanguageSyntax, to_camel_case};
|
||||
|
||||
/// JavaScript-specific code generation syntax.
|
||||
pub struct JavaScriptSyntax;
|
||||
|
||||
impl LanguageSyntax for JavaScriptSyntax {
|
||||
fn field_name(&self, name: &str) -> String {
|
||||
to_camel_case(name)
|
||||
}
|
||||
|
||||
fn path_expr(&self, base_var: &str, suffix: &str) -> String {
|
||||
// Convert base_var to camelCase for JavaScript
|
||||
let var_name = to_camel_case(base_var);
|
||||
format!("`${{{}}}{}`", var_name, suffix)
|
||||
}
|
||||
|
||||
fn suffix_expr(&self, acc_var: &str, relative: &str) -> String {
|
||||
let var_name = to_camel_case(acc_var);
|
||||
if relative.is_empty() {
|
||||
// Identity: just return acc
|
||||
var_name
|
||||
} else {
|
||||
// _m(acc, relative) -> acc ? `${acc}_relative` : 'relative'
|
||||
format!("_m({}, '{}')", var_name, relative)
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix_expr(&self, prefix: &str, acc_var: &str) -> String {
|
||||
let var_name = to_camel_case(acc_var);
|
||||
if prefix.is_empty() {
|
||||
// Identity: just return acc
|
||||
var_name
|
||||
} else {
|
||||
// _p(prefix, acc) -> acc ? `${prefix}${acc}` : 'prefix_without_underscore'
|
||||
let prefix_base = prefix.trim_end_matches('_');
|
||||
format!("_p('{}', {})", prefix_base, var_name)
|
||||
}
|
||||
}
|
||||
|
||||
fn constructor(&self, type_name: &str, path_expr: &str) -> String {
|
||||
format!("create{}(client, {})", type_name, path_expr)
|
||||
}
|
||||
|
||||
fn field_init(&self, indent: &str, name: &str, _type_ann: &str, value: &str) -> String {
|
||||
// JavaScript uses object literal syntax; type is in JSDoc, not in assignment
|
||||
format!("{}{}: {},", indent, name, value)
|
||||
}
|
||||
|
||||
fn generic_syntax(&self) -> GenericSyntax {
|
||||
GenericSyntax::JAVASCRIPT
|
||||
}
|
||||
|
||||
fn string_literal(&self, value: &str) -> String {
|
||||
format!("'{}'", value)
|
||||
}
|
||||
|
||||
fn constructor_name(&self, type_name: &str) -> String {
|
||||
format!("create{}", type_name)
|
||||
}
|
||||
|
||||
fn disc_arg_expr(&self, template: &str) -> String {
|
||||
if template == "{disc}" {
|
||||
"disc".to_string()
|
||||
} else if template.is_empty() {
|
||||
"''".to_string()
|
||||
} else if !template.contains("{disc}") {
|
||||
format!("'{}'", template)
|
||||
} else if template.ends_with("{disc}") {
|
||||
let static_part = template.trim_end_matches("{disc}").trim_end_matches('_');
|
||||
format!("_m('{}', disc)", static_part)
|
||||
} else {
|
||||
let js_template = template.replace("{disc}", "${disc}");
|
||||
format!("`{}`", js_template)
|
||||
}
|
||||
}
|
||||
|
||||
fn template_expr(&self, acc_var: &str, template: &str) -> String {
|
||||
let var_name = to_camel_case(acc_var);
|
||||
if template.is_empty() {
|
||||
// Identity — just pass disc
|
||||
format!("_m({}, disc)", var_name)
|
||||
} else if template == "{disc}" {
|
||||
// Template IS the discriminator
|
||||
format!("_m({}, disc)", var_name)
|
||||
} else if !template.contains("{disc}") {
|
||||
// Static suffix — no disc involved
|
||||
format!("_m({}, '{}')", var_name, template)
|
||||
} else {
|
||||
// Template with {disc}: use nested _m for proper separator handling
|
||||
// "ratio_{disc}_bps" → split on {disc} → _m(_m(acc, 'ratio'), disc) then _bps
|
||||
// But this is complex. For embedded disc, use string interpolation.
|
||||
// For suffix disc (ends with {disc}), use _m composition.
|
||||
if let Some(static_part) = template.strip_suffix("{disc}") {
|
||||
if static_part.is_empty() {
|
||||
format!("_m({}, disc)", var_name)
|
||||
} else {
|
||||
let static_part = static_part.trim_end_matches('_');
|
||||
format!("_m(_m({}, '{}'), disc)", var_name, static_part)
|
||||
}
|
||||
} else {
|
||||
// Embedded disc — use template literal
|
||||
let js_template = template.replace("{disc}", "${disc}");
|
||||
format!("_m({}, `{}`)", var_name, js_template)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Language-specific syntax backends.
|
||||
//!
|
||||
//! This module contains implementations of the `LanguageSyntax` trait
|
||||
//! for each supported target language.
|
||||
|
||||
mod javascript;
|
||||
mod python;
|
||||
mod rust;
|
||||
|
||||
pub use javascript::JavaScriptSyntax;
|
||||
pub use python::PythonSyntax;
|
||||
pub use rust::RustSyntax;
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Python language syntax implementation.
|
||||
|
||||
use crate::{GenericSyntax, LanguageSyntax, escape_python_keyword, to_snake_case};
|
||||
|
||||
/// Python-specific code generation syntax.
|
||||
pub struct PythonSyntax;
|
||||
|
||||
impl LanguageSyntax for PythonSyntax {
|
||||
fn field_name(&self, name: &str) -> String {
|
||||
escape_python_keyword(&to_snake_case(name))
|
||||
}
|
||||
|
||||
fn path_expr(&self, base_var: &str, suffix: &str) -> String {
|
||||
format!("f'{{{}}}{}'", base_var, suffix)
|
||||
}
|
||||
|
||||
fn suffix_expr(&self, acc_var: &str, relative: &str) -> String {
|
||||
if relative.is_empty() {
|
||||
// Identity: just return acc
|
||||
acc_var.to_string()
|
||||
} else {
|
||||
// _m(acc, relative) -> f'{acc}_{relative}' if acc else 'relative'
|
||||
format!("_m({}, '{}')", acc_var, relative)
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix_expr(&self, prefix: &str, acc_var: &str) -> String {
|
||||
if prefix.is_empty() {
|
||||
// Identity: just return acc
|
||||
acc_var.to_string()
|
||||
} else {
|
||||
// _p(prefix, acc) -> f'{prefix}{acc}' if acc else 'prefix_base'
|
||||
let prefix_base = prefix.trim_end_matches('_');
|
||||
format!("_p('{}', {})", prefix_base, acc_var)
|
||||
}
|
||||
}
|
||||
|
||||
fn constructor(&self, type_name: &str, path_expr: &str) -> String {
|
||||
format!("{}(client, {})", type_name, path_expr)
|
||||
}
|
||||
|
||||
fn field_init(&self, indent: &str, name: &str, type_ann: &str, value: &str) -> String {
|
||||
format!("{}self.{}: {} = {}", indent, name, type_ann, value)
|
||||
}
|
||||
|
||||
fn generic_syntax(&self) -> GenericSyntax {
|
||||
GenericSyntax::PYTHON
|
||||
}
|
||||
|
||||
fn string_literal(&self, value: &str) -> String {
|
||||
format!("'{}'", value)
|
||||
}
|
||||
|
||||
fn constructor_name(&self, type_name: &str) -> String {
|
||||
type_name.to_string()
|
||||
}
|
||||
|
||||
fn disc_arg_expr(&self, template: &str) -> String {
|
||||
if template == "{disc}" {
|
||||
"disc".to_string()
|
||||
} else if template.is_empty() {
|
||||
"''".to_string()
|
||||
} else if !template.contains("{disc}") {
|
||||
format!("'{}'", template)
|
||||
} else if template.ends_with("{disc}") {
|
||||
let static_part = template.trim_end_matches("{disc}").trim_end_matches('_');
|
||||
format!("_m('{}', disc)", static_part)
|
||||
} else {
|
||||
format!("f'{}'", template)
|
||||
}
|
||||
}
|
||||
|
||||
fn template_expr(&self, acc_var: &str, template: &str) -> String {
|
||||
if template == "{disc}" {
|
||||
format!("_m({}, disc)", acc_var)
|
||||
} else if !template.contains("{disc}") {
|
||||
format!("_m({}, '{}')", acc_var, template)
|
||||
} else {
|
||||
format!("_m({}, f'{}')", acc_var, template)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Rust language syntax implementation.
|
||||
|
||||
use crate::{GenericSyntax, LanguageSyntax, escape_rust_keyword, to_snake_case};
|
||||
|
||||
/// Rust-specific code generation syntax.
|
||||
pub struct RustSyntax;
|
||||
|
||||
/// Escape braces in a template string for use in `format!()`, preserving `{disc}`.
|
||||
fn escape_rust_format(template: &str) -> String {
|
||||
template
|
||||
.replace('{', "{{")
|
||||
.replace('}', "}}")
|
||||
.replace("{{disc}}", "{disc}")
|
||||
}
|
||||
|
||||
impl LanguageSyntax for RustSyntax {
|
||||
fn field_name(&self, name: &str) -> String {
|
||||
escape_rust_keyword(&to_snake_case(name))
|
||||
}
|
||||
|
||||
fn path_expr(&self, base_var: &str, suffix: &str) -> String {
|
||||
format!("format!(\"{{{}}}{}\")", base_var, suffix)
|
||||
}
|
||||
|
||||
fn suffix_expr(&self, acc_var: &str, relative: &str) -> String {
|
||||
if relative.is_empty() {
|
||||
self.owned_expr(acc_var)
|
||||
} else {
|
||||
format!("_m(&{}, \"{}\")", acc_var, relative)
|
||||
}
|
||||
}
|
||||
|
||||
fn owned_expr(&self, var: &str) -> String {
|
||||
format!("{}.clone()", var)
|
||||
}
|
||||
|
||||
fn prefix_expr(&self, prefix: &str, acc_var: &str) -> String {
|
||||
if prefix.is_empty() {
|
||||
self.owned_expr(acc_var)
|
||||
} else {
|
||||
let prefix_base = prefix.trim_end_matches('_');
|
||||
format!("_p(\"{}\", &{})", prefix_base, acc_var)
|
||||
}
|
||||
}
|
||||
|
||||
fn constructor(&self, type_name: &str, path_expr: &str) -> String {
|
||||
format!("{}::new(client.clone(), {})", type_name, path_expr)
|
||||
}
|
||||
|
||||
fn field_init(&self, indent: &str, name: &str, _type_ann: &str, value: &str) -> String {
|
||||
// Rust struct initialization; type is in struct definition, not in init
|
||||
format!("{}{}: {},", indent, name, value)
|
||||
}
|
||||
|
||||
fn generic_syntax(&self) -> GenericSyntax {
|
||||
GenericSyntax::RUST
|
||||
}
|
||||
|
||||
fn string_literal(&self, value: &str) -> String {
|
||||
format!("\"{}\".to_string()", value)
|
||||
}
|
||||
|
||||
fn constructor_name(&self, type_name: &str) -> String {
|
||||
format!("{}::new", type_name)
|
||||
}
|
||||
|
||||
fn disc_arg_expr(&self, template: &str) -> String {
|
||||
if template == "{disc}" {
|
||||
"disc.clone()".to_string()
|
||||
} else if template.is_empty() {
|
||||
"String::new()".to_string()
|
||||
} else if !template.contains("{disc}") {
|
||||
format!("\"{}\".to_string()", template)
|
||||
} else if template.ends_with("{disc}") {
|
||||
let static_part = template.trim_end_matches("{disc}").trim_end_matches('_');
|
||||
format!("_m(\"{}\", &disc)", static_part)
|
||||
} else {
|
||||
format!("format!(\"{}\")", escape_rust_format(template))
|
||||
}
|
||||
}
|
||||
|
||||
fn template_expr(&self, acc_var: &str, template: &str) -> String {
|
||||
if template == "{disc}" {
|
||||
format!("_m(&{}, &disc)", acc_var)
|
||||
} else if template.is_empty() {
|
||||
acc_var.to_string()
|
||||
} else if !template.contains("{disc}") {
|
||||
format!("_m(&{}, \"{}\")", acc_var, template)
|
||||
} else {
|
||||
format!(
|
||||
"_m(&{}, &format!(\"{}\", disc=disc))",
|
||||
acc_var,
|
||||
escape_rust_format(template)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Shared constant generation for static client data.
|
||||
//!
|
||||
//! Extracts common logic for generating INDEXES, POOL_ID_TO_POOL_NAME,
|
||||
//! and cohort name constants across JavaScript and Python clients.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use brk_cohort::{
|
||||
AGE_RANGE_NAMES, AMOUNT_RANGE_NAMES, CLASS_NAMES, ENTRY_NAMES, EPOCH_NAMES, LOSS_NAMES,
|
||||
OVER_AGE_NAMES, OVER_AMOUNT_NAMES, PROFIT_NAMES, PROFITABILITY_RANGE_NAMES,
|
||||
SPENDABLE_TYPE_NAMES, TERM_NAMES, UNDER_AGE_NAMES, UNDER_AMOUNT_NAMES,
|
||||
};
|
||||
use brk_types::{Index, pools};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{VERSION, to_camel_case};
|
||||
|
||||
/// Collected constant data for client generation.
|
||||
pub struct ClientConstants {
|
||||
pub version: String,
|
||||
pub indexes: Vec<&'static str>,
|
||||
pub pool_map: BTreeMap<String, &'static str>,
|
||||
}
|
||||
|
||||
impl ClientConstants {
|
||||
/// Collect all constant data.
|
||||
pub fn collect() -> Self {
|
||||
let indexes = Index::all();
|
||||
let indexes: Vec<&'static str> = indexes.iter().map(|i| i.name()).collect();
|
||||
|
||||
let pools = pools();
|
||||
let mut sorted_pools: Vec<_> = pools.iter().collect();
|
||||
sorted_pools.sort_by_key(|p| p.name.to_lowercase());
|
||||
let pool_map: BTreeMap<String, &'static str> = sorted_pools
|
||||
.iter()
|
||||
.map(|p| (p.slug().to_string(), p.name))
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
version: format!("v{}", VERSION),
|
||||
indexes,
|
||||
pool_map,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cohort name constants - shared data definitions.
|
||||
pub struct CohortConstants;
|
||||
|
||||
impl CohortConstants {
|
||||
/// Get all cohort constants as name-value pairs for iteration.
|
||||
pub fn all() -> Vec<(&'static str, Value)> {
|
||||
fn to_value<T: Serialize>(v: &T) -> Value {
|
||||
serde_json::to_value(v).unwrap()
|
||||
}
|
||||
|
||||
vec![
|
||||
("TERM_NAMES", to_value(&TERM_NAMES)),
|
||||
("EPOCH_NAMES", to_value(&EPOCH_NAMES)),
|
||||
("CLASS_NAMES", to_value(&CLASS_NAMES)),
|
||||
("ENTRY_NAMES", to_value(&ENTRY_NAMES)),
|
||||
("SPENDABLE_TYPE_NAMES", to_value(&SPENDABLE_TYPE_NAMES)),
|
||||
("AGE_RANGE_NAMES", to_value(&AGE_RANGE_NAMES)),
|
||||
("UNDER_AGE_NAMES", to_value(&UNDER_AGE_NAMES)),
|
||||
("OVER_AGE_NAMES", to_value(&OVER_AGE_NAMES)),
|
||||
("AMOUNT_RANGE_NAMES", to_value(&AMOUNT_RANGE_NAMES)),
|
||||
("OVER_AMOUNT_NAMES", to_value(&OVER_AMOUNT_NAMES)),
|
||||
("UNDER_AMOUNT_NAMES", to_value(&UNDER_AMOUNT_NAMES)),
|
||||
(
|
||||
"PROFITABILITY_RANGE_NAMES",
|
||||
to_value(&PROFITABILITY_RANGE_NAMES),
|
||||
),
|
||||
("PROFIT_NAMES", to_value(&PROFIT_NAMES)),
|
||||
("LOSS_NAMES", to_value(&LOSS_NAMES)),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert top-level keys of a JSON object to camelCase.
|
||||
pub fn camel_case_keys(value: Value) -> Value {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let new_map: serde_json::Map<String, Value> = map
|
||||
.into_iter()
|
||||
.map(|(k, v)| (to_camel_case(&k), v))
|
||||
.collect();
|
||||
Value::Object(new_map)
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a JSON value as a pretty-printed string.
|
||||
pub fn format_json<T: Serialize>(value: &T) -> String {
|
||||
serde_json::to_string_pretty(value).unwrap()
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Shared field generation logic.
|
||||
//!
|
||||
//! This module contains the core field generation logic that is shared
|
||||
//! across all language backends. The `LanguageSyntax` trait is used to
|
||||
//! abstract over language-specific formatting.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use brk_types::SeriesLeafWithSchema;
|
||||
|
||||
use crate::{
|
||||
ClientMetadata, LanguageSyntax, PatternBaseResult, PatternField, PatternMode, StructuralPattern,
|
||||
};
|
||||
|
||||
/// Create a path suffix from a name.
|
||||
fn path_suffix(name: &str) -> String {
|
||||
if name.starts_with('_') {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("_{}", name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the constructor value for a parameterized field (factory context).
|
||||
///
|
||||
/// Handles all three pattern modes (Suffix/Prefix/Templated) and the special
|
||||
/// case of templated child patterns that need (acc, disc) instead of a path.
|
||||
fn compute_parameterized_value<S: LanguageSyntax>(
|
||||
syntax: &S,
|
||||
field: &PatternField,
|
||||
pattern: &StructuralPattern,
|
||||
metadata: &ClientMetadata,
|
||||
) -> String {
|
||||
// Templated child patterns receive acc and disc as separate arguments
|
||||
if let Some(child_pattern) = metadata.find_pattern(&field.rust_type)
|
||||
&& child_pattern.is_templated()
|
||||
{
|
||||
let disc_template = pattern.get_field_part(&field.name).unwrap_or(&field.name);
|
||||
let disc_arg = syntax.disc_arg_expr(disc_template);
|
||||
let acc_arg = syntax.owned_expr("acc");
|
||||
return syntax.constructor(&field.rust_type, &format!("{acc_arg}, {disc_arg}"));
|
||||
}
|
||||
|
||||
// Compute path expression from pattern mode
|
||||
let path_expr = match pattern.get_field_part(&field.name) {
|
||||
Some(part) => match &pattern.mode {
|
||||
Some(PatternMode::Templated { .. }) => syntax.template_expr("acc", part),
|
||||
Some(PatternMode::Prefix { .. }) => syntax.prefix_expr(part, "acc"),
|
||||
_ => syntax.suffix_expr("acc", part),
|
||||
},
|
||||
None => syntax.path_expr("acc", &path_suffix(&field.name)),
|
||||
};
|
||||
|
||||
// Wrap in constructor — leaves use their index accessor, everything else uses the type name
|
||||
if let Some(accessor) = metadata.find_index_set_pattern(&field.indexes) {
|
||||
syntax.constructor(&accessor.name, &path_expr)
|
||||
} else if field.is_leaf() {
|
||||
panic!(
|
||||
"Field '{}' has no matching index accessor. All series must be indexed.",
|
||||
field.name
|
||||
)
|
||||
} else {
|
||||
syntax.constructor(&field.rust_type, &path_expr)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a parameterized field for a pattern factory.
|
||||
///
|
||||
/// Used for pattern instances where fields build series names from an accumulated base.
|
||||
pub fn generate_parameterized_field<S: LanguageSyntax>(
|
||||
output: &mut String,
|
||||
syntax: &S,
|
||||
field: &PatternField,
|
||||
pattern: &StructuralPattern,
|
||||
metadata: &ClientMetadata,
|
||||
indent: &str,
|
||||
) {
|
||||
let field_name = syntax.field_name(&field.name);
|
||||
let type_ann =
|
||||
metadata.field_type_annotation(field, pattern.is_generic, None, syntax.generic_syntax());
|
||||
let value = compute_parameterized_value(syntax, field, pattern, metadata);
|
||||
|
||||
writeln!(
|
||||
output,
|
||||
"{}",
|
||||
syntax.field_init(indent, &field_name, &type_ann, &value)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Generate a tree node field for a pattern-type child.
|
||||
///
|
||||
/// Called for non-inline branch children that match a parameterizable pattern.
|
||||
/// For templated patterns, extracts the discriminator from the base result.
|
||||
pub fn generate_tree_node_field<S: LanguageSyntax>(
|
||||
output: &mut String,
|
||||
syntax: &S,
|
||||
field: &PatternField,
|
||||
metadata: &ClientMetadata,
|
||||
indent: &str,
|
||||
client_expr: &str,
|
||||
base_result: &PatternBaseResult,
|
||||
) {
|
||||
let field_name = syntax.field_name(&field.name);
|
||||
let type_ann = metadata.field_type_annotation(field, false, None, syntax.generic_syntax());
|
||||
let base_arg = syntax.string_literal(&base_result.base);
|
||||
|
||||
let value = if let Some(pattern) = metadata.find_pattern(&field.rust_type)
|
||||
&& pattern.is_templated()
|
||||
{
|
||||
let disc = pattern
|
||||
.extract_disc_from_instance(&base_result.field_parts)
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"{}({}, {}, {})",
|
||||
syntax.constructor_name(&field.rust_type),
|
||||
client_expr,
|
||||
base_arg,
|
||||
syntax.string_literal(&disc)
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{}({}, {})",
|
||||
syntax.constructor_name(&field.rust_type),
|
||||
client_expr,
|
||||
base_arg
|
||||
)
|
||||
};
|
||||
|
||||
writeln!(
|
||||
output,
|
||||
"{}",
|
||||
syntax.field_init(indent, &field_name, &type_ann, &value)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Generate a leaf field using the actual series name from the TreeNode::Leaf.
|
||||
///
|
||||
/// This is the shared implementation for all language backends. It uses
|
||||
/// `leaf.name()` directly to get the correct series name, avoiding any
|
||||
/// path concatenation that could produce incorrect names.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `output` - The string buffer to write to
|
||||
/// * `syntax` - The language syntax implementation
|
||||
/// * `client_expr` - The client expression (e.g., "client.clone()", "this", "client")
|
||||
/// * `tree_field_name` - The field name from the tree structure
|
||||
/// * `leaf` - The Leaf node containing the actual series name and indexes
|
||||
/// * `metadata` - Client metadata for looking up index patterns
|
||||
/// * `indent` - Indentation string
|
||||
pub fn generate_leaf_field<S: LanguageSyntax>(
|
||||
output: &mut String,
|
||||
syntax: &S,
|
||||
client_expr: &str,
|
||||
tree_field_name: &str,
|
||||
leaf: &SeriesLeafWithSchema,
|
||||
metadata: &ClientMetadata,
|
||||
indent: &str,
|
||||
) {
|
||||
let field_name = syntax.field_name(tree_field_name);
|
||||
let accessor = metadata
|
||||
.find_index_set_pattern(leaf.indexes())
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Series '{}' has no matching index pattern. All series must be indexed.",
|
||||
leaf.name()
|
||||
)
|
||||
});
|
||||
|
||||
let type_ann = metadata.field_type_annotation_from_leaf(leaf, syntax.generic_syntax());
|
||||
let series_name = syntax.string_literal(leaf.name());
|
||||
let value = format!(
|
||||
"{}({}, {})",
|
||||
syntax.constructor_name(&accessor.name),
|
||||
client_expr,
|
||||
series_name
|
||||
);
|
||||
|
||||
writeln!(
|
||||
output,
|
||||
"{}",
|
||||
syntax.field_init(indent, &field_name, &type_ann, &value)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Shared code generation logic.
|
||||
//!
|
||||
//! This module contains generation functions that are parameterized by
|
||||
//! the `LanguageSyntax` trait, allowing them to work across all supported
|
||||
//! language backends.
|
||||
|
||||
mod constants;
|
||||
mod fields;
|
||||
mod tree;
|
||||
|
||||
pub use constants::*;
|
||||
pub use fields::*;
|
||||
pub use tree::*;
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Shared tree generation helpers.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use brk_types::TreeNode;
|
||||
|
||||
use crate::{
|
||||
ClientMetadata, PatternBaseResult, PatternField, child_type_name, get_fields_with_child_info,
|
||||
};
|
||||
|
||||
/// Build a child path by appending a child name to a parent path.
|
||||
/// Uses "/" as separator. If parent is empty, returns just the child name.
|
||||
#[inline]
|
||||
pub fn build_child_path(parent: &str, child: &str) -> String {
|
||||
if parent.is_empty() {
|
||||
child.to_string()
|
||||
} else {
|
||||
format!("{}/{}", parent, child)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-computed context for a single child node.
|
||||
pub struct ChildContext<'a> {
|
||||
/// The child's field name in the tree.
|
||||
pub name: &'a str,
|
||||
/// The child node.
|
||||
pub node: &'a TreeNode,
|
||||
/// The field info for this child (with type_param set for generic patterns).
|
||||
pub field: PatternField,
|
||||
/// Pattern analysis result.
|
||||
pub base_result: PatternBaseResult,
|
||||
/// Whether this is a leaf node.
|
||||
pub is_leaf: bool,
|
||||
/// Whether to use an inline type instead of a pattern type (only meaningful for branches).
|
||||
pub should_inline: bool,
|
||||
/// The type name to use for inline branches.
|
||||
pub inline_type_name: String,
|
||||
}
|
||||
|
||||
/// Context for generating a tree node, returned by `prepare_tree_node`.
|
||||
pub struct TreeNodeContext<'a> {
|
||||
/// Pre-computed context for each child.
|
||||
pub children: Vec<ChildContext<'a>>,
|
||||
}
|
||||
|
||||
/// Prepare a tree node for generation.
|
||||
/// Returns None if the node should be skipped (not a branch, already generated,
|
||||
/// or matches a parameterizable pattern).
|
||||
///
|
||||
/// The `path` parameter is the tree path to this node (e.g., "distribution/utxoCohorts").
|
||||
/// It's used to look up pre-computed PatternBaseResult from the analysis phase.
|
||||
pub fn prepare_tree_node<'a>(
|
||||
node: &'a TreeNode,
|
||||
name: &str,
|
||||
path: &str,
|
||||
pattern_lookup: &BTreeMap<Vec<PatternField>, String>,
|
||||
metadata: &ClientMetadata,
|
||||
generated: &mut BTreeSet<String>,
|
||||
) -> Option<TreeNodeContext<'a>> {
|
||||
let TreeNode::Branch(branch_children) = node else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let fields_with_child_info = get_fields_with_child_info(branch_children, name, pattern_lookup);
|
||||
let fields: Vec<PatternField> = fields_with_child_info
|
||||
.iter()
|
||||
.map(|(f, _)| f.clone())
|
||||
.collect();
|
||||
|
||||
// Look up the pre-computed base result, or use a default that forces inlining
|
||||
let base_result = metadata
|
||||
.get_node_base(path)
|
||||
.cloned()
|
||||
.unwrap_or_else(PatternBaseResult::force_inline);
|
||||
|
||||
// Skip if this matches a parameterizable pattern AND has no outlier AND field parts match
|
||||
let pattern_compatible = pattern_lookup
|
||||
.get(&fields)
|
||||
.and_then(|name| metadata.find_pattern(name))
|
||||
.is_none_or(|p| {
|
||||
p.is_suffix_mode() == base_result.is_suffix_mode
|
||||
&& p.field_parts_match(&base_result.field_parts)
|
||||
});
|
||||
if let Some(pattern_name) = pattern_lookup.get(&fields)
|
||||
&& pattern_name != name
|
||||
&& metadata.is_parameterizable(pattern_name)
|
||||
&& !base_result.has_outlier
|
||||
&& pattern_compatible
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// Skip if already generated
|
||||
if generated.contains(name) {
|
||||
return None;
|
||||
}
|
||||
generated.insert(name.to_string());
|
||||
|
||||
// Build child contexts with pre-computed decisions
|
||||
let children: Vec<ChildContext<'a>> = branch_children
|
||||
.iter()
|
||||
.zip(fields_with_child_info)
|
||||
.map(|((child_name, child_node), (mut field, child_fields))| {
|
||||
let is_leaf = matches!(child_node, TreeNode::Leaf(_));
|
||||
|
||||
// Set type_param for generic patterns so field_type_annotation works directly
|
||||
if let Some(cf) = &child_fields {
|
||||
field.type_param = metadata.get_type_param(cf).cloned();
|
||||
}
|
||||
|
||||
// Build child path and look up its pre-computed base result
|
||||
let child_path = build_child_path(path, child_name);
|
||||
let base_result = metadata
|
||||
.get_node_base(&child_path)
|
||||
.cloned()
|
||||
.unwrap_or_else(PatternBaseResult::force_inline);
|
||||
|
||||
// Single lookup for the child's matching pattern (avoids repeated scans)
|
||||
let matching_pattern = child_fields
|
||||
.as_ref()
|
||||
.and_then(|cf| metadata.find_pattern_by_fields(cf));
|
||||
|
||||
let matches_any_pattern = matching_pattern.is_some();
|
||||
let pattern_compatible = matching_pattern.is_none_or(|p| {
|
||||
p.is_suffix_mode() == base_result.is_suffix_mode
|
||||
&& p.field_parts_match(&base_result.field_parts)
|
||||
});
|
||||
let is_parameterizable =
|
||||
matching_pattern.is_none_or(|p| metadata.is_parameterizable(&p.name));
|
||||
|
||||
// should_inline determines if we generate an inline struct type
|
||||
let should_inline = !is_leaf
|
||||
&& (!matches_any_pattern
|
||||
|| !pattern_compatible
|
||||
|| !is_parameterizable
|
||||
|| base_result.has_outlier);
|
||||
|
||||
let inline_type_name = if should_inline {
|
||||
child_type_name(name, child_name)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
ChildContext {
|
||||
name: child_name,
|
||||
node: child_node,
|
||||
field,
|
||||
base_result,
|
||||
is_leaf,
|
||||
should_inline,
|
||||
inline_type_name,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(TreeNodeContext { children })
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
//! JavaScript API method generation.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::{
|
||||
Endpoint, Parameter,
|
||||
generators::{javascript::types::jsdoc_normalize, normalize_return_type, write_description},
|
||||
to_camel_case,
|
||||
};
|
||||
|
||||
/// Generate API methods for the BrkClient class.
|
||||
pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
|
||||
for endpoint in endpoints {
|
||||
if !endpoint.should_generate() {
|
||||
continue;
|
||||
}
|
||||
match endpoint.method.as_str() {
|
||||
"GET" => generate_get_method(output, endpoint),
|
||||
"POST" => generate_post_method(output, endpoint),
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_get_method(output: &mut String, endpoint: &Endpoint) {
|
||||
let method_name = endpoint_to_method_name(endpoint);
|
||||
let return_type = build_return_type(endpoint);
|
||||
|
||||
write_method_doc(output, endpoint);
|
||||
for param in &endpoint.path_params {
|
||||
let desc = format_param_desc(param.description.as_deref());
|
||||
let ty = jsdoc_normalize(¶m.param_type);
|
||||
writeln!(output, " * @param {{{}}} {}{}", ty, param.name, desc).unwrap();
|
||||
}
|
||||
for param in &endpoint.query_params {
|
||||
let optional = if param.required { "" } else { "=" };
|
||||
let desc = format_param_desc(param.description.as_deref());
|
||||
let ty = jsdoc_normalize(¶m.param_type);
|
||||
let ident = sanitize_ident(¶m.name);
|
||||
let name_decl = if param.required {
|
||||
ident
|
||||
} else {
|
||||
format!("[{}]", ident)
|
||||
};
|
||||
writeln!(
|
||||
output,
|
||||
" * @param {{{}{}}} {}{}",
|
||||
ty, optional, name_decl, desc
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(
|
||||
output,
|
||||
" * @param {{{{ signal?: AbortSignal, onValue?: (value: {}) => void, cache?: boolean }}}} [options]",
|
||||
return_type
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " * @returns {{Promise<{}>}}", return_type).unwrap();
|
||||
writeln!(output, " */").unwrap();
|
||||
|
||||
let params = build_method_params(endpoint);
|
||||
let params_with_opts = if params.is_empty() {
|
||||
"{ signal, onValue, cache } = {}".to_string()
|
||||
} else {
|
||||
format!("{}, {{ signal, onValue, cache }} = {{}}", params)
|
||||
};
|
||||
writeln!(output, " async {}({}) {{", method_name, params_with_opts).unwrap();
|
||||
|
||||
let path = build_path_template(&endpoint.path, &endpoint.path_params);
|
||||
|
||||
let fetch_call: String = if endpoint.returns_binary() {
|
||||
"this.getBytes(path, { signal, onValue, cache })".to_string()
|
||||
} else if endpoint.returns_json() {
|
||||
"this.getJson(path, { signal, onValue, cache })".to_string()
|
||||
} else if endpoint.response_kind.text_is_numeric() {
|
||||
"Number(await this.getText(path, { signal, cache, onValue: onValue ? (v) => onValue(Number(v)) : undefined }))".to_string()
|
||||
} else {
|
||||
"this.getText(path, { signal, onValue, cache })".to_string()
|
||||
};
|
||||
|
||||
write_path_assignment(output, endpoint, &path);
|
||||
|
||||
if endpoint.supports_csv {
|
||||
writeln!(
|
||||
output,
|
||||
" if (format === 'csv') return this.getText(path, {{ signal, onValue, cache }});"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(output, " return {};", fetch_call).unwrap();
|
||||
writeln!(output, " }}\n").unwrap();
|
||||
}
|
||||
|
||||
fn generate_post_method(output: &mut String, endpoint: &Endpoint) {
|
||||
let method_name = endpoint_to_method_name(endpoint);
|
||||
let return_type = build_return_type(endpoint);
|
||||
|
||||
write_method_doc(output, endpoint);
|
||||
for param in &endpoint.path_params {
|
||||
let desc = format_param_desc(param.description.as_deref());
|
||||
let ty = jsdoc_normalize(¶m.param_type);
|
||||
writeln!(output, " * @param {{{}}} {}{}", ty, param.name, desc).unwrap();
|
||||
}
|
||||
for param in &endpoint.query_params {
|
||||
let optional = if param.required { "" } else { "=" };
|
||||
let desc = format_param_desc(param.description.as_deref());
|
||||
let ty = jsdoc_normalize(¶m.param_type);
|
||||
writeln!(
|
||||
output,
|
||||
" * @param {{{}{}}} [{}]{}",
|
||||
ty, optional, param.name, desc
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
if let Some(body) = &endpoint.request_body {
|
||||
let optional = if body.required { "" } else { "=" };
|
||||
let ty = jsdoc_normalize(&body.body_type);
|
||||
writeln!(
|
||||
output,
|
||||
" * @param {{{}{}}} body - Request body",
|
||||
ty, optional
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(
|
||||
output,
|
||||
" * @param {{{{ signal?: AbortSignal }}}} [options]"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " * @returns {{Promise<{}>}}", return_type).unwrap();
|
||||
writeln!(output, " */").unwrap();
|
||||
|
||||
let mut params = build_method_params(endpoint);
|
||||
if endpoint.request_body.is_some() {
|
||||
if !params.is_empty() {
|
||||
params.push_str(", ");
|
||||
}
|
||||
params.push_str("body");
|
||||
}
|
||||
let params_with_opts = if params.is_empty() {
|
||||
"{ signal } = {}".to_string()
|
||||
} else {
|
||||
format!("{}, {{ signal }} = {{}}", params)
|
||||
};
|
||||
writeln!(output, " async {}({}) {{", method_name, params_with_opts).unwrap();
|
||||
|
||||
let path = build_path_template(&endpoint.path, &endpoint.path_params);
|
||||
let body_arg = if endpoint.request_body.is_some() {
|
||||
"body"
|
||||
} else {
|
||||
"''"
|
||||
};
|
||||
|
||||
let fetch_call: String = if endpoint.returns_binary() {
|
||||
format!("this.postBytes(path, {}, {{ signal }})", body_arg)
|
||||
} else if endpoint.returns_json() {
|
||||
format!("this.postJson(path, {}, {{ signal }})", body_arg)
|
||||
} else if endpoint.response_kind.text_is_numeric() {
|
||||
format!(
|
||||
"Number(await this.postText(path, {}, {{ signal }}))",
|
||||
body_arg
|
||||
)
|
||||
} else {
|
||||
format!("this.postText(path, {}, {{ signal }})", body_arg)
|
||||
};
|
||||
|
||||
write_path_assignment(output, endpoint, &path);
|
||||
|
||||
writeln!(output, " return {};", fetch_call).unwrap();
|
||||
writeln!(output, " }}\n").unwrap();
|
||||
}
|
||||
|
||||
fn build_return_type(endpoint: &Endpoint) -> String {
|
||||
let base = if endpoint.returns_binary() {
|
||||
"Uint8Array".to_string()
|
||||
} else {
|
||||
jsdoc_normalize(&normalize_return_type(
|
||||
endpoint.schema_name().unwrap_or("*"),
|
||||
))
|
||||
};
|
||||
if endpoint.supports_csv {
|
||||
format!("{} | string", base)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
fn write_method_doc(output: &mut String, endpoint: &Endpoint) {
|
||||
writeln!(output, " /**").unwrap();
|
||||
if let Some(summary) = &endpoint.summary {
|
||||
writeln!(output, " * {}", summary).unwrap();
|
||||
}
|
||||
if let Some(desc) = &endpoint.description
|
||||
&& endpoint.summary.as_ref() != Some(desc)
|
||||
{
|
||||
writeln!(output, " *").unwrap();
|
||||
write_description(output, desc, " * ", " *");
|
||||
}
|
||||
writeln!(output, " *").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" * Endpoint: `{} {}`",
|
||||
endpoint.method.to_uppercase(),
|
||||
endpoint.path
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let has_body_param = endpoint.method == "POST" && endpoint.request_body.is_some();
|
||||
if !endpoint.path_params.is_empty() || !endpoint.query_params.is_empty() || has_body_param {
|
||||
writeln!(output, " *").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn write_path_assignment(output: &mut String, endpoint: &Endpoint, path: &str) {
|
||||
if endpoint.query_params.is_empty() {
|
||||
writeln!(output, " const path = `{}`;", path).unwrap();
|
||||
} else {
|
||||
writeln!(output, " const params = new URLSearchParams();").unwrap();
|
||||
for param in &endpoint.query_params {
|
||||
let ident = sanitize_ident(¶m.name);
|
||||
let is_array = param.param_type.ends_with("[]");
|
||||
if is_array {
|
||||
if param.required {
|
||||
writeln!(
|
||||
output,
|
||||
" for (const _v of {}) params.append('{}', String(_v));",
|
||||
ident, param.name
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" if ({}) for (const _v of {}) params.append('{}', String(_v));",
|
||||
ident, ident, param.name
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
} else if param.required {
|
||||
writeln!(
|
||||
output,
|
||||
" params.set('{}', String({}));",
|
||||
param.name, ident
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" if ({} !== undefined) params.set('{}', String({}));",
|
||||
ident, param.name, ident
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
writeln!(output, " const query = params.toString();").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" const path = `{}${{query ? '?' + query : ''}}`;",
|
||||
path
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint_to_method_name(endpoint: &Endpoint) -> String {
|
||||
to_camel_case(&endpoint.operation_name())
|
||||
}
|
||||
|
||||
fn build_method_params(endpoint: &Endpoint) -> String {
|
||||
let mut params = Vec::new();
|
||||
for param in &endpoint.path_params {
|
||||
params.push(sanitize_ident(¶m.name));
|
||||
}
|
||||
for param in &endpoint.query_params {
|
||||
params.push(sanitize_ident(¶m.name));
|
||||
}
|
||||
params.join(", ")
|
||||
}
|
||||
|
||||
/// Strip characters invalid in JS identifiers (e.g. `[]` from `txId[]`).
|
||||
fn sanitize_ident(name: &str) -> String {
|
||||
name.replace(['[', ']'], "")
|
||||
}
|
||||
|
||||
fn build_path_template(path: &str, path_params: &[Parameter]) -> String {
|
||||
let mut result = path.to_string();
|
||||
for param in path_params {
|
||||
let placeholder = format!("{{{}}}", param.name);
|
||||
let interpolation = format!("${{{}}}", param.name);
|
||||
result = result.replace(&placeholder, &interpolation);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Format param description with dash prefix, or empty string if no description.
|
||||
fn format_param_desc(desc: Option<&str>) -> String {
|
||||
match desc {
|
||||
Some(d) if !d.is_empty() => format!(" - {}", d),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,938 @@
|
||||
//! JavaScript base client and pattern factory generation.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::{
|
||||
ClientConstants, ClientMetadata, CohortConstants, GenericSyntax, IndexSetPattern,
|
||||
JavaScriptSyntax, StructuralPattern, camel_case_keys, format_json,
|
||||
generate_parameterized_field, to_camel_case,
|
||||
};
|
||||
|
||||
/// Generate the base BrkClient class with HTTP functionality.
|
||||
pub fn generate_base_client(output: &mut String) {
|
||||
writeln!(
|
||||
output,
|
||||
r#"/**
|
||||
* @typedef {{Object}} BrkClientOptions
|
||||
* @property {{string}} baseUrl - Base URL for the API
|
||||
* @property {{number}} [timeout] - Request timeout in milliseconds
|
||||
* @property {{string|boolean}} [browserCache] - Enable browser Cache API with default name (true), custom name (string), or disable (false). No effect in Node.js. Default: true
|
||||
* @property {{number|boolean}} [memCache] - In-memory parsed-response cache size (LRU). true/undefined → 1000, false/0 → disabled. Lets 304 responses skip the JSON parse entirely. Default: 1000
|
||||
*/
|
||||
|
||||
const _isBrowser = typeof window !== 'undefined' && 'caches' in window;
|
||||
const _runIdle = (/** @type {{VoidFunction}} */ fn) => (globalThis.requestIdleCallback ?? setTimeout)(fn);
|
||||
const _defaultBrowserCacheName = '__BRK_CLIENT__';
|
||||
const _DEFAULT_MEM_CACHE_SIZE = 1000;
|
||||
|
||||
/** @template T @typedef {{{{ etag: string | null, value: T }}}} _MemEntry */
|
||||
/** @param {{*}} v */
|
||||
const _addCamelGetters = (v) => {{
|
||||
if (Array.isArray(v)) {{ v.forEach(_addCamelGetters); return v; }}
|
||||
if (v && typeof v === 'object' && v.constructor === Object) {{
|
||||
for (const k in v) {{
|
||||
if (k.includes('_')) {{
|
||||
const c = k.replace(/_([a-z])/g, (_, l) => l.toUpperCase());
|
||||
if (!(c in v)) Object.defineProperty(v, c, {{ get() {{ return this[k]; }} }});
|
||||
}}
|
||||
_addCamelGetters(v[k]);
|
||||
}}
|
||||
}}
|
||||
return v;
|
||||
}};
|
||||
|
||||
/**
|
||||
* @param {{string|boolean|undefined}} option
|
||||
* @returns {{Promise<Cache | null>}}
|
||||
*/
|
||||
const _openBrowserCache = (option) => {{
|
||||
if (!_isBrowser || option === false) return Promise.resolve(null);
|
||||
const name = typeof option === 'string' ? option : _defaultBrowserCacheName;
|
||||
return caches.open(name).catch(() => null);
|
||||
}};
|
||||
|
||||
/**
|
||||
* @param {{string}} url
|
||||
* @returns {{URL}}
|
||||
*/
|
||||
const _parseBaseUrl = (url) => new URL(url, typeof location === 'undefined' ? undefined : location.href);
|
||||
|
||||
/**
|
||||
* Custom error class for BRK client errors
|
||||
*/
|
||||
class BrkError extends Error {{
|
||||
/**
|
||||
* @param {{string}} message
|
||||
* @param {{number}} [status]
|
||||
*/
|
||||
constructor(message, status) {{
|
||||
super(message);
|
||||
this.name = 'BrkError';
|
||||
this.status = status;
|
||||
}}
|
||||
}}
|
||||
|
||||
// Date conversion constants and helpers
|
||||
const _GENESIS = new Date(2009, 0, 3); // day1 0, week1 0
|
||||
const _DAY_ONE = new Date(2009, 0, 9); // day1 1 (6 day gap after genesis)
|
||||
const _MS_PER_DAY = 86400000;
|
||||
const _MS_PER_WEEK = 7 * _MS_PER_DAY;
|
||||
const _EPOCH_MS = 1230768000000;
|
||||
const _DATE_INDEXES = new Set([
|
||||
'minute10', 'minute30',
|
||||
'hour1', 'hour4', 'hour12',
|
||||
'day1', 'day3', 'week1',
|
||||
'month1', 'month3', 'month6',
|
||||
'year1', 'year10',
|
||||
]);
|
||||
|
||||
/** @param {{number}} months @returns {{globalThis.Date}} */
|
||||
const _addMonths = (months) => new Date(2009, months, 1);
|
||||
|
||||
/**
|
||||
* Convert an index value to a Date for date-based indexes.
|
||||
* @param {{Index}} index - The index type
|
||||
* @param {{number}} i - The index value
|
||||
* @returns {{globalThis.Date}}
|
||||
*/
|
||||
function indexToDate(index, i) {{
|
||||
switch (index) {{
|
||||
case 'minute10': return new Date(_EPOCH_MS + i * 600000);
|
||||
case 'minute30': return new Date(_EPOCH_MS + i * 1800000);
|
||||
case 'hour1': return new Date(_EPOCH_MS + i * 3600000);
|
||||
case 'hour4': return new Date(_EPOCH_MS + i * 14400000);
|
||||
case 'hour12': return new Date(_EPOCH_MS + i * 43200000);
|
||||
case 'day1': return i === 0 ? _GENESIS : new Date(_DAY_ONE.getTime() + (i - 1) * _MS_PER_DAY);
|
||||
case 'day3': return new Date(_EPOCH_MS - 86400000 + i * 259200000);
|
||||
case 'week1': return new Date(_GENESIS.getTime() + i * _MS_PER_WEEK);
|
||||
case 'month1': return _addMonths(i);
|
||||
case 'month3': return _addMonths(i * 3);
|
||||
case 'month6': return _addMonths(i * 6);
|
||||
case 'year1': return new Date(2009 + i, 0, 1);
|
||||
case 'year10': return new Date(2009 + i * 10, 0, 1);
|
||||
default: throw new Error(`${{index}} is not a date-based index`);
|
||||
}}
|
||||
}}
|
||||
|
||||
/**
|
||||
* Convert a Date to an index value for date-based indexes.
|
||||
* Returns the floor index (latest index whose date is <= the given date).
|
||||
* @param {{Index}} index - The index type
|
||||
* @param {{globalThis.Date}} d - The date to convert
|
||||
* @returns {{number}}
|
||||
*/
|
||||
function dateToIndex(index, d) {{
|
||||
const ms = d.getTime();
|
||||
switch (index) {{
|
||||
case 'minute10': return Math.floor((ms - _EPOCH_MS) / 600000);
|
||||
case 'minute30': return Math.floor((ms - _EPOCH_MS) / 1800000);
|
||||
case 'hour1': return Math.floor((ms - _EPOCH_MS) / 3600000);
|
||||
case 'hour4': return Math.floor((ms - _EPOCH_MS) / 14400000);
|
||||
case 'hour12': return Math.floor((ms - _EPOCH_MS) / 43200000);
|
||||
case 'day1': {{
|
||||
if (ms < _DAY_ONE.getTime()) return 0;
|
||||
return 1 + Math.floor((ms - _DAY_ONE.getTime()) / _MS_PER_DAY);
|
||||
}}
|
||||
case 'day3': return Math.floor((ms - _EPOCH_MS + 86400000) / 259200000);
|
||||
case 'week1': return Math.floor((ms - _GENESIS.getTime()) / _MS_PER_WEEK);
|
||||
case 'month1': return (d.getFullYear() - 2009) * 12 + d.getMonth();
|
||||
case 'month3': return (d.getFullYear() - 2009) * 4 + Math.floor(d.getMonth() / 3);
|
||||
case 'month6': return (d.getFullYear() - 2009) * 2 + Math.floor(d.getMonth() / 6);
|
||||
case 'year1': return d.getFullYear() - 2009;
|
||||
case 'year10': return Math.floor((d.getFullYear() - 2009) / 10);
|
||||
default: throw new Error(`${{index}} is not a date-based index`);
|
||||
}}
|
||||
}}
|
||||
|
||||
/**
|
||||
* Wrap raw series data with helper methods.
|
||||
* @template T
|
||||
* @param {{SeriesData<T>}} raw - Raw JSON response
|
||||
* @returns {{DateSeriesData<T>}}
|
||||
*/
|
||||
function _wrapSeriesData(raw) {{
|
||||
const {{ index, start, end, data }} = raw;
|
||||
const _dateBased = _DATE_INDEXES.has(index);
|
||||
return /** @type {{DateSeriesData<T>}} */ ({{
|
||||
...raw,
|
||||
isDateBased: _dateBased,
|
||||
indexes() {{
|
||||
/** @type {{number[]}} */
|
||||
const result = [];
|
||||
for (let i = start; i < end; i++) result.push(i);
|
||||
return result;
|
||||
}},
|
||||
keys() {{
|
||||
return this.indexes();
|
||||
}},
|
||||
entries() {{
|
||||
/** @type {{Array<[number, T]>}} */
|
||||
const result = [];
|
||||
for (let i = 0; i < data.length; i++) result.push([start + i, data[i]]);
|
||||
return result;
|
||||
}},
|
||||
toMap() {{
|
||||
/** @type {{Map<number, T>}} */
|
||||
const map = new Map();
|
||||
for (let i = 0; i < data.length; i++) map.set(start + i, data[i]);
|
||||
return map;
|
||||
}},
|
||||
*[Symbol.iterator]() {{
|
||||
for (let i = 0; i < data.length; i++) yield /** @type {{[number, T]}} */ ([start + i, data[i]]);
|
||||
}},
|
||||
// DateSeriesData methods (only meaningful for date-based indexes)
|
||||
dates() {{
|
||||
/** @type {{globalThis.Date[]}} */
|
||||
const result = [];
|
||||
for (let i = start; i < end; i++) result.push(indexToDate(index, i));
|
||||
return result;
|
||||
}},
|
||||
dateEntries() {{
|
||||
/** @type {{Array<[globalThis.Date, T]>}} */
|
||||
const result = [];
|
||||
for (let i = 0; i < data.length; i++) result.push([indexToDate(index, start + i), data[i]]);
|
||||
return result;
|
||||
}},
|
||||
toDateMap() {{
|
||||
/** @type {{Map<globalThis.Date, T>}} */
|
||||
const map = new Map();
|
||||
for (let i = 0; i < data.length; i++) map.set(indexToDate(index, start + i), data[i]);
|
||||
return map;
|
||||
}},
|
||||
}});
|
||||
}}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {{Object}} SeriesDataBase
|
||||
* @property {{number}} version - Version of the series data
|
||||
* @property {{Index}} index - The index type used for this query
|
||||
* @property {{string}} type - Value type (e.g. "f32", "u64", "Sats")
|
||||
* @property {{number}} start - Start index (inclusive)
|
||||
* @property {{number}} end - End index (exclusive)
|
||||
* @property {{string}} stamp - ISO 8601 timestamp of when the response was generated
|
||||
* @property {{T[]}} data - The series data
|
||||
* @property {{boolean}} isDateBased - Whether this series uses a date-based index
|
||||
* @property {{() => number[]}} indexes - Get index numbers
|
||||
* @property {{() => number[]}} keys - Get keys as index numbers (alias for indexes)
|
||||
* @property {{() => Array<[number, T]>}} entries - Get [index, value] pairs
|
||||
* @property {{() => Map<number, T>}} toMap - Convert to Map<index, value>
|
||||
*/
|
||||
|
||||
/** @template T @typedef {{SeriesDataBase<T> & Iterable<[number, T]>}} SeriesData */
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {{Object}} DateSeriesDataExtras
|
||||
* @property {{() => globalThis.Date[]}} dates - Get dates for each data point
|
||||
* @property {{() => Array<[globalThis.Date, T]>}} dateEntries - Get [date, value] pairs
|
||||
* @property {{() => Map<globalThis.Date, T>}} toDateMap - Convert to Map<date, value>
|
||||
*/
|
||||
|
||||
/** @template T @typedef {{SeriesData<T> & DateSeriesDataExtras<T>}} DateSeriesData */
|
||||
/** @typedef {{SeriesData<any>}} AnySeriesData */
|
||||
|
||||
/** @template T @typedef {{(onfulfilled?: (value: SeriesData<T>) => any, onrejected?: (reason: Error) => never) => Promise<SeriesData<T>>}} Thenable */
|
||||
/** @template T @typedef {{(onfulfilled?: (value: DateSeriesData<T>) => any, onrejected?: (reason: Error) => never) => Promise<DateSeriesData<T>>}} DateThenable */
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {{Object}} SeriesEndpoint
|
||||
* @property {{(index: number) => SingleItemBuilder<T>}} get - Get single item at index
|
||||
* @property {{(start?: number, end?: number) => RangeBuilder<T>}} slice - Slice by index
|
||||
* @property {{(n: number) => RangeBuilder<T>}} first - Get first n items
|
||||
* @property {{(n: number) => RangeBuilder<T>}} last - Get last n items
|
||||
* @property {{(n: number) => SkippedBuilder<T>}} skip - Skip first n items, chain with take()
|
||||
* @property {{(onValue?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch all data
|
||||
* @property {{() => Promise<string>}} fetchCsv - Fetch all data as CSV
|
||||
* @property {{() => Promise<number>}} len - Get total number of data points
|
||||
* @property {{() => Promise<Version>}} version - Get the current version of the series
|
||||
* @property {{Thenable<T>}} then - Thenable (await endpoint)
|
||||
* @property {{string}} path - The endpoint path
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {{Object}} DateSeriesEndpoint
|
||||
* @property {{(index: number | globalThis.Date) => DateSingleItemBuilder<T>}} get - Get single item at index or Date
|
||||
* @property {{(start?: number | globalThis.Date, end?: number | globalThis.Date) => DateRangeBuilder<T>}} slice - Slice by index or Date
|
||||
* @property {{(n: number) => DateRangeBuilder<T>}} first - Get first n items
|
||||
* @property {{(n: number) => DateRangeBuilder<T>}} last - Get last n items
|
||||
* @property {{(n: number) => DateSkippedBuilder<T>}} skip - Skip first n items, chain with take()
|
||||
* @property {{(onValue?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch all data
|
||||
* @property {{() => Promise<string>}} fetchCsv - Fetch all data as CSV
|
||||
* @property {{() => Promise<number>}} len - Get total number of data points
|
||||
* @property {{() => Promise<Version>}} version - Get the current version of the series
|
||||
* @property {{DateThenable<T>}} then - Thenable (await endpoint)
|
||||
* @property {{string}} path - The endpoint path
|
||||
*/
|
||||
|
||||
/** @typedef {{SeriesEndpoint<any>}} AnySeriesEndpoint */
|
||||
|
||||
/** @template T @typedef {{Object}} SingleItemBuilder
|
||||
* @property {{(onValue?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch the item
|
||||
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
|
||||
* @property {{Thenable<T>}} then - Thenable
|
||||
*/
|
||||
|
||||
/** @template T @typedef {{Object}} DateSingleItemBuilder
|
||||
* @property {{(onValue?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch the item
|
||||
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
|
||||
* @property {{DateThenable<T>}} then - Thenable
|
||||
*/
|
||||
|
||||
/** @template T @typedef {{Object}} SkippedBuilder
|
||||
* @property {{(n: number) => RangeBuilder<T>}} take - Take n items after skipped position
|
||||
* @property {{(onValue?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch from skipped position to end
|
||||
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
|
||||
* @property {{Thenable<T>}} then - Thenable
|
||||
*/
|
||||
|
||||
/** @template T @typedef {{Object}} DateSkippedBuilder
|
||||
* @property {{(n: number) => DateRangeBuilder<T>}} take - Take n items after skipped position
|
||||
* @property {{(onValue?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch from skipped position to end
|
||||
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
|
||||
* @property {{DateThenable<T>}} then - Thenable
|
||||
*/
|
||||
|
||||
/** @template T @typedef {{Object}} RangeBuilder
|
||||
* @property {{(onValue?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch the range
|
||||
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
|
||||
* @property {{Thenable<T>}} then - Thenable
|
||||
*/
|
||||
|
||||
/** @template T @typedef {{Object}} DateRangeBuilder
|
||||
* @property {{(onValue?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch the range
|
||||
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
|
||||
* @property {{DateThenable<T>}} then - Thenable
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {{Object}} SeriesPattern
|
||||
* @property {{string}} name - The series name
|
||||
* @property {{Readonly<Partial<Record<Index, SeriesEndpoint<T>>>>}} by - Index endpoints as lazy getters
|
||||
* @property {{() => readonly Index[]}} indexes - Get the list of available indexes
|
||||
* @property {{(index: Index) => SeriesEndpoint<T>|undefined}} get - Get an endpoint for a specific index
|
||||
*/
|
||||
|
||||
/** @typedef {{SeriesPattern<any>}} AnySeriesPattern */
|
||||
|
||||
/**
|
||||
* Create a series endpoint builder with typestate pattern.
|
||||
* @template T
|
||||
* @param {{BrkClient}} client
|
||||
* @param {{string}} name - The series vec name
|
||||
* @param {{Index}} index - The index name
|
||||
* @returns {{DateSeriesEndpoint<T>}}
|
||||
*/
|
||||
function _endpoint(client, name, index) {{
|
||||
const p = `/api/series/${{name}}/${{index}}`;
|
||||
|
||||
/**
|
||||
* @param {{number}} [start]
|
||||
* @param {{number}} [end]
|
||||
* @param {{string}} [format]
|
||||
* @returns {{string}}
|
||||
*/
|
||||
const buildPath = (start, end, format) => {{
|
||||
const params = new URLSearchParams();
|
||||
if (start !== undefined) params.set('start', String(start));
|
||||
if (end !== undefined) params.set('end', String(end));
|
||||
if (format) params.set('format', format);
|
||||
const query = params.toString();
|
||||
return query ? `${{p}}?${{query}}` : p;
|
||||
}};
|
||||
|
||||
/**
|
||||
* @param {{number}} [start]
|
||||
* @param {{number}} [end]
|
||||
* @returns {{DateRangeBuilder<T>}}
|
||||
*/
|
||||
const rangeBuilder = (start, end) => ({{
|
||||
fetch(onValue) {{ return client._fetchSeriesData(buildPath(start, end), onValue); }},
|
||||
fetchCsv() {{ return client.getText(buildPath(start, end, 'csv')); }},
|
||||
then(resolve, reject) {{ return this.fetch().then(resolve, reject); }},
|
||||
}});
|
||||
|
||||
/**
|
||||
* @param {{number}} idx
|
||||
* @returns {{DateSingleItemBuilder<T>}}
|
||||
*/
|
||||
const singleItemBuilder = (idx) => ({{
|
||||
fetch(onValue) {{ return client._fetchSeriesData(buildPath(idx, idx + 1), onValue); }},
|
||||
fetchCsv() {{ return client.getText(buildPath(idx, idx + 1, 'csv')); }},
|
||||
then(resolve, reject) {{ return this.fetch().then(resolve, reject); }},
|
||||
}});
|
||||
|
||||
/**
|
||||
* @param {{number}} start
|
||||
* @returns {{DateSkippedBuilder<T>}}
|
||||
*/
|
||||
const skippedBuilder = (start) => ({{
|
||||
take(n) {{ return rangeBuilder(start, start + n); }},
|
||||
fetch(onValue) {{ return client._fetchSeriesData(buildPath(start, undefined), onValue); }},
|
||||
fetchCsv() {{ return client.getText(buildPath(start, undefined, 'csv')); }},
|
||||
then(resolve, reject) {{ return this.fetch().then(resolve, reject); }},
|
||||
}});
|
||||
|
||||
/** @type {{DateSeriesEndpoint<T>}} */
|
||||
const endpoint = {{
|
||||
get(idx) {{ if (idx instanceof Date) idx = dateToIndex(index, idx); return singleItemBuilder(idx); }},
|
||||
slice(start, end) {{
|
||||
if (start instanceof Date) start = dateToIndex(index, start);
|
||||
if (end instanceof Date) end = dateToIndex(index, end);
|
||||
return rangeBuilder(start, end);
|
||||
}},
|
||||
first(n) {{ return rangeBuilder(undefined, n); }},
|
||||
last(n) {{ return n === 0 ? rangeBuilder(undefined, 0) : rangeBuilder(-n, undefined); }},
|
||||
skip(n) {{ return skippedBuilder(n); }},
|
||||
fetch(onValue) {{ return client._fetchSeriesData(buildPath(), onValue); }},
|
||||
fetchCsv() {{ return client.getText(buildPath(undefined, undefined, 'csv')); }},
|
||||
len() {{ return client.getSeriesLen(name, index); }},
|
||||
version() {{ return client.getSeriesVersion(name, index); }},
|
||||
then(resolve, reject) {{ return this.fetch().then(resolve, reject); }},
|
||||
get path() {{ return p; }},
|
||||
}};
|
||||
|
||||
return endpoint;
|
||||
}}
|
||||
|
||||
/**
|
||||
* Base HTTP client for making requests with caching support
|
||||
*/
|
||||
class BrkClientBase {{
|
||||
/**
|
||||
* @param {{BrkClientOptions|string}} options
|
||||
*/
|
||||
constructor(options) {{
|
||||
const isString = typeof options === 'string';
|
||||
const rawUrl = isString ? options : options.baseUrl;
|
||||
this.baseUrl = rawUrl.endsWith('/') ? rawUrl.slice(0, -1) : rawUrl;
|
||||
const url = _parseBaseUrl(this.baseUrl);
|
||||
this.url = url.href.endsWith('/') ? url.href.slice(0, -1) : url.href;
|
||||
this.domain = url.hostname;
|
||||
this.timeout = isString ? 5000 : (options.timeout ?? 5000);
|
||||
/** @type {{Promise<Cache | null>}} */
|
||||
this._browserCachePromise = _openBrowserCache(isString ? undefined : options.browserCache);
|
||||
/** @type {{Cache | null}} */
|
||||
this._browserCache = null;
|
||||
this._browserCachePromise.then(c => this._browserCache = c);
|
||||
const memOpt = isString ? undefined : options.memCache;
|
||||
this._memCacheMax = memOpt === false || memOpt === 0
|
||||
? 0
|
||||
: (typeof memOpt === 'number' ? memOpt : _DEFAULT_MEM_CACHE_SIZE);
|
||||
/** @type {{Map<string, _MemEntry<unknown>>}} */
|
||||
this._memCache = new Map();
|
||||
}}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {{string}} key
|
||||
* @returns {{_MemEntry<T> | undefined}}
|
||||
*/
|
||||
_memGet(key) {{
|
||||
if (!this._memCacheMax) return undefined;
|
||||
const hit = this._memCache.get(key);
|
||||
if (!hit) return undefined;
|
||||
this._memCache.delete(key);
|
||||
this._memCache.set(key, hit);
|
||||
return /** @type {{_MemEntry<T>}} */ (hit);
|
||||
}}
|
||||
|
||||
/**
|
||||
* @param {{string}} key
|
||||
* @param {{string | null}} etag
|
||||
* @param {{unknown}} value
|
||||
*/
|
||||
_memSet(key, etag, value) {{
|
||||
if (!this._memCacheMax) return;
|
||||
if (this._memCache.has(key)) this._memCache.delete(key);
|
||||
else if (this._memCache.size >= this._memCacheMax) {{
|
||||
const oldest = this._memCache.keys().next().value;
|
||||
if (oldest !== undefined) this._memCache.delete(oldest);
|
||||
}}
|
||||
this._memCache.set(key, {{ etag, value }});
|
||||
}}
|
||||
|
||||
/**
|
||||
* @param {{string}} path
|
||||
* @param {{{{ signal?: AbortSignal, cache?: boolean }}}} [options]
|
||||
* @returns {{Promise<Response>}}
|
||||
*/
|
||||
async get(path, {{ signal, cache = true }} = {{}}) {{
|
||||
const url = `${{this.baseUrl}}${{path}}`;
|
||||
const signals = [AbortSignal.timeout(this.timeout)];
|
||||
if (signal) signals.push(signal);
|
||||
/** @type {{RequestInit}} */
|
||||
const init = {{ signal: AbortSignal.any(signals) }};
|
||||
if (!cache) init.cache = 'no-store';
|
||||
const res = await fetch(url, init);
|
||||
if (!res.ok) throw new BrkError(`HTTP ${{res.status}}: ${{url}}`, res.status);
|
||||
return res;
|
||||
}}
|
||||
|
||||
/**
|
||||
* Make a GET request with layered caching.
|
||||
*
|
||||
* Contract:
|
||||
* - The returned Promise resolves with the **freshest** value (post-revalidation).
|
||||
* - `onValue` fires once with the freshest value, or twice if a stale snapshot
|
||||
* could be shown first (stale-while-revalidate). On a 304 there is no second fire.
|
||||
*
|
||||
* Layers:
|
||||
* - L1 (memCache): in-memory parsed values keyed by URL+ETag. Lets 304s skip the parse entirely.
|
||||
* - L2 (browserCache): Cache API, survives reload and feeds onValue fast on cold start.
|
||||
*
|
||||
* @template T
|
||||
* @param {{string}} path
|
||||
* @param {{(res: Response) => Promise<T>}} parse - Response body reader
|
||||
* @param {{{{ onValue?: (value: T) => void, signal?: AbortSignal, cache?: boolean }}}} [options]
|
||||
* @returns {{Promise<T>}}
|
||||
*/
|
||||
async _getCached(path, parse, {{ onValue, signal, cache = true }} = {{}}) {{
|
||||
if (!cache) {{
|
||||
const res = await this.get(path, {{ signal, cache }});
|
||||
const value = await parse(res);
|
||||
if (onValue) onValue(value);
|
||||
return value;
|
||||
}}
|
||||
|
||||
const url = `${{this.baseUrl}}${{path}}`;
|
||||
/** @type {{_MemEntry<T> | undefined}} */
|
||||
const memHit = this._memGet(url);
|
||||
const browserCache = this._browserCache;
|
||||
|
||||
// L1 fast path: deliver from memCache, revalidate via network.
|
||||
// ETag match → zero parse, zero clone, zero cache write, no second onValue fire.
|
||||
if (memHit) {{
|
||||
if (onValue) onValue(memHit.value);
|
||||
try {{
|
||||
const res = await this.get(path, {{ signal }});
|
||||
const netEtag = res.headers.get('ETag');
|
||||
if (netEtag && netEtag === memHit.etag) return memHit.value;
|
||||
const cloned = browserCache ? res.clone() : null;
|
||||
const value = await parse(res);
|
||||
this._memSet(url, netEtag, value);
|
||||
if (onValue) onValue(value);
|
||||
if (cloned && browserCache) {{
|
||||
const cacheStore = browserCache;
|
||||
_runIdle(() => cacheStore.put(url, cloned));
|
||||
}}
|
||||
return value;
|
||||
}} catch {{
|
||||
return memHit.value;
|
||||
}}
|
||||
}}
|
||||
|
||||
// L1 miss: race browserCache (stale snapshot) vs network (fresh).
|
||||
let networkSettled = false;
|
||||
const stalePromise = onValue && browserCache
|
||||
? browserCache.match(url).then(async (res) => {{
|
||||
if (!res || networkSettled) return null;
|
||||
const value = await parse(res);
|
||||
if (networkSettled) return value;
|
||||
this._memSet(url, res.headers.get('ETag'), value);
|
||||
onValue(value);
|
||||
return value;
|
||||
}}).catch(() => null)
|
||||
: null;
|
||||
|
||||
try {{
|
||||
const res = await this.get(path, {{ signal }});
|
||||
networkSettled = true;
|
||||
const netEtag = res.headers.get('ETag');
|
||||
// Stale won and populated memCache with matching ETag → reuse, skip parse + second onValue.
|
||||
const populated = /** @type {{_MemEntry<T> | undefined}} */ (this._memGet(url));
|
||||
if (populated && netEtag && netEtag === populated.etag) return populated.value;
|
||||
const cloned = browserCache ? res.clone() : null;
|
||||
const value = await parse(res);
|
||||
this._memSet(url, netEtag, value);
|
||||
if (onValue) onValue(value);
|
||||
if (cloned && browserCache) {{
|
||||
const cacheStore = browserCache;
|
||||
_runIdle(() => cacheStore.put(url, cloned));
|
||||
}}
|
||||
return value;
|
||||
}} catch (e) {{
|
||||
const stale = await stalePromise;
|
||||
if (stale != null) return stale;
|
||||
throw e;
|
||||
}}
|
||||
}}
|
||||
|
||||
/**
|
||||
* Make a GET request expecting a JSON response. Cached and supports `onValue`.
|
||||
* @template T
|
||||
* @param {{string}} path
|
||||
* @param {{{{ onValue?: (value: T) => void, signal?: AbortSignal, cache?: boolean }}}} [options]
|
||||
* @returns {{Promise<T>}}
|
||||
*/
|
||||
getJson(path, options) {{
|
||||
return this._getCached(path, async (res) => _addCamelGetters(await res.json()), options);
|
||||
}}
|
||||
|
||||
/**
|
||||
* Make a GET request expecting a text response (text/plain, text/csv, ...).
|
||||
* Cached and supports `onValue`, same as `getJson`.
|
||||
* @param {{string}} path
|
||||
* @param {{{{ onValue?: (value: string) => void, signal?: AbortSignal, cache?: boolean }}}} [options]
|
||||
* @returns {{Promise<string>}}
|
||||
*/
|
||||
getText(path, options) {{
|
||||
return this._getCached(path, (res) => res.text(), options);
|
||||
}}
|
||||
|
||||
/**
|
||||
* Make a GET request expecting binary data (application/octet-stream).
|
||||
* Cached and supports `onValue`, same as `getJson`.
|
||||
* @param {{string}} path
|
||||
* @param {{{{ onValue?: (value: Uint8Array) => void, signal?: AbortSignal, cache?: boolean }}}} [options]
|
||||
* @returns {{Promise<Uint8Array>}}
|
||||
*/
|
||||
getBytes(path, options) {{
|
||||
return this._getCached(path, async (res) => new Uint8Array(await res.arrayBuffer()), options);
|
||||
}}
|
||||
|
||||
/**
|
||||
* Make a POST request with a string body.
|
||||
*
|
||||
* POST responses are uncached and never invoke `onValue` — every call hits
|
||||
* the network with the same body and returns the upstream response.
|
||||
*
|
||||
* @param {{string}} path
|
||||
* @param {{string}} body
|
||||
* @param {{{{ signal?: AbortSignal }}}} [options]
|
||||
* @returns {{Promise<Response>}}
|
||||
*/
|
||||
async post(path, body, {{ signal }} = {{}}) {{
|
||||
const url = `${{this.baseUrl}}${{path}}`;
|
||||
const signals = [AbortSignal.timeout(this.timeout)];
|
||||
if (signal) signals.push(signal);
|
||||
const res = await fetch(url, {{
|
||||
method: 'POST',
|
||||
body,
|
||||
signal: AbortSignal.any(signals),
|
||||
}});
|
||||
if (!res.ok) throw new BrkError(`HTTP ${{res.status}}: ${{url}}`, res.status);
|
||||
return res;
|
||||
}}
|
||||
|
||||
/**
|
||||
* Make a POST request expecting a JSON response.
|
||||
* @template T
|
||||
* @param {{string}} path
|
||||
* @param {{string}} body
|
||||
* @param {{{{ signal?: AbortSignal }}}} [options]
|
||||
* @returns {{Promise<T>}}
|
||||
*/
|
||||
async postJson(path, body, options) {{
|
||||
const res = await this.post(path, body, options);
|
||||
return _addCamelGetters(await res.json());
|
||||
}}
|
||||
|
||||
/**
|
||||
* Make a POST request expecting a text response.
|
||||
* @param {{string}} path
|
||||
* @param {{string}} body
|
||||
* @param {{{{ signal?: AbortSignal }}}} [options]
|
||||
* @returns {{Promise<string>}}
|
||||
*/
|
||||
async postText(path, body, options) {{
|
||||
const res = await this.post(path, body, options);
|
||||
return res.text();
|
||||
}}
|
||||
|
||||
/**
|
||||
* Make a POST request expecting binary data (application/octet-stream).
|
||||
* @param {{string}} path
|
||||
* @param {{string}} body
|
||||
* @param {{{{ signal?: AbortSignal }}}} [options]
|
||||
* @returns {{Promise<Uint8Array>}}
|
||||
*/
|
||||
async postBytes(path, body, options) {{
|
||||
const res = await this.post(path, body, options);
|
||||
return new Uint8Array(await res.arrayBuffer());
|
||||
}}
|
||||
|
||||
/**
|
||||
* Fetch series data and wrap with helper methods (internal)
|
||||
* @template T
|
||||
* @param {{string}} path
|
||||
* @param {{(value: DateSeriesData<T>) => void}} [onValue]
|
||||
* @returns {{Promise<DateSeriesData<T>>}}
|
||||
*/
|
||||
async _fetchSeriesData(path, onValue) {{
|
||||
const wrappedOnValue = onValue ? (/** @type {{SeriesData<T>}} */ raw) => onValue(_wrapSeriesData(raw)) : undefined;
|
||||
const raw = await this.getJson(path, {{ onValue: wrappedOnValue }});
|
||||
return _wrapSeriesData(raw);
|
||||
}}
|
||||
}}
|
||||
|
||||
/**
|
||||
* Build series name with suffix.
|
||||
* @param {{string}} acc - Accumulated prefix
|
||||
* @param {{string}} s - Series suffix
|
||||
* @returns {{string}}
|
||||
*/
|
||||
const _m = (acc, s) => s ? (acc ? `${{acc}}_${{s}}` : s) : acc;
|
||||
|
||||
/**
|
||||
* Build series name with prefix.
|
||||
* @param {{string}} prefix - Prefix to prepend
|
||||
* @param {{string}} acc - Accumulated name
|
||||
* @returns {{string}}
|
||||
*/
|
||||
const _p = (prefix, acc) => acc ? `${{prefix}}_${{acc}}` : prefix;
|
||||
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Generate static constants for the BrkClient class.
|
||||
pub fn generate_static_constants(output: &mut String) {
|
||||
let constants = ClientConstants::collect();
|
||||
|
||||
// VERSION, INDEXES, POOL_ID_TO_POOL_NAME
|
||||
writeln!(output, " VERSION = \"{}\";\n", constants.version).unwrap();
|
||||
write_static_const(output, "INDEXES", &format_json(&constants.indexes));
|
||||
write_static_const(
|
||||
output,
|
||||
"POOL_ID_TO_POOL_NAME",
|
||||
&format_json(&constants.pool_map),
|
||||
);
|
||||
|
||||
// Cohort constants with camelCase keys
|
||||
for (name, value) in CohortConstants::all() {
|
||||
write_static_const(output, name, &format_json(&camel_case_keys(value)));
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
writeln!(
|
||||
output,
|
||||
r#" /**
|
||||
* Convert an index value to a Date for date-based indexes.
|
||||
* @param {{Index}} index - The index type
|
||||
* @param {{number}} i - The index value
|
||||
* @returns {{globalThis.Date}}
|
||||
*/
|
||||
indexToDate(index, i) {{
|
||||
return indexToDate(index, i);
|
||||
}}
|
||||
|
||||
/**
|
||||
* Convert a Date to an index value for date-based indexes.
|
||||
* @param {{Index}} index - The index type
|
||||
* @param {{globalThis.Date}} d - The date to convert
|
||||
* @returns {{number}}
|
||||
*/
|
||||
dateToIndex(index, d) {{
|
||||
return dateToIndex(index, d);
|
||||
}}
|
||||
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn indent_json_const(json: &str) -> String {
|
||||
json.lines()
|
||||
.enumerate()
|
||||
.map(|(i, line)| {
|
||||
if i == 0 {
|
||||
line.to_string()
|
||||
} else {
|
||||
format!(" {}", line)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn write_static_const(output: &mut String, name: &str, json: &str) {
|
||||
writeln!(
|
||||
output,
|
||||
" {} = /** @type {{const}} */ ({});\n",
|
||||
name,
|
||||
indent_json_const(json)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Generate index accessor factory functions.
|
||||
pub fn generate_index_accessors(output: &mut String, patterns: &[IndexSetPattern]) {
|
||||
if patterns.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
writeln!(output, "// Index group constants and factory\n").unwrap();
|
||||
|
||||
// Generate index array constants (e.g., _i1 = ["day1", "height"])
|
||||
for (i, pattern) in patterns.iter().enumerate() {
|
||||
write!(output, "const _i{} = /** @type {{const}} */ ([", i + 1).unwrap();
|
||||
for (j, index) in pattern.indexes.iter().enumerate() {
|
||||
if j > 0 {
|
||||
write!(output, ", ").unwrap();
|
||||
}
|
||||
write!(output, "\"{}\"", index.name()).unwrap();
|
||||
}
|
||||
writeln!(output, "]);").unwrap();
|
||||
}
|
||||
writeln!(output).unwrap();
|
||||
|
||||
// Generate ONE generic series pattern factory
|
||||
writeln!(
|
||||
output,
|
||||
r#"/**
|
||||
* Generic series pattern factory.
|
||||
* @template T
|
||||
* @param {{BrkClient}} client
|
||||
* @param {{string}} name - The series vec name
|
||||
* @param {{readonly Index[]}} indexes - The supported indexes
|
||||
*/
|
||||
function _mp(client, name, indexes) {{
|
||||
const by = {{}};
|
||||
for (const idx of indexes) {{
|
||||
Object.defineProperty(by, idx, {{
|
||||
get() {{ return _endpoint(client, name, idx); }},
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
}});
|
||||
}}
|
||||
return {{
|
||||
name,
|
||||
by,
|
||||
/** @returns {{readonly Index[]}} */
|
||||
indexes() {{ return indexes; }},
|
||||
/** @param {{Index}} index @returns {{SeriesEndpoint<T>|undefined}} */
|
||||
get(index) {{ return indexes.includes(index) ? _endpoint(client, name, index) : undefined; }}
|
||||
}};
|
||||
}}
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Generate typedefs and thin wrapper functions
|
||||
for (i, pattern) in patterns.iter().enumerate() {
|
||||
// Generate typedef for type safety
|
||||
let by_fields: Vec<String> = pattern
|
||||
.indexes
|
||||
.iter()
|
||||
.map(|idx| {
|
||||
let builder = if idx.is_date_based() {
|
||||
"DateSeriesEndpoint"
|
||||
} else {
|
||||
"SeriesEndpoint"
|
||||
};
|
||||
format!("readonly {}: {}<T>", idx.name(), builder)
|
||||
})
|
||||
.collect();
|
||||
let by_type = format!("{{ {} }}", by_fields.join(", "));
|
||||
|
||||
writeln!(
|
||||
output,
|
||||
"/** @template T @typedef {{{{ name: string, by: {}, indexes: () => readonly Index[], get: (index: Index) => SeriesEndpoint<T>|undefined }}}} {} */",
|
||||
by_type, pattern.name
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Generate thin wrapper that calls the generic factory
|
||||
writeln!(
|
||||
output,
|
||||
"/** @template T @param {{BrkClient}} client @param {{string}} name @returns {{{}<T>}} */",
|
||||
pattern.name
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
"function create{}(client, name) {{ return /** @type {{{}<T>}} */ (_mp(client, name, _i{})); }}",
|
||||
pattern.name,
|
||||
pattern.name,
|
||||
i + 1
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(output).unwrap();
|
||||
}
|
||||
|
||||
/// Generate structural pattern factory functions.
|
||||
pub fn generate_structural_patterns(
|
||||
output: &mut String,
|
||||
patterns: &[StructuralPattern],
|
||||
metadata: &ClientMetadata,
|
||||
) {
|
||||
if patterns.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
writeln!(output, "// Reusable structural pattern factories\n").unwrap();
|
||||
|
||||
for pattern in patterns {
|
||||
// Generate typedef
|
||||
writeln!(output, "/**").unwrap();
|
||||
if pattern.is_generic {
|
||||
writeln!(output, " * @template T").unwrap();
|
||||
}
|
||||
writeln!(output, " * @typedef {{Object}} {}", pattern.name).unwrap();
|
||||
for field in &pattern.fields {
|
||||
let js_type = metadata.field_type_annotation(
|
||||
field,
|
||||
pattern.is_generic,
|
||||
None,
|
||||
GenericSyntax::JAVASCRIPT,
|
||||
);
|
||||
writeln!(
|
||||
output,
|
||||
" * @property {{{}}} {}",
|
||||
js_type,
|
||||
to_camel_case(&field.name)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(output, " */\n").unwrap();
|
||||
|
||||
// Skip factory for non-parameterizable patterns (inlined at tree level)
|
||||
if !metadata.is_parameterizable(&pattern.name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
writeln!(output, "/**").unwrap();
|
||||
writeln!(output, " * Create a {} pattern node", pattern.name).unwrap();
|
||||
if pattern.is_generic {
|
||||
writeln!(output, " * @template T").unwrap();
|
||||
}
|
||||
writeln!(output, " * @param {{BrkClient}} client").unwrap();
|
||||
writeln!(output, " * @param {{string}} acc - Accumulated series name").unwrap();
|
||||
if pattern.is_templated() {
|
||||
writeln!(output, " * @param {{string}} disc - Discriminator suffix").unwrap();
|
||||
}
|
||||
let return_type = if pattern.is_generic {
|
||||
format!("{}<T>", pattern.name)
|
||||
} else {
|
||||
pattern.name.clone()
|
||||
};
|
||||
writeln!(output, " * @returns {{{}}}", return_type).unwrap();
|
||||
writeln!(output, " */").unwrap();
|
||||
|
||||
if pattern.is_templated() {
|
||||
writeln!(
|
||||
output,
|
||||
"function create{}(client, acc, disc) {{",
|
||||
pattern.name
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(output, "function create{}(client, acc) {{", pattern.name).unwrap();
|
||||
}
|
||||
writeln!(output, " return {{").unwrap();
|
||||
|
||||
let syntax = JavaScriptSyntax;
|
||||
for field in &pattern.fields {
|
||||
generate_parameterized_field(output, &syntax, field, pattern, metadata, " ");
|
||||
}
|
||||
|
||||
writeln!(output, " }};").unwrap();
|
||||
writeln!(output, "}}\n").unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! JavaScript client generation.
|
||||
//!
|
||||
//! This module generates a JavaScript + JSDoc client for the BRK API.
|
||||
|
||||
mod api;
|
||||
pub mod client;
|
||||
pub mod tree;
|
||||
pub mod types;
|
||||
|
||||
use std::{fmt::Write, fs, io, path::Path};
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::write_if_changed;
|
||||
use crate::{ClientMetadata, Endpoint, TypeSchemas, VERSION};
|
||||
|
||||
/// Generate JavaScript + JSDoc client from metadata and OpenAPI endpoints.
|
||||
///
|
||||
/// `output_path` is the full path to the output file (e.g., "modules/brk-client/index.js").
|
||||
pub fn generate_javascript_client(
|
||||
metadata: &ClientMetadata,
|
||||
endpoints: &[Endpoint],
|
||||
schemas: &TypeSchemas,
|
||||
output_path: &Path,
|
||||
) -> io::Result<()> {
|
||||
let mut output = String::new();
|
||||
|
||||
writeln!(output, "// Auto-generated BRK JavaScript client").unwrap();
|
||||
writeln!(output, "// Do not edit manually\n").unwrap();
|
||||
|
||||
types::generate_type_definitions(&mut output, schemas);
|
||||
client::generate_base_client(&mut output);
|
||||
client::generate_index_accessors(&mut output, &metadata.index_set_patterns);
|
||||
client::generate_structural_patterns(&mut output, &metadata.structural_patterns, metadata);
|
||||
tree::generate_tree_typedefs(&mut output, &metadata.catalog, metadata);
|
||||
tree::generate_main_client(&mut output, &metadata.catalog, metadata, endpoints);
|
||||
|
||||
write_if_changed(output_path, &output)?;
|
||||
|
||||
// Update package.json version if it exists in the same directory
|
||||
if let Some(parent) = output_path.parent() {
|
||||
let package_json_path = parent.join("package.json");
|
||||
if package_json_path.exists() {
|
||||
update_package_json_version(&package_json_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_package_json_version(package_json_path: &Path) -> io::Result<()> {
|
||||
let content = fs::read_to_string(package_json_path)?;
|
||||
let mut package: serde_json::Value = serde_json::from_str(&content)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
if let Some(obj) = package.as_object_mut() {
|
||||
obj.insert("version".to_string(), json!(VERSION));
|
||||
}
|
||||
|
||||
let updated = serde_json::to_string_pretty(&package)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
write_if_changed(package_json_path, &(updated + "\n"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! JavaScript tree structure generation.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt::Write;
|
||||
|
||||
use brk_types::TreeNode;
|
||||
|
||||
use crate::{
|
||||
ClientMetadata, Endpoint, GenericSyntax, JavaScriptSyntax, PatternField, build_child_path,
|
||||
generate_leaf_field, generate_tree_node_field, prepare_tree_node, to_camel_case,
|
||||
};
|
||||
|
||||
use super::api::generate_api_methods;
|
||||
use super::client::generate_static_constants;
|
||||
|
||||
/// Generate JSDoc typedefs for the series tree.
|
||||
pub fn generate_tree_typedefs(output: &mut String, catalog: &TreeNode, metadata: &ClientMetadata) {
|
||||
writeln!(output, "// Catalog tree typedefs\n").unwrap();
|
||||
|
||||
let pattern_lookup = metadata.pattern_lookup();
|
||||
let mut generated = BTreeSet::new();
|
||||
generate_tree_typedef(
|
||||
output,
|
||||
"SeriesTree",
|
||||
"",
|
||||
catalog,
|
||||
pattern_lookup,
|
||||
metadata,
|
||||
&mut generated,
|
||||
);
|
||||
}
|
||||
|
||||
fn generate_tree_typedef(
|
||||
output: &mut String,
|
||||
name: &str,
|
||||
path: &str,
|
||||
node: &TreeNode,
|
||||
pattern_lookup: &std::collections::BTreeMap<Vec<PatternField>, String>,
|
||||
metadata: &ClientMetadata,
|
||||
generated: &mut BTreeSet<String>,
|
||||
) {
|
||||
let Some(ctx) = prepare_tree_node(node, name, path, pattern_lookup, metadata, generated) else {
|
||||
return;
|
||||
};
|
||||
|
||||
writeln!(output, "/**").unwrap();
|
||||
writeln!(output, " * @typedef {{Object}} {}", name).unwrap();
|
||||
|
||||
for child in &ctx.children {
|
||||
let js_type = if child.should_inline {
|
||||
child.inline_type_name.clone()
|
||||
} else {
|
||||
metadata.field_type_annotation(&child.field, false, None, GenericSyntax::JAVASCRIPT)
|
||||
};
|
||||
|
||||
writeln!(
|
||||
output,
|
||||
" * @property {{{}}} {}",
|
||||
js_type,
|
||||
to_camel_case(&child.field.name)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
writeln!(output, " */\n").unwrap();
|
||||
|
||||
// Generate child typedefs
|
||||
for child in &ctx.children {
|
||||
if child.should_inline {
|
||||
let child_path = build_child_path(path, child.name);
|
||||
generate_tree_typedef(
|
||||
output,
|
||||
&child.inline_type_name,
|
||||
&child_path,
|
||||
child.node,
|
||||
pattern_lookup,
|
||||
metadata,
|
||||
generated,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the main BrkClient class.
|
||||
pub fn generate_main_client(
|
||||
output: &mut String,
|
||||
catalog: &TreeNode,
|
||||
metadata: &ClientMetadata,
|
||||
endpoints: &[Endpoint],
|
||||
) {
|
||||
let pattern_lookup = metadata.pattern_lookup();
|
||||
|
||||
writeln!(output, "/**").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" * Main BRK client with series tree and API methods"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " * @extends BrkClientBase").unwrap();
|
||||
writeln!(output, " */").unwrap();
|
||||
writeln!(output, "class BrkClient extends BrkClientBase {{").unwrap();
|
||||
|
||||
generate_static_constants(output);
|
||||
|
||||
writeln!(output, " /**").unwrap();
|
||||
writeln!(output, " * @param {{BrkClientOptions|string}} options").unwrap();
|
||||
writeln!(output, " */").unwrap();
|
||||
writeln!(output, " constructor(options) {{").unwrap();
|
||||
writeln!(output, " super(options);").unwrap();
|
||||
writeln!(output, " /** @type {{SeriesTree}} */").unwrap();
|
||||
writeln!(output, " this.series = this._buildTree();").unwrap();
|
||||
writeln!(output, " }}\n").unwrap();
|
||||
|
||||
writeln!(output, " /**").unwrap();
|
||||
writeln!(output, " * @private").unwrap();
|
||||
writeln!(output, " * @returns {{SeriesTree}}").unwrap();
|
||||
writeln!(output, " */").unwrap();
|
||||
writeln!(output, " _buildTree() {{").unwrap();
|
||||
writeln!(output, " return {{").unwrap();
|
||||
let mut generated = BTreeSet::new();
|
||||
generate_tree_initializer(
|
||||
output,
|
||||
catalog,
|
||||
"SeriesTree",
|
||||
"",
|
||||
3,
|
||||
pattern_lookup,
|
||||
metadata,
|
||||
&mut generated,
|
||||
);
|
||||
writeln!(output, " }};").unwrap();
|
||||
writeln!(output, " }}\n").unwrap();
|
||||
|
||||
writeln!(output, " /**").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" * Create a dynamic series endpoint builder for any series/index combination."
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " *").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" * Use this for programmatic access when the series name is determined at runtime."
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" * For type-safe access, use the `series` tree instead."
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " *").unwrap();
|
||||
writeln!(output, " * @param {{string}} series - The series name").unwrap();
|
||||
writeln!(output, " * @param {{Index}} index - The index name").unwrap();
|
||||
writeln!(output, " * @returns {{SeriesEndpoint<unknown>}}").unwrap();
|
||||
writeln!(output, " */").unwrap();
|
||||
writeln!(output, " seriesEndpoint(series, index) {{").unwrap();
|
||||
writeln!(output, " return _endpoint(this, series, index);").unwrap();
|
||||
writeln!(output, " }}\n").unwrap();
|
||||
|
||||
generate_api_methods(output, endpoints);
|
||||
|
||||
writeln!(output, "}}\n").unwrap();
|
||||
|
||||
writeln!(output, "export {{ BrkClient, BrkError }};").unwrap();
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn generate_tree_initializer(
|
||||
output: &mut String,
|
||||
node: &TreeNode,
|
||||
name: &str,
|
||||
path: &str,
|
||||
indent: usize,
|
||||
pattern_lookup: &std::collections::BTreeMap<Vec<PatternField>, String>,
|
||||
metadata: &ClientMetadata,
|
||||
generated: &mut BTreeSet<String>,
|
||||
) {
|
||||
let indent_str = " ".repeat(indent);
|
||||
|
||||
let Some(ctx) = prepare_tree_node(node, name, path, pattern_lookup, metadata, generated) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let syntax = JavaScriptSyntax;
|
||||
for child in &ctx.children {
|
||||
let field_name = to_camel_case(child.name);
|
||||
|
||||
if child.is_leaf {
|
||||
if let TreeNode::Leaf(leaf) = child.node {
|
||||
generate_leaf_field(
|
||||
output,
|
||||
&syntax,
|
||||
"this",
|
||||
child.name,
|
||||
leaf,
|
||||
metadata,
|
||||
&indent_str,
|
||||
);
|
||||
}
|
||||
} else if child.should_inline {
|
||||
// Inline object
|
||||
let child_path = build_child_path(path, child.name);
|
||||
writeln!(output, "{}{}: {{", indent_str, field_name).unwrap();
|
||||
generate_tree_initializer(
|
||||
output,
|
||||
child.node,
|
||||
&child.inline_type_name,
|
||||
&child_path,
|
||||
indent + 1,
|
||||
pattern_lookup,
|
||||
metadata,
|
||||
generated,
|
||||
);
|
||||
writeln!(output, "{}}},", indent_str).unwrap();
|
||||
} else {
|
||||
generate_tree_node_field(
|
||||
output,
|
||||
&syntax,
|
||||
&child.field,
|
||||
metadata,
|
||||
&indent_str,
|
||||
"this",
|
||||
&child.base_result,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! JavaScript type definitions generation.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
TypeSchemas,
|
||||
generators::{MANUAL_GENERIC_TYPES, write_description},
|
||||
get_union_variants, ref_to_type_name, to_camel_case,
|
||||
};
|
||||
|
||||
/// Generate JSDoc type definitions from OpenAPI schemas.
|
||||
pub fn generate_type_definitions(output: &mut String, schemas: &TypeSchemas) {
|
||||
if schemas.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
writeln!(output, "// Type definitions\n").unwrap();
|
||||
|
||||
for (name, schema) in schemas {
|
||||
if MANUAL_GENERIC_TYPES.contains(&name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let js_type = schema_to_js_type(schema, Some(name));
|
||||
|
||||
let type_desc = schema.get("description").and_then(|d| d.as_str());
|
||||
|
||||
if is_primitive_alias(schema) {
|
||||
if let Some(desc) = type_desc {
|
||||
writeln!(output, "/**").unwrap();
|
||||
write_description(output, desc, " * ", " *");
|
||||
writeln!(output, " *").unwrap();
|
||||
writeln!(output, " * @typedef {{{}}} {}", js_type, name).unwrap();
|
||||
writeln!(output, " */").unwrap();
|
||||
} else {
|
||||
writeln!(output, "/** @typedef {{{}}} {} */", js_type, name).unwrap();
|
||||
}
|
||||
} else if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
|
||||
writeln!(output, "/**").unwrap();
|
||||
if let Some(desc) = type_desc {
|
||||
write_description(output, desc, " * ", " *");
|
||||
writeln!(output, " *").unwrap();
|
||||
}
|
||||
writeln!(output, " * @typedef {{Object}} {}", name).unwrap();
|
||||
for (prop_name, prop_schema) in props {
|
||||
let prop_type = schema_to_js_type(prop_schema, Some(name));
|
||||
let required = schema
|
||||
.get("required")
|
||||
.and_then(|r| r.as_array())
|
||||
.map(|arr| arr.iter().any(|v| v.as_str() == Some(prop_name)))
|
||||
.unwrap_or(false);
|
||||
let optional = if required { "" } else { "=" };
|
||||
let safe_name = to_camel_case(&prop_name.replace(['[', ']'], ""));
|
||||
let prop_desc = prop_schema
|
||||
.get("description")
|
||||
.and_then(|d| d.as_str())
|
||||
.map(|d| format!(" - {}", d))
|
||||
.unwrap_or_default();
|
||||
writeln!(
|
||||
output,
|
||||
" * @property {{{}{}}} {}{}",
|
||||
prop_type, optional, safe_name, prop_desc
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(output, " */").unwrap();
|
||||
} else if let Some(desc) = type_desc {
|
||||
writeln!(output, "/**").unwrap();
|
||||
write_description(output, desc, " * ", " *");
|
||||
writeln!(output, " *").unwrap();
|
||||
writeln!(output, " * @typedef {{{}}} {}", js_type, name).unwrap();
|
||||
writeln!(output, " */").unwrap();
|
||||
} else {
|
||||
writeln!(output, "/** @typedef {{{}}} {} */", js_type, name).unwrap();
|
||||
}
|
||||
}
|
||||
writeln!(output).unwrap();
|
||||
}
|
||||
|
||||
fn is_primitive_alias(schema: &Value) -> bool {
|
||||
schema.get("properties").is_none()
|
||||
&& schema.get("items").is_none()
|
||||
&& schema.get("anyOf").is_none()
|
||||
&& schema.get("oneOf").is_none()
|
||||
&& schema.get("enum").is_none()
|
||||
}
|
||||
|
||||
fn json_type_to_js(ty: &str, schema: &Value, current_type: Option<&str>) -> String {
|
||||
match ty {
|
||||
"integer" | "number" => "number".to_string(),
|
||||
"boolean" => "boolean".to_string(),
|
||||
"string" => "string".to_string(),
|
||||
"null" => "null".to_string(),
|
||||
"array" => {
|
||||
let item_type = schema
|
||||
.get("items")
|
||||
.map(|s| schema_to_js_type(s, current_type))
|
||||
.unwrap_or_else(|| "*".to_string());
|
||||
format!("{}[]", item_type)
|
||||
}
|
||||
"object" => {
|
||||
if let Some(add_props) = schema.get("additionalProperties") {
|
||||
let value_type = schema_to_js_type(add_props, current_type);
|
||||
return format!("{{ [key: string]: {} }}", value_type);
|
||||
}
|
||||
"Object".to_string()
|
||||
}
|
||||
_ => "*".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// JSDoc has no `integer` keyword, only `number`. Map `integer` (and `integer[]`,
|
||||
/// `Foo<integer>`, etc.) to `number` before emitting type strings to JS.
|
||||
pub fn jsdoc_normalize(ty: &str) -> String {
|
||||
let mut out = ty.to_string();
|
||||
let mut prev = String::new();
|
||||
while prev != out {
|
||||
prev = out.clone();
|
||||
out = out.replace("integer[]", "number[]");
|
||||
out = out.replace("<integer>", "<number>");
|
||||
out = out.replace("(integer)", "(number)");
|
||||
out = out.replace("integer | ", "number | ");
|
||||
out = out.replace(" | integer", " | number");
|
||||
}
|
||||
if out == "integer" {
|
||||
return "number".to_string();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Convert a JSON schema to a JavaScript type string.
|
||||
pub fn schema_to_js_type(schema: &Value, current_type: Option<&str>) -> String {
|
||||
if let Some(all_of) = schema.get("allOf").and_then(|v| v.as_array()) {
|
||||
for item in all_of {
|
||||
let resolved = schema_to_js_type(item, current_type);
|
||||
if resolved != "*" {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref_path) = schema.get("$ref").and_then(|r| r.as_str()) {
|
||||
return ref_to_type_name(ref_path).unwrap_or("*").to_string();
|
||||
}
|
||||
|
||||
if let Some(enum_values) = schema.get("enum").and_then(|e| e.as_array()) {
|
||||
let literals: Vec<String> = enum_values
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|s| format!("\"{}\"", s))
|
||||
.collect();
|
||||
if !literals.is_empty() {
|
||||
return format!("({})", literals.join("|"));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ty) = schema.get("type") {
|
||||
if let Some(type_array) = ty.as_array() {
|
||||
let types: Vec<String> = type_array
|
||||
.iter()
|
||||
.filter_map(|t| t.as_str())
|
||||
.filter(|t| *t != "null")
|
||||
.map(|t| json_type_to_js(t, schema, current_type))
|
||||
.collect();
|
||||
let has_null = type_array.iter().any(|t| t.as_str() == Some("null"));
|
||||
|
||||
if types.len() == 1 {
|
||||
let base_type = &types[0];
|
||||
return if has_null {
|
||||
format!("?{}", base_type)
|
||||
} else {
|
||||
base_type.clone()
|
||||
};
|
||||
} else if !types.is_empty() {
|
||||
let union = format!("({})", types.join("|"));
|
||||
return if has_null {
|
||||
format!("?{}", union)
|
||||
} else {
|
||||
union
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ty_str) = ty.as_str() {
|
||||
return json_type_to_js(ty_str, schema, current_type);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(variants) = get_union_variants(schema) {
|
||||
let types: Vec<String> = variants
|
||||
.iter()
|
||||
.map(|v| schema_to_js_type(v, current_type))
|
||||
.collect();
|
||||
let filtered: Vec<_> = types.iter().filter(|t| *t != "*").collect();
|
||||
if !filtered.is_empty() {
|
||||
return format!(
|
||||
"({})",
|
||||
filtered
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("|")
|
||||
);
|
||||
}
|
||||
return format!("({})", types.join("|"));
|
||||
}
|
||||
|
||||
if let Some(format) = schema.get("format").and_then(|f| f.as_str()) {
|
||||
return match format {
|
||||
"int32" | "int64" => "number".to_string(),
|
||||
"float" | "double" => "number".to_string(),
|
||||
"date" | "date-time" => "string".to_string(),
|
||||
_ => "*".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
"*".to_string()
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Code generators for client libraries.
|
||||
//!
|
||||
//! Each language has its own submodule with focused files:
|
||||
//! - `types.rs` - Type definitions
|
||||
//! - `client.rs` - Base client and pattern factories
|
||||
//! - `tree.rs` - Tree structure generation
|
||||
//! - `api.rs` - API method generation
|
||||
//! - `mod.rs` - Entry point
|
||||
|
||||
use std::{fmt::Write, fs, io, path::Path};
|
||||
|
||||
pub mod javascript;
|
||||
pub mod python;
|
||||
pub mod rust;
|
||||
|
||||
pub use javascript::generate_javascript_client;
|
||||
pub use python::generate_python_client;
|
||||
pub use rust::generate_rust_client;
|
||||
|
||||
/// Types that are manually defined as generics in client code, not from schema.
|
||||
pub const MANUAL_GENERIC_TYPES: &[&str] = &["SeriesData", "SeriesEndpoint"];
|
||||
|
||||
/// Write a multi-line description with the given prefix for each line.
|
||||
/// `empty_prefix` is used for blank lines (e.g., " *" without trailing space).
|
||||
pub fn write_description(output: &mut String, desc: &str, prefix: &str, empty_prefix: &str) {
|
||||
for line in desc.lines() {
|
||||
if line.is_empty() {
|
||||
writeln!(output, "{}", empty_prefix).unwrap();
|
||||
} else {
|
||||
writeln!(output, "{}{}", prefix, line).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace generic types with their Any variants in return types.
|
||||
/// Used by JS and Python generators.
|
||||
pub fn normalize_return_type(return_type: &str) -> String {
|
||||
let mut result = return_type.to_string();
|
||||
for type_name in MANUAL_GENERIC_TYPES {
|
||||
result = result.replace(type_name, &format!("Any{}", type_name));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Write content to a file only if it differs from existing content.
|
||||
/// Preserves mtime when unchanged, avoiding unnecessary cargo rebuilds.
|
||||
pub fn write_if_changed(path: &Path, content: &str) -> io::Result<()> {
|
||||
if let Ok(existing) = fs::read_to_string(path)
|
||||
&& existing == content
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
fs::write(path, content)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
//! Python API method generation.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::{
|
||||
Endpoint, Parameter, escape_python_keyword,
|
||||
generators::{normalize_return_type, write_description},
|
||||
to_snake_case,
|
||||
};
|
||||
|
||||
use super::client::generate_class_constants;
|
||||
use super::types::js_type_to_python;
|
||||
|
||||
/// Generate the main client class
|
||||
pub fn generate_main_client(output: &mut String, endpoints: &[Endpoint]) {
|
||||
writeln!(output, "class BrkClient(BrkClientBase):").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" \"\"\"Main BRK client with series tree and API methods.\"\"\""
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output).unwrap();
|
||||
|
||||
// Generate class-level constants
|
||||
generate_class_constants(output);
|
||||
|
||||
writeln!(
|
||||
output,
|
||||
" def __init__(self, base_url: str = 'http://localhost:3000', timeout: float = 30.0):"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " super().__init__(base_url, timeout)").unwrap();
|
||||
writeln!(output, " self.series = SeriesTree(self)").unwrap();
|
||||
writeln!(output).unwrap();
|
||||
|
||||
// Generate series_endpoint() method for dynamic series access
|
||||
writeln!(
|
||||
output,
|
||||
" def series_endpoint(self, series: str, index: Index) -> SeriesEndpoint[Any]:"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" \"\"\"Create a dynamic series endpoint builder for any series/index combination."
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output).unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" Use this for programmatic access when the series name is determined at runtime."
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" For type-safe access, use the `series` tree instead."
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " \"\"\"").unwrap();
|
||||
writeln!(output, " return SeriesEndpoint(self, series, index)").unwrap();
|
||||
writeln!(output).unwrap();
|
||||
|
||||
// Generate helper methods
|
||||
writeln!(
|
||||
output,
|
||||
" def index_to_date(self, index: Index, i: int) -> Union[date, datetime]:"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" \"\"\"Convert an index value to a date/datetime for date-based indexes.\"\"\""
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " return _index_to_date(index, i)").unwrap();
|
||||
writeln!(output).unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" def date_to_index(self, index: Index, d: Union[date, datetime]) -> int:"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" \"\"\"Convert a date/datetime to an index value for date-based indexes.\"\"\""
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " return _date_to_index(index, d)").unwrap();
|
||||
writeln!(output).unwrap();
|
||||
// Generate API methods
|
||||
generate_api_methods(output, endpoints);
|
||||
}
|
||||
|
||||
/// Generate API methods from OpenAPI endpoints
|
||||
pub fn generate_api_methods(output: &mut String, endpoints: &[Endpoint]) {
|
||||
for endpoint in endpoints {
|
||||
if !endpoint.should_generate() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let method_name = endpoint_to_method_name(endpoint);
|
||||
let base_return_type = if endpoint.returns_binary() {
|
||||
"bytes".to_string()
|
||||
} else {
|
||||
normalize_return_type(
|
||||
&endpoint
|
||||
.schema_name()
|
||||
.map(js_type_to_python)
|
||||
.unwrap_or_else(|| "str".to_string()),
|
||||
)
|
||||
};
|
||||
|
||||
let return_type = if endpoint.supports_csv {
|
||||
format!("Union[{}, str]", base_return_type)
|
||||
} else {
|
||||
base_return_type
|
||||
};
|
||||
|
||||
// Build method signature
|
||||
let params = build_method_params(endpoint);
|
||||
writeln!(
|
||||
output,
|
||||
" def {}(self{}) -> {}:",
|
||||
method_name, params, return_type
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Docstring
|
||||
match (&endpoint.summary, &endpoint.description) {
|
||||
(Some(summary), Some(desc)) if summary != desc => {
|
||||
writeln!(output, " \"\"\"{}.", summary.trim_end_matches('.')).unwrap();
|
||||
writeln!(output).unwrap();
|
||||
write_description(output, desc, " ", "");
|
||||
}
|
||||
(Some(summary), _) => {
|
||||
writeln!(output, " \"\"\"{}", summary).unwrap();
|
||||
}
|
||||
(None, Some(desc)) => {
|
||||
// First line includes opening quotes
|
||||
let mut lines = desc.lines();
|
||||
if let Some(first) = lines.next() {
|
||||
writeln!(output, " \"\"\"{}", first).unwrap();
|
||||
}
|
||||
for line in lines {
|
||||
if line.is_empty() {
|
||||
writeln!(output).unwrap();
|
||||
} else {
|
||||
writeln!(output, " {}", line).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, None) => {
|
||||
write!(output, " \"\"\"").unwrap();
|
||||
}
|
||||
}
|
||||
writeln!(output).unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" Endpoint: `{} {}`\"\"\"",
|
||||
endpoint.method.to_uppercase(),
|
||||
endpoint.path
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Build path
|
||||
let path = build_path_template(&endpoint.path, &endpoint.path_params);
|
||||
|
||||
let is_post = endpoint.method == "POST";
|
||||
let fetch_method = match (is_post, &endpoint.response_kind) {
|
||||
(false, _) if endpoint.returns_binary() => "get",
|
||||
(false, _) if endpoint.returns_json() => "get_json",
|
||||
(false, _) => "get_text",
|
||||
(true, _) if endpoint.returns_binary() => "post",
|
||||
(true, _) if endpoint.returns_json() => "post_json",
|
||||
(true, _) => "post_text",
|
||||
};
|
||||
|
||||
let body_arg = if is_post && endpoint.request_body.is_some() {
|
||||
", body"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let (wrap_prefix, wrap_suffix) = if endpoint.response_kind.text_is_numeric() {
|
||||
("int(", ")")
|
||||
} else {
|
||||
("", "")
|
||||
};
|
||||
|
||||
if endpoint.query_params.is_empty() {
|
||||
if endpoint.path_params.is_empty() {
|
||||
writeln!(
|
||||
output,
|
||||
" return {}self.{}('{}'{}){}",
|
||||
wrap_prefix, fetch_method, path, body_arg, wrap_suffix
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" return {}self.{}(f'{}'{}){}",
|
||||
wrap_prefix, fetch_method, path, body_arg, wrap_suffix
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
} else {
|
||||
writeln!(output, " params = []").unwrap();
|
||||
for param in &endpoint.query_params {
|
||||
// Use safe name for Python variable, original name for API query parameter
|
||||
let safe_name = escape_python_keyword(¶m.name);
|
||||
let is_array = param.param_type.ends_with("[]");
|
||||
if is_array {
|
||||
writeln!(
|
||||
output,
|
||||
" for _v in {}: params.append(f'{}={{_v}}')",
|
||||
safe_name, param.name
|
||||
)
|
||||
.unwrap();
|
||||
} else if param.required {
|
||||
writeln!(
|
||||
output,
|
||||
" params.append(f'{}={{{}}}')",
|
||||
param.name, safe_name
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" if {} is not None: params.append(f'{}={{{}}}')",
|
||||
safe_name, param.name, safe_name
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
writeln!(output, " query = '&'.join(params)").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" path = f'{}{{\"?\" + query if query else \"\"}}'",
|
||||
path
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if endpoint.supports_csv {
|
||||
writeln!(output, " if format == 'csv':").unwrap();
|
||||
writeln!(output, " return self.get_text(path)").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" return {}self.{}(path{}){}",
|
||||
wrap_prefix, fetch_method, body_arg, wrap_suffix
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" return {}self.{}(path{}){}",
|
||||
wrap_prefix, fetch_method, body_arg, wrap_suffix
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(output).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint_to_method_name(endpoint: &Endpoint) -> String {
|
||||
to_snake_case(&endpoint.operation_name())
|
||||
}
|
||||
|
||||
fn build_method_params(endpoint: &Endpoint) -> String {
|
||||
let mut params = Vec::new();
|
||||
// Path params are always required
|
||||
for param in &endpoint.path_params {
|
||||
let safe_name = escape_python_keyword(¶m.name);
|
||||
let py_type = js_type_to_python(¶m.param_type);
|
||||
params.push(format!(", {}: {}", safe_name, py_type));
|
||||
}
|
||||
// Required query params must come before optional ones (Python syntax requirement)
|
||||
for param in &endpoint.query_params {
|
||||
if param.required {
|
||||
let safe_name = escape_python_keyword(¶m.name);
|
||||
let py_type = js_type_to_python(¶m.param_type);
|
||||
params.push(format!(", {}: {}", safe_name, py_type));
|
||||
}
|
||||
}
|
||||
for param in &endpoint.query_params {
|
||||
if !param.required {
|
||||
let safe_name = escape_python_keyword(¶m.name);
|
||||
let py_type = js_type_to_python(¶m.param_type);
|
||||
params.push(format!(", {}: Optional[{}] = None", safe_name, py_type));
|
||||
}
|
||||
}
|
||||
if let Some(body) = &endpoint.request_body {
|
||||
let py_type = js_type_to_python(&body.body_type);
|
||||
if body.required {
|
||||
params.push(format!(", body: {}", py_type));
|
||||
} else {
|
||||
params.push(format!(", body: Optional[{}] = None", py_type));
|
||||
}
|
||||
}
|
||||
params.join("")
|
||||
}
|
||||
|
||||
fn build_path_template(path: &str, path_params: &[Parameter]) -> String {
|
||||
let mut result = path.to_string();
|
||||
for param in path_params {
|
||||
let placeholder = format!("{{{}}}", param.name);
|
||||
// Use escaped name for Python variable interpolation in f-string
|
||||
let safe_name = escape_python_keyword(¶m.name);
|
||||
let interpolation = format!("{{{}}}", safe_name);
|
||||
result = result.replace(&placeholder, &interpolation);
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
//! Python base client and pattern factory generation.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::{
|
||||
ClientConstants, ClientMetadata, CohortConstants, IndexSetPattern, PythonSyntax,
|
||||
StructuralPattern, format_json, generate_parameterized_field, index_to_field_name,
|
||||
};
|
||||
|
||||
/// Generate class-level constants for the BrkClient class.
|
||||
pub fn generate_class_constants(output: &mut String) {
|
||||
let constants = ClientConstants::collect();
|
||||
|
||||
// VERSION
|
||||
writeln!(output, " VERSION = \"{}\"\n", constants.version).unwrap();
|
||||
|
||||
// INDEXES, POOL_ID_TO_POOL_NAME
|
||||
write_class_const(output, "INDEXES", &format_json(&constants.indexes));
|
||||
// Python needs string keys for pool map
|
||||
let pool_map: std::collections::BTreeMap<String, &str> = constants
|
||||
.pool_map
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), *v))
|
||||
.collect();
|
||||
write_class_const(output, "POOL_ID_TO_POOL_NAME", &format_json(&pool_map));
|
||||
|
||||
// Cohort constants (no camelCase conversion for Python)
|
||||
for (name, value) in CohortConstants::all() {
|
||||
write_class_const(output, name, &format_json(&value));
|
||||
}
|
||||
}
|
||||
|
||||
fn write_class_const(output: &mut String, name: &str, json: &str) {
|
||||
let indented = json
|
||||
.lines()
|
||||
.enumerate()
|
||||
.map(|(i, line)| {
|
||||
if i == 0 {
|
||||
format!(" {} = {}", name, line)
|
||||
} else {
|
||||
format!(" {}", line)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
writeln!(output, "{}\n", indented).unwrap();
|
||||
}
|
||||
|
||||
/// Generate the base BrkClient class with HTTP functionality
|
||||
pub fn generate_base_client(output: &mut String) {
|
||||
writeln!(
|
||||
output,
|
||||
r#"class BrkError(Exception):
|
||||
"""Custom error class for BRK client errors."""
|
||||
|
||||
def __init__(self, message: str, status: Optional[int] = None):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
class BrkClientBase:
|
||||
"""Base HTTP client for making requests."""
|
||||
|
||||
def __init__(self, base_url: str, timeout: float = 30.0):
|
||||
parsed = urlparse(base_url)
|
||||
self._host = parsed.netloc
|
||||
self._secure = parsed.scheme == 'https'
|
||||
self._timeout = timeout
|
||||
self._conn: Optional[Union[HTTPSConnection, HTTPConnection]] = None
|
||||
|
||||
def _connect(self) -> Union[HTTPSConnection, HTTPConnection]:
|
||||
"""Get or create HTTP connection."""
|
||||
if self._conn is None:
|
||||
if self._secure:
|
||||
self._conn = HTTPSConnection(self._host, timeout=self._timeout)
|
||||
else:
|
||||
self._conn = HTTPConnection(self._host, timeout=self._timeout)
|
||||
return self._conn
|
||||
|
||||
def get(self, path: str) -> bytes:
|
||||
"""Make a GET request and return raw bytes."""
|
||||
try:
|
||||
conn = self._connect()
|
||||
conn.request("GET", path)
|
||||
res = conn.getresponse()
|
||||
data = res.read()
|
||||
if res.status >= 400:
|
||||
raise BrkError(f"HTTP error: {{res.status}}", res.status)
|
||||
return data
|
||||
except (ConnectionError, OSError, TimeoutError) as e:
|
||||
self._conn = None
|
||||
raise BrkError(str(e))
|
||||
|
||||
def get_json(self, path: str) -> Any:
|
||||
"""Make a GET request and return JSON."""
|
||||
return json.loads(self.get(path))
|
||||
|
||||
def get_text(self, path: str) -> str:
|
||||
"""Make a GET request and return text."""
|
||||
return self.get(path).decode()
|
||||
|
||||
def post(self, path: str, body: str) -> bytes:
|
||||
"""Make a POST request with a string body and return raw bytes."""
|
||||
try:
|
||||
conn = self._connect()
|
||||
conn.request("POST", path, body=body)
|
||||
res = conn.getresponse()
|
||||
data = res.read()
|
||||
if res.status >= 400:
|
||||
raise BrkError(f"HTTP error: {{res.status}}", res.status)
|
||||
return data
|
||||
except (ConnectionError, OSError, TimeoutError) as e:
|
||||
self._conn = None
|
||||
raise BrkError(str(e))
|
||||
|
||||
def post_json(self, path: str, body: str) -> Any:
|
||||
"""Make a POST request and return JSON."""
|
||||
return json.loads(self.post(path, body))
|
||||
|
||||
def post_text(self, path: str, body: str) -> str:
|
||||
"""Make a POST request and return text."""
|
||||
return self.post(path, body).decode()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the HTTP client."""
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
def __enter__(self) -> BrkClientBase:
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: Optional[type], exc_val: Optional[BaseException], exc_tb: Optional[Any]) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
def _m(acc: str, s: str) -> str:
|
||||
"""Build series name with suffix."""
|
||||
if not s: return acc
|
||||
return f"{{acc}}_{{s}}" if acc else s
|
||||
|
||||
|
||||
def _p(prefix: str, acc: str) -> str:
|
||||
"""Build series name with prefix."""
|
||||
return f"{{prefix}}_{{acc}}" if acc else prefix
|
||||
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Generate the SeriesData and SeriesEndpoint classes
|
||||
pub fn generate_endpoint_class(output: &mut String) {
|
||||
writeln!(
|
||||
output,
|
||||
r#"# Date conversion constants
|
||||
_GENESIS = date(2009, 1, 3) # day1 0, week1 0
|
||||
_DAY_ONE = date(2009, 1, 9) # day1 1 (6 day gap after genesis)
|
||||
_EPOCH = datetime(2009, 1, 1, tzinfo=timezone.utc)
|
||||
_DATE_INDEXES = frozenset([
|
||||
'minute10', 'minute30',
|
||||
'hour1', 'hour4', 'hour12',
|
||||
'day1', 'day3', 'week1',
|
||||
'month1', 'month3', 'month6',
|
||||
'year1', 'year10',
|
||||
])
|
||||
|
||||
def _index_to_date(index: str, i: int) -> Union[date, datetime]:
|
||||
"""Convert an index value to a date/datetime for date-based indexes."""
|
||||
if index == 'minute10':
|
||||
return _EPOCH + timedelta(minutes=i * 10)
|
||||
elif index == 'minute30':
|
||||
return _EPOCH + timedelta(minutes=i * 30)
|
||||
elif index == 'hour1':
|
||||
return _EPOCH + timedelta(hours=i)
|
||||
elif index == 'hour4':
|
||||
return _EPOCH + timedelta(hours=i * 4)
|
||||
elif index == 'hour12':
|
||||
return _EPOCH + timedelta(hours=i * 12)
|
||||
elif index == 'day1':
|
||||
return _GENESIS if i == 0 else _DAY_ONE + timedelta(days=i - 1)
|
||||
elif index == 'day3':
|
||||
return _EPOCH.date() - timedelta(days=1) + timedelta(days=i * 3)
|
||||
elif index == 'week1':
|
||||
return _GENESIS + timedelta(weeks=i)
|
||||
elif index == 'month1':
|
||||
return date(2009 + i // 12, i % 12 + 1, 1)
|
||||
elif index == 'month3':
|
||||
m = i * 3
|
||||
return date(2009 + m // 12, m % 12 + 1, 1)
|
||||
elif index == 'month6':
|
||||
m = i * 6
|
||||
return date(2009 + m // 12, m % 12 + 1, 1)
|
||||
elif index == 'year1':
|
||||
return date(2009 + i, 1, 1)
|
||||
elif index == 'year10':
|
||||
return date(2009 + i * 10, 1, 1)
|
||||
else:
|
||||
raise ValueError(f"{{index}} is not a date-based index")
|
||||
|
||||
|
||||
def _date_to_index(index: str, d: Union[date, datetime]) -> int:
|
||||
"""Convert a date/datetime to an index value for date-based indexes.
|
||||
|
||||
Returns the floor index (latest index whose date is <= the given date).
|
||||
For sub-day indexes (minute*, hour*), a plain date is treated as midnight UTC.
|
||||
"""
|
||||
if index in ('minute10', 'minute30', 'hour1', 'hour4', 'hour12'):
|
||||
if isinstance(d, datetime):
|
||||
dt = d if d.tzinfo else d.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
|
||||
secs = int((dt - _EPOCH).total_seconds())
|
||||
div = {{'minute10': 600, 'minute30': 1800,
|
||||
'hour1': 3600, 'hour4': 14400, 'hour12': 43200}}
|
||||
return secs // div[index]
|
||||
dd = d.date() if isinstance(d, datetime) else d
|
||||
if index == 'day1':
|
||||
if dd < _DAY_ONE:
|
||||
return 0
|
||||
return 1 + (dd - _DAY_ONE).days
|
||||
elif index == 'day3':
|
||||
return (dd - date(2008, 12, 31)).days // 3
|
||||
elif index == 'week1':
|
||||
return (dd - _GENESIS).days // 7
|
||||
elif index == 'month1':
|
||||
return (dd.year - 2009) * 12 + (dd.month - 1)
|
||||
elif index == 'month3':
|
||||
return (dd.year - 2009) * 4 + (dd.month - 1) // 3
|
||||
elif index == 'month6':
|
||||
return (dd.year - 2009) * 2 + (dd.month - 1) // 6
|
||||
elif index == 'year1':
|
||||
return dd.year - 2009
|
||||
elif index == 'year10':
|
||||
return (dd.year - 2009) // 10
|
||||
else:
|
||||
raise ValueError(f"{{index}} is not a date-based index")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeriesData(Generic[T]):
|
||||
"""Series data with range information. Always int-indexed."""
|
||||
version: int
|
||||
index: Index
|
||||
type: str
|
||||
start: int
|
||||
end: int
|
||||
stamp: str
|
||||
data: List[T]
|
||||
|
||||
@property
|
||||
def is_date_based(self) -> bool:
|
||||
"""Whether this series uses a date-based index."""
|
||||
return self.index in _DATE_INDEXES
|
||||
|
||||
def indexes(self) -> List[int]:
|
||||
"""Get raw index numbers."""
|
||||
return list(range(self.start, self.end))
|
||||
|
||||
def keys(self) -> List[int]:
|
||||
"""Get keys as index numbers."""
|
||||
return self.indexes()
|
||||
|
||||
def items(self) -> List[Tuple[int, T]]:
|
||||
"""Get (index, value) pairs."""
|
||||
return list(zip(self.indexes(), self.data))
|
||||
|
||||
def to_dict(self) -> Dict[int, T]:
|
||||
"""Return {{index: value}} dict."""
|
||||
return dict(zip(self.indexes(), self.data))
|
||||
|
||||
def __iter__(self) -> Iterator[Tuple[int, T]]:
|
||||
"""Iterate over (index, value) pairs."""
|
||||
return iter(zip(self.indexes(), self.data))
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.data)
|
||||
|
||||
def to_polars(self) -> pl.DataFrame:
|
||||
"""Convert to Polars DataFrame with 'index' and 'value' columns."""
|
||||
try:
|
||||
import polars as pl # type: ignore[import-not-found]
|
||||
except ImportError:
|
||||
raise ImportError("polars is required: pip install polars")
|
||||
return pl.DataFrame({{"index": self.indexes(), "value": self.data}})
|
||||
|
||||
def to_pandas(self) -> pd.DataFrame:
|
||||
"""Convert to Pandas DataFrame with 'index' and 'value' columns."""
|
||||
try:
|
||||
import pandas as pd # type: ignore[import-not-found]
|
||||
except ImportError:
|
||||
raise ImportError("pandas is required: pip install pandas")
|
||||
return pd.DataFrame({{"index": self.indexes(), "value": self.data}})
|
||||
|
||||
|
||||
@dataclass
|
||||
class DateSeriesData(SeriesData[T]):
|
||||
"""Series data with date-based index. Extends SeriesData with date methods."""
|
||||
|
||||
def dates(self) -> List[Union[date, datetime]]:
|
||||
"""Get dates for the index range. Returns datetime for sub-daily indexes, date for daily+."""
|
||||
return [_index_to_date(self.index, i) for i in range(self.start, self.end)]
|
||||
|
||||
def date_items(self) -> List[Tuple[Union[date, datetime], T]]:
|
||||
"""Get (date, value) pairs."""
|
||||
return list(zip(self.dates(), self.data))
|
||||
|
||||
def to_date_dict(self) -> Dict[Union[date, datetime], T]:
|
||||
"""Return {{date: value}} dict."""
|
||||
return dict(zip(self.dates(), self.data))
|
||||
|
||||
def to_polars(self, with_dates: bool = True) -> pl.DataFrame:
|
||||
"""Convert to Polars DataFrame.
|
||||
|
||||
Returns a DataFrame with columns:
|
||||
- 'date' and 'value' if with_dates=True (default)
|
||||
- 'index' and 'value' otherwise
|
||||
"""
|
||||
try:
|
||||
import polars as pl # type: ignore[import-not-found]
|
||||
except ImportError:
|
||||
raise ImportError("polars is required: pip install polars")
|
||||
if with_dates:
|
||||
return pl.DataFrame({{"date": self.dates(), "value": self.data}})
|
||||
return pl.DataFrame({{"index": self.indexes(), "value": self.data}})
|
||||
|
||||
def to_pandas(self, with_dates: bool = True) -> pd.DataFrame:
|
||||
"""Convert to Pandas DataFrame.
|
||||
|
||||
Returns a DataFrame with columns:
|
||||
- 'date' and 'value' if with_dates=True (default)
|
||||
- 'index' and 'value' otherwise
|
||||
"""
|
||||
try:
|
||||
import pandas as pd # type: ignore[import-not-found]
|
||||
except ImportError:
|
||||
raise ImportError("pandas is required: pip install pandas")
|
||||
if with_dates:
|
||||
return pd.DataFrame({{"date": self.dates(), "value": self.data}})
|
||||
return pd.DataFrame({{"index": self.indexes(), "value": self.data}})
|
||||
|
||||
|
||||
# Type aliases for non-generic usage
|
||||
AnySeriesData = SeriesData[Any]
|
||||
AnyDateSeriesData = DateSeriesData[Any]
|
||||
|
||||
|
||||
class _EndpointConfig:
|
||||
"""Shared endpoint configuration."""
|
||||
client: BrkClient
|
||||
name: str
|
||||
index: Index
|
||||
start: Optional[int]
|
||||
end: Optional[int]
|
||||
|
||||
def __init__(self, client: BrkClient, name: str, index: Index,
|
||||
start: Optional[int] = None, end: Optional[int] = None):
|
||||
self.client = client
|
||||
self.name = name
|
||||
self.index = index
|
||||
self.start = start
|
||||
self.end = end
|
||||
|
||||
def path(self) -> str:
|
||||
return f"/api/series/{{self.name}}/{{self.index}}"
|
||||
|
||||
def _build_path(self, format: Optional[str] = None) -> str:
|
||||
params = []
|
||||
if self.start is not None:
|
||||
params.append(f"start={{self.start}}")
|
||||
if self.end is not None:
|
||||
params.append(f"end={{self.end}}")
|
||||
if format is not None:
|
||||
params.append(f"format={{format}}")
|
||||
query = "&".join(params)
|
||||
p = self.path()
|
||||
return f"{{p}}?{{query}}" if query else p
|
||||
|
||||
def _new(self, start: Optional[int] = None, end: Optional[int] = None) -> _EndpointConfig:
|
||||
return _EndpointConfig(self.client, self.name, self.index, start, end)
|
||||
|
||||
def get_series(self) -> SeriesData[Any]:
|
||||
return SeriesData(**self.client.get_json(self._build_path()))
|
||||
|
||||
def get_date_series(self) -> DateSeriesData[Any]:
|
||||
return DateSeriesData(**self.client.get_json(self._build_path()))
|
||||
|
||||
def get_csv(self) -> str:
|
||||
return self.client.get_text(self._build_path(format='csv'))
|
||||
|
||||
def get_len(self) -> int:
|
||||
return self.client.get_series_len(self.name, self.index)
|
||||
|
||||
def get_version(self) -> Version:
|
||||
return self.client.get_series_version(self.name, self.index)
|
||||
|
||||
|
||||
class RangeBuilder(Generic[T]):
|
||||
"""Builder with range specified."""
|
||||
|
||||
def __init__(self, config: _EndpointConfig):
|
||||
self._config = config
|
||||
|
||||
def fetch(self) -> SeriesData[T]:
|
||||
"""Fetch the range as parsed JSON."""
|
||||
return self._config.get_series()
|
||||
|
||||
def fetch_csv(self) -> str:
|
||||
"""Fetch the range as CSV string."""
|
||||
return self._config.get_csv()
|
||||
|
||||
|
||||
class SingleItemBuilder(Generic[T]):
|
||||
"""Builder for single item access."""
|
||||
|
||||
def __init__(self, config: _EndpointConfig):
|
||||
self._config = config
|
||||
|
||||
def fetch(self) -> SeriesData[T]:
|
||||
"""Fetch the single item."""
|
||||
return self._config.get_series()
|
||||
|
||||
def fetch_csv(self) -> str:
|
||||
"""Fetch as CSV."""
|
||||
return self._config.get_csv()
|
||||
|
||||
|
||||
class SkippedBuilder(Generic[T]):
|
||||
"""Builder after calling skip(n). Chain with take() to specify count."""
|
||||
|
||||
def __init__(self, config: _EndpointConfig):
|
||||
self._config = config
|
||||
|
||||
def take(self, n: int) -> RangeBuilder[T]:
|
||||
"""Take n items after the skipped position."""
|
||||
start = self._config.start or 0
|
||||
return RangeBuilder(self._config._new(start, start + n))
|
||||
|
||||
def fetch(self) -> SeriesData[T]:
|
||||
"""Fetch from skipped position to end."""
|
||||
return self._config.get_series()
|
||||
|
||||
def fetch_csv(self) -> str:
|
||||
"""Fetch as CSV."""
|
||||
return self._config.get_csv()
|
||||
|
||||
|
||||
class DateRangeBuilder(RangeBuilder[T]):
|
||||
"""Range builder that returns DateSeriesData."""
|
||||
def fetch(self) -> DateSeriesData[T]:
|
||||
return self._config.get_date_series()
|
||||
|
||||
|
||||
class DateSingleItemBuilder(SingleItemBuilder[T]):
|
||||
"""Single item builder that returns DateSeriesData."""
|
||||
def fetch(self) -> DateSeriesData[T]:
|
||||
return self._config.get_date_series()
|
||||
|
||||
|
||||
class DateSkippedBuilder(SkippedBuilder[T]):
|
||||
"""Skipped builder that returns DateSeriesData."""
|
||||
def take(self, n: int) -> DateRangeBuilder[T]:
|
||||
start = self._config.start or 0
|
||||
return DateRangeBuilder(self._config._new(start, start + n))
|
||||
def fetch(self) -> DateSeriesData[T]:
|
||||
return self._config.get_date_series()
|
||||
|
||||
|
||||
class SeriesEndpoint(Generic[T]):
|
||||
"""Builder for series endpoint queries with int-based indexing.
|
||||
|
||||
Examples:
|
||||
data = endpoint.fetch()
|
||||
data = endpoint[5].fetch()
|
||||
data = endpoint[:10].fetch()
|
||||
data = endpoint.head(20).fetch()
|
||||
data = endpoint.skip(100).take(10).fetch()
|
||||
"""
|
||||
|
||||
def __init__(self, client: BrkClient, name: str, index: Index):
|
||||
self._config = _EndpointConfig(client, name, index)
|
||||
|
||||
@overload
|
||||
def __getitem__(self, key: int) -> SingleItemBuilder[T]: ...
|
||||
@overload
|
||||
def __getitem__(self, key: slice) -> RangeBuilder[T]: ...
|
||||
|
||||
def __getitem__(self, key: Union[int, slice]) -> Union[SingleItemBuilder[T], RangeBuilder[T]]:
|
||||
"""Access single item or slice by integer index."""
|
||||
if isinstance(key, int):
|
||||
return SingleItemBuilder(self._config._new(key, key + 1))
|
||||
return RangeBuilder(self._config._new(key.start, key.stop))
|
||||
|
||||
def head(self, n: int = 10) -> RangeBuilder[T]:
|
||||
"""Get the first n items."""
|
||||
return RangeBuilder(self._config._new(end=n))
|
||||
|
||||
def tail(self, n: int = 10) -> RangeBuilder[T]:
|
||||
"""Get the last n items."""
|
||||
return RangeBuilder(self._config._new(end=0) if n == 0 else self._config._new(start=-n))
|
||||
|
||||
def skip(self, n: int) -> SkippedBuilder[T]:
|
||||
"""Skip the first n items."""
|
||||
return SkippedBuilder(self._config._new(start=n))
|
||||
|
||||
def fetch(self) -> SeriesData[T]:
|
||||
"""Fetch all data."""
|
||||
return self._config.get_series()
|
||||
|
||||
def fetch_csv(self) -> str:
|
||||
"""Fetch all data as CSV."""
|
||||
return self._config.get_csv()
|
||||
|
||||
def len(self) -> int:
|
||||
"""Total number of data points for this series."""
|
||||
return self._config.get_len()
|
||||
|
||||
def version(self) -> Version:
|
||||
"""Current version of the series."""
|
||||
return self._config.get_version()
|
||||
|
||||
def path(self) -> str:
|
||||
"""Get the base endpoint path."""
|
||||
return self._config.path()
|
||||
|
||||
|
||||
class DateSeriesEndpoint(Generic[T]):
|
||||
"""Builder for series endpoint queries with date-based indexing.
|
||||
|
||||
Accepts dates in __getitem__ and returns DateSeriesData from fetch().
|
||||
|
||||
Examples:
|
||||
data = endpoint.fetch()
|
||||
data = endpoint[date(2020, 1, 1)].fetch()
|
||||
data = endpoint[date(2020, 1, 1):date(2023, 1, 1)].fetch()
|
||||
data = endpoint[:10].fetch()
|
||||
"""
|
||||
|
||||
def __init__(self, client: BrkClient, name: str, index: Index):
|
||||
self._config = _EndpointConfig(client, name, index)
|
||||
|
||||
@overload
|
||||
def __getitem__(self, key: int) -> DateSingleItemBuilder[T]: ...
|
||||
@overload
|
||||
def __getitem__(self, key: datetime) -> DateSingleItemBuilder[T]: ...
|
||||
@overload
|
||||
def __getitem__(self, key: date) -> DateSingleItemBuilder[T]: ...
|
||||
@overload
|
||||
def __getitem__(self, key: slice) -> DateRangeBuilder[T]: ...
|
||||
|
||||
def __getitem__(self, key: Union[int, slice, date, datetime]) -> Union[DateSingleItemBuilder[T], DateRangeBuilder[T]]:
|
||||
"""Access single item or slice. Accepts int, date, or datetime."""
|
||||
if isinstance(key, (date, datetime)):
|
||||
idx = _date_to_index(self._config.index, key)
|
||||
return DateSingleItemBuilder(self._config._new(idx, idx + 1))
|
||||
if isinstance(key, int):
|
||||
return DateSingleItemBuilder(self._config._new(key, key + 1))
|
||||
start, stop = key.start, key.stop
|
||||
if isinstance(start, (date, datetime)):
|
||||
start = _date_to_index(self._config.index, start)
|
||||
if isinstance(stop, (date, datetime)):
|
||||
stop = _date_to_index(self._config.index, stop)
|
||||
return DateRangeBuilder(self._config._new(start, stop))
|
||||
|
||||
def head(self, n: int = 10) -> DateRangeBuilder[T]:
|
||||
"""Get the first n items."""
|
||||
return DateRangeBuilder(self._config._new(end=n))
|
||||
|
||||
def tail(self, n: int = 10) -> DateRangeBuilder[T]:
|
||||
"""Get the last n items."""
|
||||
return DateRangeBuilder(self._config._new(end=0) if n == 0 else self._config._new(start=-n))
|
||||
|
||||
def skip(self, n: int) -> DateSkippedBuilder[T]:
|
||||
"""Skip the first n items."""
|
||||
return DateSkippedBuilder(self._config._new(start=n))
|
||||
|
||||
def fetch(self) -> DateSeriesData[T]:
|
||||
"""Fetch all data."""
|
||||
return self._config.get_date_series()
|
||||
|
||||
def fetch_csv(self) -> str:
|
||||
"""Fetch all data as CSV."""
|
||||
return self._config.get_csv()
|
||||
|
||||
def len(self) -> int:
|
||||
"""Total number of data points for this series."""
|
||||
return self._config.get_len()
|
||||
|
||||
def version(self) -> Version:
|
||||
"""Current version of the series."""
|
||||
return self._config.get_version()
|
||||
|
||||
def path(self) -> str:
|
||||
"""Get the base endpoint path."""
|
||||
return self._config.path()
|
||||
|
||||
|
||||
# Type aliases for non-generic usage
|
||||
AnySeriesEndpoint = SeriesEndpoint[Any]
|
||||
AnyDateSeriesEndpoint = DateSeriesEndpoint[Any]
|
||||
|
||||
|
||||
class SeriesPattern(Protocol[T]):
|
||||
"""Protocol for series patterns with different index sets."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get the series name."""
|
||||
...
|
||||
|
||||
def indexes(self) -> List[str]:
|
||||
"""Get the list of available indexes for this series."""
|
||||
...
|
||||
|
||||
def get(self, index: Index) -> Optional[SeriesEndpoint[T]]:
|
||||
"""Get an endpoint builder for a specific index, if supported."""
|
||||
...
|
||||
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Generate index accessor classes
|
||||
pub fn generate_index_accessors(output: &mut String, patterns: &[IndexSetPattern]) {
|
||||
if patterns.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate static index tuples
|
||||
writeln!(output, "# Static index tuples").unwrap();
|
||||
for (i, pattern) in patterns.iter().enumerate() {
|
||||
write!(output, "_i{} = (", i + 1).unwrap();
|
||||
for (j, index) in pattern.indexes.iter().enumerate() {
|
||||
if j > 0 {
|
||||
write!(output, ", ").unwrap();
|
||||
}
|
||||
write!(output, "'{}'", index.name()).unwrap();
|
||||
}
|
||||
// Single-element tuple needs trailing comma
|
||||
if pattern.indexes.len() == 1 {
|
||||
write!(output, ",").unwrap();
|
||||
}
|
||||
writeln!(output, ")").unwrap();
|
||||
}
|
||||
writeln!(output).unwrap();
|
||||
|
||||
// Generate helper functions
|
||||
writeln!(
|
||||
output,
|
||||
r#"def _ep(c: BrkClient, n: str, i: Index) -> SeriesEndpoint[Any]:
|
||||
return SeriesEndpoint(c, n, i)
|
||||
|
||||
def _dep(c: BrkClient, n: str, i: Index) -> DateSeriesEndpoint[Any]:
|
||||
return DateSeriesEndpoint(c, n, i)
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
writeln!(output, "# Index accessor classes\n").unwrap();
|
||||
|
||||
for (i, pattern) in patterns.iter().enumerate() {
|
||||
let by_class_name = format!("_{}By", pattern.name);
|
||||
let idx_var = format!("_i{}", i + 1);
|
||||
|
||||
// Generate the By class with compact methods
|
||||
writeln!(output, "class {}(Generic[T]):", by_class_name).unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" def __init__(self, c: BrkClient, n: str): self._c, self._n = c, n"
|
||||
)
|
||||
.unwrap();
|
||||
for index in &pattern.indexes {
|
||||
let method_name = index_to_field_name(index);
|
||||
let index_name = index.name();
|
||||
let (builder_type, helper) = if index.is_date_based() {
|
||||
("DateSeriesEndpoint", "_dep")
|
||||
} else {
|
||||
("SeriesEndpoint", "_ep")
|
||||
};
|
||||
writeln!(
|
||||
output,
|
||||
" def {}(self) -> {}[T]: return {}(self._c, self._n, '{}')",
|
||||
method_name, builder_type, helper, index_name
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(output).unwrap();
|
||||
|
||||
// Generate the main accessor class
|
||||
writeln!(output, "class {}(Generic[T]):", pattern.name).unwrap();
|
||||
writeln!(output, " by: {}[T]", by_class_name).unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" def __init__(self, c: BrkClient, n: str): self._n, self.by = n, {}(c, n)",
|
||||
by_class_name
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " @property").unwrap();
|
||||
writeln!(output, " def name(self) -> str: return self._n").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" def indexes(self) -> List[str]: return list({})",
|
||||
idx_var
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" def get(self, index: Index) -> Optional[SeriesEndpoint[T]]: return _ep(self.by._c, self._n, index) if index in {} else None",
|
||||
idx_var
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate structural pattern classes
|
||||
pub fn generate_structural_patterns(
|
||||
output: &mut String,
|
||||
patterns: &[StructuralPattern],
|
||||
metadata: &ClientMetadata,
|
||||
) {
|
||||
if patterns.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
writeln!(output, "# Reusable structural pattern classes\n").unwrap();
|
||||
|
||||
for pattern in patterns {
|
||||
// Generate class
|
||||
if pattern.is_generic {
|
||||
writeln!(output, "class {}(Generic[T]):", pattern.name).unwrap();
|
||||
} else {
|
||||
writeln!(output, "class {}:", pattern.name).unwrap();
|
||||
}
|
||||
writeln!(
|
||||
output,
|
||||
" \"\"\"Pattern struct for repeated tree structure.\"\"\""
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Skip constructor for non-parameterizable patterns (inlined at tree level)
|
||||
if !metadata.is_parameterizable(&pattern.name) {
|
||||
writeln!(output, " pass\n").unwrap();
|
||||
continue;
|
||||
}
|
||||
|
||||
writeln!(output, " ").unwrap();
|
||||
if pattern.is_templated() {
|
||||
writeln!(
|
||||
output,
|
||||
" def __init__(self, client: BrkClient, acc: str, disc: str):"
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" def __init__(self, client: BrkClient, acc: str):"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(
|
||||
output,
|
||||
" \"\"\"Create pattern node with accumulated series name.\"\"\""
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let syntax = PythonSyntax;
|
||||
for field in &pattern.fields {
|
||||
generate_parameterized_field(output, &syntax, field, pattern, metadata, " ");
|
||||
}
|
||||
|
||||
writeln!(output).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Python client generation.
|
||||
//!
|
||||
//! This module generates a Python client with type hints for the BRK API.
|
||||
|
||||
pub mod api;
|
||||
pub mod client;
|
||||
pub mod tree;
|
||||
pub mod types;
|
||||
|
||||
use std::{fmt::Write, io, path::Path};
|
||||
|
||||
use super::write_if_changed;
|
||||
use crate::{ClientMetadata, Endpoint, TypeSchemas};
|
||||
|
||||
/// Generate Python client from metadata and OpenAPI endpoints.
|
||||
///
|
||||
/// `output_path` is the full path to the output file (e.g., "packages/brk_client/__init__.py").
|
||||
pub fn generate_python_client(
|
||||
metadata: &ClientMetadata,
|
||||
endpoints: &[Endpoint],
|
||||
schemas: &TypeSchemas,
|
||||
output_path: &Path,
|
||||
) -> io::Result<()> {
|
||||
let mut output = String::new();
|
||||
|
||||
writeln!(output, "# Auto-generated BRK Python client").unwrap();
|
||||
writeln!(output, "# Do not edit manually\n").unwrap();
|
||||
writeln!(output, "from __future__ import annotations").unwrap();
|
||||
writeln!(output, "from dataclasses import dataclass").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
"from typing import TypeVar, Generic, Any, Dict, Optional, List, Iterator, Literal, TypedDict, Union, Protocol, overload, Tuple, TYPE_CHECKING"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
"from http.client import HTTPSConnection, HTTPConnection"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, "from urllib.parse import urlparse").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
"from datetime import date, datetime, timedelta, timezone"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, "import json\n").unwrap();
|
||||
writeln!(output, "if TYPE_CHECKING:").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" import pandas as pd # type: ignore[import-not-found]"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" import polars as pl # type: ignore[import-not-found]\n"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, "T = TypeVar('T')\n").unwrap();
|
||||
|
||||
types::generate_type_definitions(&mut output, schemas);
|
||||
client::generate_base_client(&mut output);
|
||||
client::generate_endpoint_class(&mut output);
|
||||
client::generate_index_accessors(&mut output, &metadata.index_set_patterns);
|
||||
client::generate_structural_patterns(&mut output, &metadata.structural_patterns, metadata);
|
||||
tree::generate_tree_classes(&mut output, &metadata.catalog, metadata);
|
||||
api::generate_main_client(&mut output, endpoints);
|
||||
|
||||
write_if_changed(output_path, &output)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Python tree structure generation.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt::Write;
|
||||
|
||||
use brk_types::TreeNode;
|
||||
|
||||
use crate::{
|
||||
ClientMetadata, LanguageSyntax, PatternField, PythonSyntax, build_child_path,
|
||||
generate_leaf_field, generate_tree_node_field, prepare_tree_node,
|
||||
};
|
||||
|
||||
/// Generate tree classes
|
||||
pub fn generate_tree_classes(output: &mut String, catalog: &TreeNode, metadata: &ClientMetadata) {
|
||||
writeln!(output, "# Series tree classes\n").unwrap();
|
||||
|
||||
let pattern_lookup = metadata.pattern_lookup();
|
||||
let mut generated = BTreeSet::new();
|
||||
generate_tree_class(
|
||||
output,
|
||||
"SeriesTree",
|
||||
"",
|
||||
catalog,
|
||||
pattern_lookup,
|
||||
metadata,
|
||||
&mut generated,
|
||||
);
|
||||
}
|
||||
|
||||
/// Recursively generate tree classes
|
||||
fn generate_tree_class(
|
||||
output: &mut String,
|
||||
name: &str,
|
||||
path: &str,
|
||||
node: &TreeNode,
|
||||
pattern_lookup: &std::collections::BTreeMap<Vec<PatternField>, String>,
|
||||
metadata: &ClientMetadata,
|
||||
generated: &mut BTreeSet<String>,
|
||||
) {
|
||||
let Some(ctx) = prepare_tree_node(node, name, path, pattern_lookup, metadata, generated) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Generate child classes FIRST (post-order traversal)
|
||||
// This ensures children are defined before parent references them
|
||||
for child in &ctx.children {
|
||||
if child.should_inline {
|
||||
let child_path = build_child_path(path, child.name);
|
||||
generate_tree_class(
|
||||
output,
|
||||
&child.inline_type_name,
|
||||
&child_path,
|
||||
child.node,
|
||||
pattern_lookup,
|
||||
metadata,
|
||||
generated,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// THEN generate the current class (after all children are defined)
|
||||
writeln!(output, "class {}:", name).unwrap();
|
||||
writeln!(output, " \"\"\"Series tree node.\"\"\"").unwrap();
|
||||
writeln!(output, " ").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" def __init__(self, client: BrkClient, base_path: str = ''):"
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if ctx.children.is_empty() {
|
||||
writeln!(output, " pass").unwrap();
|
||||
}
|
||||
|
||||
let syntax = PythonSyntax;
|
||||
for child in &ctx.children {
|
||||
if child.is_leaf {
|
||||
if let TreeNode::Leaf(leaf) = child.node {
|
||||
generate_leaf_field(
|
||||
output, &syntax, "client", child.name, leaf, metadata, " ",
|
||||
);
|
||||
}
|
||||
} else if child.should_inline {
|
||||
let field_name = syntax.field_name(child.name);
|
||||
writeln!(
|
||||
output,
|
||||
" self.{}: {} = {}(client)",
|
||||
field_name, child.inline_type_name, child.inline_type_name
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
generate_tree_node_field(
|
||||
output,
|
||||
&syntax,
|
||||
&child.field,
|
||||
metadata,
|
||||
" ",
|
||||
"client",
|
||||
&child.base_result,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(output).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//! Python type definitions generation.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt::Write;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
TypeSchemas, escape_python_keyword, generators::MANUAL_GENERIC_TYPES, get_union_variants,
|
||||
ref_to_type_name,
|
||||
};
|
||||
|
||||
/// Generate type definitions from schemas.
|
||||
pub fn generate_type_definitions(output: &mut String, schemas: &TypeSchemas) {
|
||||
if schemas.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
writeln!(output, "# Type definitions\n").unwrap();
|
||||
|
||||
let sorted_names = topological_sort_schemas(schemas);
|
||||
|
||||
// Partition into simple type aliases and TypedDict classes
|
||||
// Generate type aliases first to avoid forward reference issues
|
||||
let (type_aliases, typed_dicts): (Vec<_>, Vec<_>) = sorted_names
|
||||
.into_iter()
|
||||
.filter(|name| !MANUAL_GENERIC_TYPES.contains(&name.as_str()))
|
||||
.filter(|name| schemas.contains_key(name))
|
||||
.partition(|name| {
|
||||
schemas
|
||||
.get(name)
|
||||
.map(|s| s.get("properties").is_none())
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
// Generate simple type aliases first
|
||||
// Quote references to TypedDicts since they're defined after
|
||||
let typed_dict_set: BTreeSet<_> = typed_dicts.iter().cloned().collect();
|
||||
for name in type_aliases {
|
||||
let schema = &schemas[&name];
|
||||
let type_desc = schema.get("description").and_then(|d| d.as_str());
|
||||
let py_type = schema_to_python_type(schema, Some(&name), Some(&typed_dict_set));
|
||||
if let Some(desc) = type_desc {
|
||||
for line in desc.lines() {
|
||||
writeln!(output, "# {}", line).unwrap();
|
||||
}
|
||||
}
|
||||
writeln!(output, "{} = {}", name, py_type).unwrap();
|
||||
}
|
||||
|
||||
// Then generate TypedDict classes
|
||||
for name in typed_dicts {
|
||||
let schema = &schemas[&name];
|
||||
let type_desc = schema.get("description").and_then(|d| d.as_str());
|
||||
let props = schema
|
||||
.get("properties")
|
||||
.and_then(|p| p.as_object())
|
||||
.unwrap();
|
||||
|
||||
writeln!(output, "class {}(TypedDict):", name).unwrap();
|
||||
|
||||
// Collect field descriptions for Attributes section
|
||||
let field_docs: Vec<(String, Option<&str>)> = props
|
||||
.iter()
|
||||
.map(|(prop_name, prop_schema)| {
|
||||
let safe_name = escape_python_keyword(prop_name);
|
||||
let desc = prop_schema.get("description").and_then(|d| d.as_str());
|
||||
(safe_name, desc)
|
||||
})
|
||||
.collect();
|
||||
let has_field_docs = field_docs.iter().any(|(_, d)| d.is_some());
|
||||
|
||||
// Generate docstring if we have type description or field descriptions
|
||||
if type_desc.is_some() || has_field_docs {
|
||||
writeln!(output, " \"\"\"").unwrap();
|
||||
if let Some(desc) = type_desc {
|
||||
for line in desc.lines() {
|
||||
writeln!(output, " {}", line).unwrap();
|
||||
}
|
||||
}
|
||||
if has_field_docs {
|
||||
if type_desc.is_some() {
|
||||
writeln!(output).unwrap();
|
||||
}
|
||||
writeln!(output, " Attributes:").unwrap();
|
||||
for (field_name, desc) in &field_docs {
|
||||
if let Some(d) = desc {
|
||||
writeln!(output, " {}: {}", field_name, d).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
writeln!(output, " \"\"\"").unwrap();
|
||||
}
|
||||
|
||||
for (prop_name, prop_schema) in props {
|
||||
let prop_type = schema_to_python_type(prop_schema, Some(&name), None);
|
||||
let safe_name = escape_python_keyword(prop_name);
|
||||
writeln!(output, " {}: {}", safe_name, prop_type).unwrap();
|
||||
}
|
||||
writeln!(output).unwrap();
|
||||
}
|
||||
writeln!(output).unwrap();
|
||||
}
|
||||
|
||||
/// Topologically sort schema names so dependencies come before dependents (avoids forward references).
|
||||
/// Types that reference other types (via $ref) must be defined after their dependencies.
|
||||
fn topological_sort_schemas(schemas: &TypeSchemas) -> Vec<String> {
|
||||
// Build dependency graph
|
||||
let mut deps: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
||||
for (name, schema) in schemas {
|
||||
let mut type_deps = BTreeSet::new();
|
||||
collect_schema_refs(schema, &mut type_deps);
|
||||
// Only keep deps that are in our schemas, and drop self-references
|
||||
// (handled at emit time by quoting via current_type)
|
||||
type_deps.retain(|d| schemas.contains_key(d) && d != name);
|
||||
deps.insert(name.clone(), type_deps);
|
||||
}
|
||||
|
||||
// Kahn's algorithm for topological sort
|
||||
let mut in_degree: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for name in schemas.keys() {
|
||||
in_degree.insert(name.clone(), 0);
|
||||
}
|
||||
for type_deps in deps.values() {
|
||||
for dep in type_deps {
|
||||
*in_degree.entry(dep.clone()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Start with types that have no dependents (are not referenced by others)
|
||||
let mut queue: Vec<String> = in_degree
|
||||
.iter()
|
||||
.filter(|(_, count)| **count == 0)
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect();
|
||||
queue.sort(); // Deterministic order
|
||||
|
||||
let mut result = Vec::new();
|
||||
while let Some(name) = queue.pop() {
|
||||
result.push(name.clone());
|
||||
if let Some(type_deps) = deps.get(&name) {
|
||||
for dep in type_deps {
|
||||
if let Some(count) = in_degree.get_mut(dep) {
|
||||
*count = count.saturating_sub(1);
|
||||
if *count == 0 {
|
||||
queue.push(dep.clone());
|
||||
queue.sort(); // Keep sorted for determinism
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse so dependencies come first
|
||||
result.reverse();
|
||||
|
||||
// Add any types that weren't processed (e.g., due to circular refs or other edge cases)
|
||||
let result_set: BTreeSet<_> = result.iter().cloned().collect();
|
||||
let mut missing: Vec<_> = schemas
|
||||
.keys()
|
||||
.filter(|k| !result_set.contains(*k))
|
||||
.cloned()
|
||||
.collect();
|
||||
missing.sort();
|
||||
result.extend(missing);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Collect all type references ($ref) from a schema
|
||||
fn collect_schema_refs(schema: &Value, refs: &mut BTreeSet<String>) {
|
||||
match schema {
|
||||
Value::Object(map) => {
|
||||
if let Some(ref_path) = map.get("$ref").and_then(|r| r.as_str())
|
||||
&& let Some(type_name) = ref_to_type_name(ref_path)
|
||||
{
|
||||
refs.insert(type_name.to_string());
|
||||
}
|
||||
for value in map.values() {
|
||||
collect_schema_refs(value, refs);
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
for item in arr {
|
||||
collect_schema_refs(item, refs);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a single JSON type string to Python type
|
||||
fn json_type_to_python(ty: &str, schema: &Value, current_type: Option<&str>) -> String {
|
||||
match ty {
|
||||
"integer" => "int".to_string(),
|
||||
"number" => "float".to_string(),
|
||||
"boolean" => "bool".to_string(),
|
||||
"string" => "str".to_string(),
|
||||
"null" => "None".to_string(),
|
||||
"array" => {
|
||||
let item_type = schema
|
||||
.get("items")
|
||||
.map(|s| schema_to_python_type(s, current_type, None))
|
||||
.unwrap_or_else(|| "Any".to_string());
|
||||
format!("List[{}]", item_type)
|
||||
}
|
||||
"object" => {
|
||||
if let Some(add_props) = schema.get("additionalProperties") {
|
||||
let value_type = schema_to_python_type(add_props, current_type, None);
|
||||
return format!("dict[str, {}]", value_type);
|
||||
}
|
||||
"dict".to_string()
|
||||
}
|
||||
_ => "Any".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert JSON Schema to Python type.
|
||||
///
|
||||
/// - `current_type`: Used to detect and quote self-references for recursive types
|
||||
/// - `quote_types`: Optional set of additional type names that should be quoted
|
||||
pub fn schema_to_python_type(
|
||||
schema: &Value,
|
||||
current_type: Option<&str>,
|
||||
quote_types: Option<&BTreeSet<String>>,
|
||||
) -> String {
|
||||
if let Some(all_of) = schema.get("allOf").and_then(|v| v.as_array()) {
|
||||
for item in all_of {
|
||||
let resolved = schema_to_python_type(item, current_type, quote_types);
|
||||
if resolved != "Any" {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle $ref
|
||||
if let Some(ref_path) = schema.get("$ref").and_then(|r| r.as_str()) {
|
||||
let type_name = ref_to_type_name(ref_path).unwrap_or("Any");
|
||||
// Quote self-references or types in quote_types set
|
||||
let should_quote =
|
||||
current_type == Some(type_name) || quote_types.is_some_and(|qt| qt.contains(type_name));
|
||||
if should_quote {
|
||||
return format!("\"{}\"", type_name);
|
||||
}
|
||||
return type_name.to_string();
|
||||
}
|
||||
|
||||
// Handle enum (array of string values)
|
||||
if let Some(enum_values) = schema.get("enum").and_then(|e| e.as_array()) {
|
||||
let literals: Vec<String> = enum_values
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|s| format!("\"{}\"", s))
|
||||
.collect();
|
||||
if !literals.is_empty() {
|
||||
return format!("Literal[{}]", literals.join(", "));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ty) = schema.get("type") {
|
||||
if let Some(type_array) = ty.as_array() {
|
||||
let types: Vec<String> = type_array
|
||||
.iter()
|
||||
.filter_map(|t| t.as_str())
|
||||
.filter(|t| *t != "null")
|
||||
.map(|t| json_type_to_python(t, schema, current_type))
|
||||
.collect();
|
||||
let has_null = type_array.iter().any(|t| t.as_str() == Some("null"));
|
||||
|
||||
if types.len() == 1 {
|
||||
let base_type = &types[0];
|
||||
return if has_null {
|
||||
format!("Optional[{}]", base_type)
|
||||
} else {
|
||||
base_type.clone()
|
||||
};
|
||||
} else if !types.is_empty() {
|
||||
let union = format!("Union[{}]", types.join(", "));
|
||||
return if has_null {
|
||||
format!("Optional[{}]", union)
|
||||
} else {
|
||||
union
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ty_str) = ty.as_str() {
|
||||
return json_type_to_python(ty_str, schema, current_type);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(variants) = get_union_variants(schema) {
|
||||
let types: Vec<String> = variants
|
||||
.iter()
|
||||
.map(|v| schema_to_python_type(v, current_type, quote_types))
|
||||
.collect();
|
||||
let filtered: Vec<_> = types.iter().filter(|t| *t != "Any").collect();
|
||||
if !filtered.is_empty() {
|
||||
return format!(
|
||||
"Union[{}]",
|
||||
filtered
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
}
|
||||
return format!("Union[{}]", types.join(", "));
|
||||
}
|
||||
|
||||
// Check for format hint without type (common in OpenAPI)
|
||||
if let Some(format) = schema.get("format").and_then(|f| f.as_str()) {
|
||||
return match format {
|
||||
"int32" | "int64" => "int".to_string(),
|
||||
"float" | "double" => "float".to_string(),
|
||||
"date" | "date-time" => "str".to_string(),
|
||||
_ => "Any".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
"Any".to_string()
|
||||
}
|
||||
|
||||
/// Convert JS-style type to Python type (e.g., "Txid[]" -> "List[Txid]", "integer" -> "int")
|
||||
pub fn js_type_to_python(js_type: &str) -> String {
|
||||
if let Some(inner) = js_type.strip_suffix("[]") {
|
||||
format!("List[{}]", js_type_to_python(inner))
|
||||
} else {
|
||||
match js_type {
|
||||
"integer" => "int".to_string(),
|
||||
"number" => "float".to_string(),
|
||||
"boolean" => "bool".to_string(),
|
||||
"string" => "str".to_string(),
|
||||
"null" => "None".to_string(),
|
||||
"Object" | "object" => "dict".to_string(),
|
||||
"*" => "Any".to_string(),
|
||||
_ => js_type.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||