mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-22 12:23:04 -07:00
Compare commits
656 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe8f095434 | |||
| 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 |
@@ -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,296 +0,0 @@
|
||||
# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist
|
||||
#
|
||||
# Copyright 2022-2024, axodotdev
|
||||
# SPDX-License-Identifier: MIT or Apache-2.0
|
||||
#
|
||||
# CI that:
|
||||
#
|
||||
# * checks for a Git Tag that looks like a release
|
||||
# * builds artifacts with dist (archives, installers, hashes)
|
||||
# * uploads those artifacts to temporary workflow zip
|
||||
# * on success, uploads the artifacts to a GitHub Release
|
||||
#
|
||||
# Note that the GitHub Release will be created with a generated
|
||||
# title/body based on your changelogs.
|
||||
|
||||
name: Release
|
||||
permissions:
|
||||
"contents": "write"
|
||||
|
||||
# This task will run whenever you push a git tag that looks like a version
|
||||
# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc.
|
||||
# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where
|
||||
# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION
|
||||
# must be a Cargo-style SemVer Version (must have at least major.minor.patch).
|
||||
#
|
||||
# If PACKAGE_NAME is specified, then the announcement will be for that
|
||||
# package (erroring out if it doesn't have the given version or isn't dist-able).
|
||||
#
|
||||
# If PACKAGE_NAME isn't specified, then the announcement will be for all
|
||||
# (dist-able) packages in the workspace with that version (this mode is
|
||||
# intended for workspaces with only one dist-able package, or with all dist-able
|
||||
# packages versioned/released in lockstep).
|
||||
#
|
||||
# If you push multiple tags at once, separate instances of this workflow will
|
||||
# spin up, creating an independent announcement for each one. However, GitHub
|
||||
# will hard limit this to 3 tags per commit, as it will assume more tags is a
|
||||
# mistake.
|
||||
#
|
||||
# If there's a prerelease-style suffix to the version, then the release(s)
|
||||
# will be marked as a prerelease.
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
tags:
|
||||
- '**[0-9]+.[0-9]+.[0-9]+*'
|
||||
|
||||
jobs:
|
||||
# Run 'dist plan' (or host) to determine what tasks we need to do
|
||||
plan:
|
||||
runs-on: "ubuntu-22.04"
|
||||
outputs:
|
||||
val: ${{ steps.plan.outputs.manifest }}
|
||||
tag: ${{ !github.event.pull_request && github.ref_name || '' }}
|
||||
tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }}
|
||||
publishing: ${{ !github.event.pull_request }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install dist
|
||||
# we specify bash to get pipefail; it guards against the `curl` command
|
||||
# failing. otherwise `sh` won't catch that `curl` returned non-0
|
||||
shell: bash
|
||||
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.2/cargo-dist-installer.sh | sh"
|
||||
- name: Cache dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/dist
|
||||
# sure would be cool if github gave us proper conditionals...
|
||||
# so here's a doubly-nested ternary-via-truthiness to try to provide the best possible
|
||||
# functionality based on whether this is a pull_request, and whether it's from a fork.
|
||||
# (PRs run on the *source* but secrets are usually on the *target* -- that's *good*
|
||||
# but also really annoying to build CI around when it needs secrets to work right.)
|
||||
- id: plan
|
||||
run: |
|
||||
dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json
|
||||
echo "dist ran successfully"
|
||||
cat plan-dist-manifest.json
|
||||
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||
- name: "Upload dist-manifest.json"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artifacts-plan-dist-manifest
|
||||
path: plan-dist-manifest.json
|
||||
|
||||
# Build and packages all the platform-specific things
|
||||
build-local-artifacts:
|
||||
name: build-local-artifacts (${{ join(matrix.targets, ', ') }})
|
||||
# Let the initial task tell us to not run (currently very blunt)
|
||||
needs:
|
||||
- plan
|
||||
if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# Target platforms/runners are computed by dist in create-release.
|
||||
# Each member of the matrix has the following arguments:
|
||||
#
|
||||
# - runner: the github runner
|
||||
# - dist-args: cli flags to pass to dist
|
||||
# - install-dist: expression to run to install dist on the runner
|
||||
#
|
||||
# Typically there will be:
|
||||
# - 1 "global" task that builds universal installers
|
||||
# - N "local" tasks that build each platform's binaries and platform-specific installers
|
||||
matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
container: ${{ matrix.container && matrix.container.image || null }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json
|
||||
steps:
|
||||
- name: enable windows longpaths
|
||||
run: |
|
||||
git config --global core.longpaths true
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install Rust non-interactively if not already installed
|
||||
if: ${{ matrix.container }}
|
||||
run: |
|
||||
if ! command -v cargo > /dev/null 2>&1; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
fi
|
||||
- name: Install dist
|
||||
run: ${{ matrix.install_dist.run }}
|
||||
# Get the dist-manifest
|
||||
- name: Fetch local artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
merge-multiple: true
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
${{ matrix.packages_install }}
|
||||
- name: Build artifacts
|
||||
run: |
|
||||
# Actually do builds and make zips and whatnot
|
||||
dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json
|
||||
echo "dist ran successfully"
|
||||
- id: cargo-dist
|
||||
name: Post-build
|
||||
# We force bash here just because github makes it really hard to get values up
|
||||
# to "real" actions without writing to env-vars, and writing to env-vars has
|
||||
# inconsistent syntax between shell and powershell.
|
||||
shell: bash
|
||||
run: |
|
||||
# Parse out what we just built and upload it to scratch storage
|
||||
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
|
||||
dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||
- name: "Upload artifacts"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
|
||||
path: |
|
||||
${{ steps.cargo-dist.outputs.paths }}
|
||||
${{ env.BUILD_MANIFEST_NAME }}
|
||||
|
||||
# Build and package all the platform-agnostic(ish) things
|
||||
build-global-artifacts:
|
||||
needs:
|
||||
- plan
|
||||
- build-local-artifacts
|
||||
runs-on: "ubuntu-22.04"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install cached dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/
|
||||
- run: chmod +x ~/.cargo/bin/dist
|
||||
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
|
||||
- name: Fetch local artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
merge-multiple: true
|
||||
- id: cargo-dist
|
||||
shell: bash
|
||||
run: |
|
||||
dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json
|
||||
echo "dist ran successfully"
|
||||
|
||||
# Parse out what we just built and upload it to scratch storage
|
||||
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
|
||||
jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||
- name: "Upload artifacts"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artifacts-build-global
|
||||
path: |
|
||||
${{ steps.cargo-dist.outputs.paths }}
|
||||
${{ env.BUILD_MANIFEST_NAME }}
|
||||
# Determines if we should publish/announce
|
||||
host:
|
||||
needs:
|
||||
- plan
|
||||
- build-local-artifacts
|
||||
- build-global-artifacts
|
||||
# Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine)
|
||||
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
runs-on: "ubuntu-22.04"
|
||||
outputs:
|
||||
val: ${{ steps.host.outputs.manifest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install cached dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/
|
||||
- run: chmod +x ~/.cargo/bin/dist
|
||||
# Fetch artifacts from scratch-storage
|
||||
- name: Fetch artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
merge-multiple: true
|
||||
- id: host
|
||||
shell: bash
|
||||
run: |
|
||||
dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json
|
||||
echo "artifacts uploaded and released successfully"
|
||||
cat dist-manifest.json
|
||||
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||
- name: "Upload dist-manifest.json"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
# Overwrite the previous copy
|
||||
name: artifacts-dist-manifest
|
||||
path: dist-manifest.json
|
||||
# Create a GitHub Release while uploading all files to it
|
||||
- name: "Download GitHub Artifacts"
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Cleanup
|
||||
run: |
|
||||
# Remove the granular manifests
|
||||
rm -f artifacts/*-dist-manifest.json
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}"
|
||||
ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}"
|
||||
ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}"
|
||||
RELEASE_COMMIT: "${{ github.sha }}"
|
||||
run: |
|
||||
# Write and read notes from a file to avoid quoting breaking things
|
||||
echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt
|
||||
|
||||
gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
|
||||
|
||||
announce:
|
||||
needs:
|
||||
- plan
|
||||
- host
|
||||
# use "always() && ..." to allow us to wait for all publish jobs while
|
||||
# still allowing individual publish jobs to skip themselves (for prereleases).
|
||||
# "host" however must run to completion, no skipping allowed!
|
||||
if: ${{ always() && needs.host.result == 'success' }}
|
||||
runs-on: "ubuntu-22.04"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
+18
@@ -6,12 +6,26 @@ target
|
||||
websites/dist
|
||||
bridge/
|
||||
/ids.txt
|
||||
rust_out
|
||||
|
||||
# Copies
|
||||
*\ copy*
|
||||
|
||||
# Ignored
|
||||
_*
|
||||
!__*.py
|
||||
/*.md
|
||||
/*.py
|
||||
/*.json
|
||||
/*.html
|
||||
!/btc-cycle-sim.html
|
||||
/research
|
||||
/filter_*
|
||||
/heatmaps*
|
||||
/oracle*
|
||||
/playground
|
||||
/*.txt
|
||||
/*.csv
|
||||
|
||||
# Logs
|
||||
*.log*
|
||||
@@ -26,6 +40,7 @@ flamegraph.svg
|
||||
|
||||
# AI
|
||||
.claude/settings*
|
||||
!CLAUDE.md
|
||||
|
||||
# Expand
|
||||
expand.rs
|
||||
@@ -33,3 +48,6 @@ expand.rs
|
||||
# Benchmarks
|
||||
[0-9]/
|
||||
/benches
|
||||
|
||||
# AI
|
||||
.claude
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"file_scan_exclusions": [
|
||||
// default
|
||||
"**/.git",
|
||||
"**/.svn",
|
||||
"**/.hg",
|
||||
"**/.jj",
|
||||
"**/CVS",
|
||||
"**/.DS_Store",
|
||||
"**/Thumbs.db",
|
||||
"**/.classpath",
|
||||
"**/.settings",
|
||||
// custom
|
||||
"**/lean-qr/*/index.mjs",
|
||||
"**/modern-screenshot/*/index.mjs",
|
||||
"**/solidjs-signals/*/dist/prod.js",
|
||||
"uFuzzy.mjs",
|
||||
"lightweight-charts.standalone.production.mjs"
|
||||
// "scripts/packages",
|
||||
// "dist"
|
||||
]
|
||||
}
|
||||
Generated
+925
-3030
File diff suppressed because it is too large
Load Diff
+47
-50
@@ -4,7 +4,7 @@ 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.1.0-alpha.1"
|
||||
package.version = "0.3.3"
|
||||
package.homepage = "https://bitcoinresearchkit.org"
|
||||
package.repository = "https://github.com/bitcoinresearchkit/brk"
|
||||
package.readme = "README.md"
|
||||
@@ -28,70 +28,67 @@ lto = false
|
||||
strip = false
|
||||
inherits = "release"
|
||||
|
||||
[profile.dist]
|
||||
inherits = "release"
|
||||
|
||||
[profile.profiling]
|
||||
inherits = "release"
|
||||
debug = true
|
||||
|
||||
[workspace.dependencies]
|
||||
aide = { version = "0.16.0-alpha.1", features = ["axum-json", "axum-query"] }
|
||||
axum = "0.8.8"
|
||||
bitcoin = { version = "0.32.8", features = ["serde"] }
|
||||
bitcoincore-rpc = "0.19.0"
|
||||
brk_bencher = { version = "0.1.0-alpha.1", path = "crates/brk_bencher" }
|
||||
brk_binder = { version = "0.1.0-alpha.1", path = "crates/brk_binder" }
|
||||
brk_bundler = { version = "0.1.0-alpha.1", path = "crates/brk_bundler" }
|
||||
brk_cli = { version = "0.1.0-alpha.1", path = "crates/brk_cli" }
|
||||
brk_computer = { version = "0.1.0-alpha.1", path = "crates/brk_computer" }
|
||||
brk_error = { version = "0.1.0-alpha.1", path = "crates/brk_error" }
|
||||
brk_fetcher = { version = "0.1.0-alpha.1", path = "crates/brk_fetcher" }
|
||||
brk_grouper = { version = "0.1.0-alpha.1", path = "crates/brk_grouper" }
|
||||
brk_indexer = { version = "0.1.0-alpha.1", path = "crates/brk_indexer" }
|
||||
brk_query = { version = "0.1.0-alpha.1", path = "crates/brk_query", features = ["tokio"] }
|
||||
brk_iterator = { version = "0.1.0-alpha.1", path = "crates/brk_iterator" }
|
||||
brk_logger = { version = "0.1.0-alpha.1", path = "crates/brk_logger" }
|
||||
brk_mcp = { version = "0.1.0-alpha.1", path = "crates/brk_mcp" }
|
||||
brk_mempool = { version = "0.1.0-alpha.1", path = "crates/brk_mempool" }
|
||||
brk_reader = { version = "0.1.0-alpha.1", path = "crates/brk_reader" }
|
||||
brk_rpc = { version = "0.1.0-alpha.1", path = "crates/brk_rpc" }
|
||||
brk_server = { version = "0.1.0-alpha.1", path = "crates/brk_server" }
|
||||
brk_store = { version = "0.1.0-alpha.1", path = "crates/brk_store" }
|
||||
brk_types = { version = "0.1.0-alpha.1", path = "crates/brk_types" }
|
||||
brk_traversable = { version = "0.1.0-alpha.1", path = "crates/brk_traversable", features = ["pco", "derive"] }
|
||||
brk_traversable_derive = { version = "0.1.0-alpha.1", path = "crates/brk_traversable_derive" }
|
||||
byteview = "0.9.1"
|
||||
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.100", features = ["serde"] }
|
||||
brk_alloc = { version = "0.3.3", path = "crates/brk_alloc" }
|
||||
brk_bencher = { version = "0.3.3", path = "crates/brk_bencher" }
|
||||
brk_bindgen = { version = "0.3.3", path = "crates/brk_bindgen" }
|
||||
brk_cli = { version = "0.3.3", path = "crates/brk_cli" }
|
||||
brk_client = { version = "0.3.3", path = "crates/brk_client" }
|
||||
brk_cohort = { version = "0.3.3", path = "crates/brk_cohort" }
|
||||
brk_computer = { version = "0.3.3", path = "crates/brk_computer" }
|
||||
brk_error = { version = "0.3.3", path = "crates/brk_error" }
|
||||
brk_fetcher = { version = "0.3.3", path = "crates/brk_fetcher" }
|
||||
brk_indexer = { version = "0.3.3", path = "crates/brk_indexer" }
|
||||
brk_iterator = { version = "0.3.3", path = "crates/brk_iterator" }
|
||||
brk_logger = { version = "0.3.3", path = "crates/brk_logger" }
|
||||
brk_mempool = { version = "0.3.3", path = "crates/brk_mempool" }
|
||||
brk_oracle = { version = "0.3.3", path = "crates/brk_oracle" }
|
||||
brk_query = { version = "0.3.3", path = "crates/brk_query", features = ["tokio"] }
|
||||
brk_reader = { version = "0.3.3", path = "crates/brk_reader" }
|
||||
brk_rpc = { version = "0.3.3", path = "crates/brk_rpc" }
|
||||
brk_server = { version = "0.3.3", path = "crates/brk_server" }
|
||||
brk_store = { version = "0.3.3", path = "crates/brk_store" }
|
||||
brk_traversable = { version = "0.3.3", path = "crates/brk_traversable", features = ["pco", "derive"] }
|
||||
brk_traversable_derive = { version = "0.3.3", path = "crates/brk_traversable_derive" }
|
||||
brk_types = { version = "0.3.3", path = "crates/brk_types" }
|
||||
brk_website = { version = "0.3.3", path = "crates/brk_website" }
|
||||
byteview = "0.10.1"
|
||||
color-eyre = "0.6.5"
|
||||
derive_deref = "1.1.1"
|
||||
fjall = "3.0.0-rc.6"
|
||||
jiff = "0.2.16"
|
||||
log = "0.4.29"
|
||||
mimalloc = { version = "0.1.48", features = ["v3"] }
|
||||
minreq = { version = "2.14.1", features = ["https", "serde_json"] }
|
||||
corepc-jsonrpc = { package = "jsonrpc", version = "0.19.0", features = ["simple_http"], default-features = false }
|
||||
corepc-types = { version = "0.14.0", features = ["std"], default-features = false }
|
||||
derive_more = { version = "2.1.1", features = ["deref", "deref_mut"] }
|
||||
fjall = "3.1.4"
|
||||
indexmap = { version = "2.14.0", features = ["serde"] }
|
||||
jiff = { version = "0.2.28", features = ["perf-inline", "tz-system"], default-features = false }
|
||||
owo-colors = "4.3.0"
|
||||
parking_lot = "0.12.5"
|
||||
rayon = "1.11.0"
|
||||
rustc-hash = "2.1.1"
|
||||
schemars = "1.1.0"
|
||||
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.145", features = ["float_roundtrip"] }
|
||||
serde_json = { version = "1.0.150", features = ["float_roundtrip", "preserve_order"] }
|
||||
smallvec = "1.15.1"
|
||||
tokio = { version = "1.48.0", features = ["rt-multi-thread"] }
|
||||
vecdb = { version = "0.4.4", features = ["derive", "serde_json", "pco"] }
|
||||
tokio = { version = "1.52.3", features = ["rt-multi-thread"] }
|
||||
tower-http = { version = "0.6.11", 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"] }
|
||||
# vecdb = { git = "https://github.com/anydb-rs/anydb", features = ["derive", "serde_json", "pco"] }
|
||||
|
||||
[workspace.metadata.release]
|
||||
shared-version = true
|
||||
tag-name = "v{{version}}"
|
||||
pre-release-commit-message = "release: v{{version}}"
|
||||
tag-message = "release: v{{version}}"
|
||||
|
||||
[workspace.metadata.dist]
|
||||
cargo-dist-version = "0.30.2"
|
||||
ci = "github"
|
||||
allow-dirty = ["ci"]
|
||||
installers = []
|
||||
targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu"]
|
||||
allow-branch = ["main", "next"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 bitcoinresearchkit, kibo.money, satonomics
|
||||
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
|
||||
@@ -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()
|
||||
}
|
||||
+12
-13
@@ -6,22 +6,21 @@ homepage.workspace = true
|
||||
repository.workspace = true
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
build = "build.rs"
|
||||
|
||||
[features]
|
||||
full = [
|
||||
"bencher",
|
||||
"binder",
|
||||
"bundler",
|
||||
"bindgen",
|
||||
"client",
|
||||
"computer",
|
||||
"error",
|
||||
"fetcher",
|
||||
"grouper",
|
||||
"cohort",
|
||||
"indexer",
|
||||
"iterator",
|
||||
"logger",
|
||||
"mcp",
|
||||
"mempool",
|
||||
"oracle",
|
||||
"query",
|
||||
"reader",
|
||||
"rpc",
|
||||
@@ -31,17 +30,17 @@ full = [
|
||||
"types",
|
||||
]
|
||||
bencher = ["brk_bencher"]
|
||||
binder = ["brk_binder"]
|
||||
bundler = ["brk_bundler"]
|
||||
bindgen = ["brk_bindgen"]
|
||||
client = ["brk_client"]
|
||||
computer = ["brk_computer"]
|
||||
error = ["brk_error"]
|
||||
fetcher = ["brk_fetcher"]
|
||||
grouper = ["brk_grouper"]
|
||||
cohort = ["brk_cohort"]
|
||||
indexer = ["brk_indexer"]
|
||||
iterator = ["brk_iterator"]
|
||||
logger = ["brk_logger"]
|
||||
mcp = ["brk_mcp"]
|
||||
mempool = ["brk_mempool"]
|
||||
oracle = ["brk_oracle"]
|
||||
query = ["brk_query"]
|
||||
reader = ["brk_reader"]
|
||||
rpc = ["brk_rpc"]
|
||||
@@ -52,17 +51,17 @@ types = ["brk_types"]
|
||||
|
||||
[dependencies]
|
||||
brk_bencher = { workspace = true, optional = true }
|
||||
brk_binder = { workspace = true, optional = true }
|
||||
brk_bundler = { 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_grouper = { 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_mcp = { 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 }
|
||||
|
||||
+51
-25
@@ -2,15 +2,15 @@
|
||||
|
||||
Umbrella crate for the Bitcoin Research Kit.
|
||||
|
||||
## What It Enables
|
||||
|
||||
Single dependency to access any BRK component. Enable only what you need via feature flags.
|
||||
[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.x", features = ["query", "types"] }
|
||||
brk = { version = "0.1", features = ["query", "types"] }
|
||||
```
|
||||
|
||||
```rust,ignore
|
||||
@@ -18,25 +18,51 @@ use brk::query::Query;
|
||||
use brk::types::Height;
|
||||
```
|
||||
|
||||
## Feature Flags
|
||||
Feature flags match crate names without the `brk_` prefix. Use `full` to enable all:
|
||||
|
||||
| Feature | Crate | Description |
|
||||
|---------|-------|-------------|
|
||||
| `binder` | `brk_binder` | Client code generation |
|
||||
| `bundler` | `brk_bundler` | JS bundling |
|
||||
| `computer` | `brk_computer` | Metric computation |
|
||||
| `error` | `brk_error` | Error types |
|
||||
| `fetcher` | `brk_fetcher` | Price data fetching |
|
||||
| `grouper` | `brk_grouper` | Cohort filtering |
|
||||
| `indexer` | `brk_indexer` | Blockchain indexing |
|
||||
| `iterator` | `brk_iterator` | Block iteration |
|
||||
| `logger` | `brk_logger` | Logging setup |
|
||||
| `mcp` | `brk_mcp` | MCP server |
|
||||
| `mempool` | `brk_mempool` | Mempool monitoring |
|
||||
| `query` | `brk_query` | Query interface |
|
||||
| `reader` | `brk_reader` | Raw block reading |
|
||||
| `rpc` | `brk_rpc` | Bitcoin RPC client |
|
||||
| `server` | `brk_server` | HTTP API server |
|
||||
| `store` | `brk_store` | Key-value storage |
|
||||
| `traversable` | `brk_traversable` | Data traversal |
|
||||
| `types` | `brk_types` | Domain types |
|
||||
```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 |
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
fn main() {
|
||||
let profile = std::env::var("PROFILE").unwrap_or_default();
|
||||
|
||||
if profile == "release" {
|
||||
println!("cargo:rustc-flag=-C");
|
||||
println!("cargo:rustc-flag=target-cpu=native");
|
||||
}
|
||||
}
|
||||
+12
-12
@@ -4,13 +4,17 @@
|
||||
#[doc(inline)]
|
||||
pub use brk_bencher as bencher;
|
||||
|
||||
#[cfg(feature = "binder")]
|
||||
#[cfg(feature = "bindgen")]
|
||||
#[doc(inline)]
|
||||
pub use brk_binder as binder;
|
||||
pub use brk_bindgen as bindgen;
|
||||
|
||||
#[cfg(feature = "bundler")]
|
||||
#[cfg(feature = "client")]
|
||||
#[doc(inline)]
|
||||
pub use brk_bundler as bundler;
|
||||
pub use brk_client as client;
|
||||
|
||||
#[cfg(feature = "cohort")]
|
||||
#[doc(inline)]
|
||||
pub use brk_cohort as cohort;
|
||||
|
||||
#[cfg(feature = "computer")]
|
||||
#[doc(inline)]
|
||||
@@ -24,10 +28,6 @@ pub use brk_error as error;
|
||||
#[doc(inline)]
|
||||
pub use brk_fetcher as fetcher;
|
||||
|
||||
#[cfg(feature = "grouper")]
|
||||
#[doc(inline)]
|
||||
pub use brk_grouper as grouper;
|
||||
|
||||
#[cfg(feature = "indexer")]
|
||||
#[doc(inline)]
|
||||
pub use brk_indexer as indexer;
|
||||
@@ -40,14 +40,14 @@ pub use brk_iterator as iterator;
|
||||
#[doc(inline)]
|
||||
pub use brk_logger as logger;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
#[doc(inline)]
|
||||
pub use brk_mcp as mcp;
|
||||
|
||||
#[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;
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
brk_error = { workspace = true }
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
fn main() {
|
||||
let profile = std::env::var("PROFILE").unwrap_or_default();
|
||||
|
||||
if profile == "release" {
|
||||
println!("cargo:rustc-flag=-C");
|
||||
println!("cargo:rustc-flag=target-cpu=native");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Write};
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
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)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use std::fs::File;
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{self, Write},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::fs;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use libproc::pid_rusage::{pidrusage, RUsageInfoV2};
|
||||
use libproc::pid_rusage::{RUsageInfoV2, pidrusage};
|
||||
|
||||
pub struct IoMonitor {
|
||||
pid: u32,
|
||||
|
||||
@@ -113,7 +113,9 @@ impl Bencher {
|
||||
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"))??;
|
||||
handle
|
||||
.join()
|
||||
.map_err(|_| Error::Internal("Monitor thread panicked"))??;
|
||||
}
|
||||
|
||||
self.0.progression.flush()?;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::fs::File;
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{self, Write},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::fs;
|
||||
|
||||
@@ -7,7 +7,6 @@ license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
plotters = "0.3.7"
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
fn main() {
|
||||
let profile = std::env::var("PROFILE").unwrap_or_default();
|
||||
|
||||
if profile == "release" {
|
||||
println!("cargo:rustc-flag=-C");
|
||||
println!("cargo:rustc-flag=target-cpu=native");
|
||||
}
|
||||
}
|
||||
@@ -43,14 +43,18 @@ pub fn generate(config: ChartConfig, runs: &[Run]) -> Result<()> {
|
||||
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 (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))
|
||||
.caption(
|
||||
&config.title,
|
||||
(FONT, FONT_SIZE_BIG).into_font().color(&TEXT_COLOR),
|
||||
)
|
||||
.margin(20)
|
||||
.margin_right(40)
|
||||
.x_label_area_size(50)
|
||||
@@ -62,7 +66,14 @@ pub fn generate(config: ChartConfig, runs: &[Run]) -> Result<()> {
|
||||
|
||||
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)?;
|
||||
draw_series(
|
||||
&mut chart,
|
||||
&run.data,
|
||||
&run.id,
|
||||
color,
|
||||
time_divisor,
|
||||
scale_factor,
|
||||
)?;
|
||||
}
|
||||
|
||||
configure_legend(&mut chart)?;
|
||||
@@ -93,14 +104,18 @@ pub fn generate_dual(
|
||||
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 (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))
|
||||
.caption(
|
||||
&config.title,
|
||||
(FONT, FONT_SIZE_BIG).into_font().color(&TEXT_COLOR),
|
||||
)
|
||||
.margin(20)
|
||||
.margin_right(40)
|
||||
.x_label_area_size(50)
|
||||
@@ -164,7 +179,13 @@ type Chart<'a, 'b> = ChartContext<
|
||||
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<()> {
|
||||
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 {
|
||||
@@ -200,9 +221,12 @@ fn draw_series(
|
||||
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));
|
||||
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)))?
|
||||
@@ -221,7 +245,12 @@ fn draw_dashed_series(
|
||||
) -> Result<()> {
|
||||
let points: Vec<_> = data
|
||||
.iter()
|
||||
.map(|d| (d.timestamp_ms as f64 / 1000.0 / time_divisor, d.value / scale_factor))
|
||||
.map(|d| {
|
||||
(
|
||||
d.timestamp_ms as f64 / 1000.0 / time_divisor,
|
||||
d.value / scale_factor,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Draw dashed line by skipping every other segment
|
||||
@@ -234,7 +263,12 @@ fn draw_dashed_series(
|
||||
.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)));
|
||||
.legend(move |(x, y)| {
|
||||
PathElement::new(
|
||||
vec![(x, y), (x + 10, y), (x + 20, y)],
|
||||
color.stroke_width(2),
|
||||
)
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ mod chart;
|
||||
mod data;
|
||||
mod format;
|
||||
|
||||
use data::{read_dual_runs, read_runs, Cutoffs, DualRun, Result, Run};
|
||||
use data::{Cutoffs, DualRun, Result, Run, read_dual_runs, read_runs};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
@@ -57,10 +57,24 @@ impl Visualizer {
|
||||
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)?;
|
||||
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)?;
|
||||
self.generate_individual_charts(
|
||||
crate_path,
|
||||
crate_name,
|
||||
&disk_runs,
|
||||
&memory_runs,
|
||||
&progress_runs,
|
||||
&io_runs,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -246,4 +260,3 @@ impl Visualizer {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
clients/
|
||||
@@ -1,296 +0,0 @@
|
||||
# brk_binder Design Document
|
||||
|
||||
## Goal
|
||||
|
||||
Generate typed API clients for **Rust, JavaScript, and Python** with:
|
||||
- **Discoverability**: Full IDE autocomplete for 20k+ metrics
|
||||
- **Ease of use**: Fluent API with `.fetch()` on each metric node
|
||||
|
||||
## Current State
|
||||
|
||||
### What's Working ✅
|
||||
|
||||
1. **JS + JSDoc generator**: Generates `client.js` with full JSDoc type annotations
|
||||
2. **Python generator**: Generates `client.py` with type hints and httpx
|
||||
3. **Rust generator**: Generates `client.rs` with strong typing and reqwest
|
||||
4. **schemars integration**: JSON schemas embedded in `MetricLeafWithSchema` for type info
|
||||
5. **Tree navigation**: `client.tree.blocks.difficulty.fetch()` pattern
|
||||
6. **OpenAPI integration**: All GET endpoints generate typed methods
|
||||
7. **Server integration**: brk_server calls brk_binder on startup (when clients/ dir exists)
|
||||
|
||||
### Generated Output
|
||||
|
||||
When `crates/brk_binder/clients/` directory exists, running the server generates:
|
||||
|
||||
```
|
||||
crates/brk_binder/clients/
|
||||
├── javascript/
|
||||
│ └── client.js # JS + JSDoc with tree + API methods
|
||||
├── python/
|
||||
│ └── client.py # Python with type hints + httpx
|
||||
└── rust/
|
||||
└── client.rs # Rust with reqwest + strong typing
|
||||
```
|
||||
|
||||
## Target Architecture
|
||||
|
||||
### Input Sources
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Input Sources │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 1. OpenAPI spec (from aide) - endpoint definitions │
|
||||
│ 2. brk_query catalog - metric tree structure │
|
||||
│ 3. brk_types - Rust types for responses (Rust client only) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Output: Fluent Client
|
||||
|
||||
```javascript
|
||||
// JavaScript (with JSDoc for IDE support)
|
||||
const client = new BrkClient("http://localhost:3000");
|
||||
const data = await client.tree.supply.active.by_date.fetch();
|
||||
// ^^^^ autocomplete all the way down
|
||||
```
|
||||
|
||||
```python
|
||||
# Python
|
||||
client = BrkClient("http://localhost:3000")
|
||||
data = client.tree.supply.active.by_date.fetch()
|
||||
```
|
||||
|
||||
```rust
|
||||
// Rust
|
||||
let client = BrkClient::new("http://localhost:3000")?;
|
||||
let data = client.tree().supply.active.by_date.fetch()?;
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Smart Metric Nodes
|
||||
|
||||
Each tree leaf becomes a "smart node" holding a client reference:
|
||||
|
||||
```javascript
|
||||
// JavaScript + JSDoc
|
||||
/**
|
||||
* Metric node with fetch capability
|
||||
* @template T
|
||||
*/
|
||||
class MetricNode {
|
||||
constructor(client, path) {
|
||||
this._client = client;
|
||||
this._path = path;
|
||||
}
|
||||
|
||||
async fetch() {
|
||||
return this._client.get(this._path);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
# Python
|
||||
class MetricNode(Generic[T]):
|
||||
def __init__(self, client: BrkClientBase, path: str):
|
||||
self._client = client
|
||||
self._path = path
|
||||
|
||||
def fetch(self) -> T:
|
||||
return self._client.get(self._path)
|
||||
```
|
||||
|
||||
```rust
|
||||
// Rust
|
||||
pub struct MetricNode<'a, T> {
|
||||
client: &'a BrkClientBase,
|
||||
path: &'static str,
|
||||
_marker: PhantomData<T>,
|
||||
}
|
||||
|
||||
impl<'a, T: DeserializeOwned> MetricNode<'a, T> {
|
||||
pub fn fetch(&self) -> Result<T> {
|
||||
self.client.get(self.path)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern Reuse
|
||||
|
||||
To avoid 20k+ individual types, reuse structural patterns:
|
||||
|
||||
```rust
|
||||
// Shared pattern for metrics with same index groupings
|
||||
struct ByDateHeightMonth<T> {
|
||||
by_date: MetricNode<T>,
|
||||
by_height: MetricNode<T>,
|
||||
by_month: MetricNode<T>,
|
||||
}
|
||||
|
||||
// Composed into full tree
|
||||
struct Supply {
|
||||
active: ByDateHeightMonth<Vec<f64>>,
|
||||
total: ByDateHeightMonth<Vec<f64>>,
|
||||
}
|
||||
```
|
||||
|
||||
## Type Discovery Solution ✅ IMPLEMENTED
|
||||
|
||||
### The Problem
|
||||
|
||||
Type information was erased at runtime because metrics are stored as `&dyn AnyExportableVec` trait objects.
|
||||
|
||||
### The Solution
|
||||
|
||||
Use `std::any::type_name::<T>()` with caching to extract short type names.
|
||||
|
||||
#### Implementation (vecdb)
|
||||
|
||||
Added `short_type_name<T>()` helper and `value_type_to_string()` to `AnyVec` trait.
|
||||
|
||||
### Result
|
||||
|
||||
`brk_query` now exposes:
|
||||
|
||||
```rust
|
||||
for (metric_name, index_to_vec) in &vecs.metric_to_index_to_vec {
|
||||
for (index, vec) in index_to_vec {
|
||||
println!("{} @ {} -> {}",
|
||||
metric_name, // "difficulty"
|
||||
vec.index_type_to_string(), // "Height"
|
||||
vec.value_type_to_string(), // "StoredF64"
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## TreeNode Enhancement ✅ IMPLEMENTED
|
||||
|
||||
Changed `TreeNode::Leaf(String)` to `TreeNode::Leaf(MetricLeafWithSchema)` where:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct MetricLeafWithSchema {
|
||||
#[serde(flatten)]
|
||||
pub leaf: MetricLeaf,
|
||||
#[serde(skip)]
|
||||
pub schema: serde_json::Value, // JSON Schema from schemars
|
||||
}
|
||||
```
|
||||
|
||||
## OpenAPI Integration ✅ IMPLEMENTED
|
||||
|
||||
### Flow
|
||||
|
||||
1. brk_server creates OpenAPI spec via aide
|
||||
2. On startup, serializes spec to JSON string
|
||||
3. Passes JSON to `brk_binder::generate_clients()`
|
||||
4. brk_binder parses with `oas3` crate (supports OpenAPI 3.1)
|
||||
5. Generates typed methods for all GET endpoints
|
||||
|
||||
### Why oas3?
|
||||
|
||||
aide generates OpenAPI 3.1 specs. The `openapiv3` crate only supports 3.0.x.
|
||||
The `oas3` crate supports OpenAPI 3.1.x parsing.
|
||||
|
||||
## Tasks
|
||||
|
||||
### Phase 0: Type Infrastructure ✅ COMPLETE
|
||||
|
||||
- [x] vecdb: Add `short_type_name<T>()` and `value_type_to_string()`
|
||||
- [x] vecdb: Add optional `schemars` feature with `AnySchemaVec` trait
|
||||
- [x] brk_types: Enhance `TreeNode::Leaf` to include `MetricLeafWithSchema`
|
||||
- [x] brk_traversable: Update all `to_tree_node()` with schemars integration
|
||||
- [x] brk_binder: Set up generator module structure
|
||||
|
||||
### Phase 1: JavaScript Client ✅ COMPLETE
|
||||
|
||||
- [x] Define `MetricNode` class with JSDoc generics
|
||||
- [x] Define `BrkClient` with base HTTP functionality
|
||||
- [x] Generate `client.js` with full JSDoc type annotations
|
||||
- [x] Tree navigation: `client.tree.category.metric.fetch()`
|
||||
- [x] API methods from OpenAPI endpoints
|
||||
|
||||
### Phase 2: OpenAPI Integration ✅ COMPLETE
|
||||
|
||||
- [x] Add `oas3` crate dependency (OpenAPI 3.1 support)
|
||||
- [x] brk_server passes OpenAPI JSON to brk_binder on startup
|
||||
- [x] Parse OpenAPI spec and extract endpoint definitions
|
||||
- [x] Generate typed methods for each GET endpoint
|
||||
|
||||
### Phase 3: Python Client ✅ COMPLETE
|
||||
|
||||
- [x] Define `MetricNode` class with type hints
|
||||
- [x] Define `BrkClient` with httpx
|
||||
- [x] Generate typed methods from OpenAPI
|
||||
- [x] Generate tree navigation
|
||||
|
||||
### Phase 4: Rust Client ✅ COMPLETE
|
||||
|
||||
- [x] Define `MetricNode<T>` struct with lifetimes
|
||||
- [x] Define `BrkClient` with reqwest (blocking)
|
||||
- [x] Generate tree navigation with proper lifetimes
|
||||
- [x] Generate typed methods from OpenAPI
|
||||
|
||||
### Phase 5: Polish
|
||||
|
||||
- [x] Switch from `openapiv3` to `oas3` crate
|
||||
- [ ] Error types per language
|
||||
- [ ] Documentation generation
|
||||
- [ ] Tests
|
||||
- [ ] Example usage in each language
|
||||
- [ ] Async Rust client variant
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
crates/brk_binder/
|
||||
├── src/
|
||||
│ ├── lib.rs
|
||||
│ ├── js.rs # JS constants generation (existing)
|
||||
│ └── generator/
|
||||
│ ├── mod.rs # generate_clients() entry point
|
||||
│ ├── types.rs # ClientMetadata, MetricInfo, IndexPattern
|
||||
│ ├── openapi.rs # OpenAPI 3.1 spec parsing (oas3)
|
||||
│ ├── javascript.rs # JavaScript + JSDoc client ✅
|
||||
│ ├── python.rs # Python client ✅
|
||||
│ └── rust.rs # Rust client ✅
|
||||
├── clients/ # Generated output (gitignored)
|
||||
│ ├── javascript/
|
||||
│ ├── python/
|
||||
│ └── rust/
|
||||
├── Cargo.toml
|
||||
├── README.md
|
||||
└── DESIGN.md
|
||||
|
||||
crates/brk_server/
|
||||
└── src/
|
||||
├── lib.rs # Calls brk_binder::generate_clients() on startup
|
||||
└── api/
|
||||
└── openapi.rs # create_openapi() for aide
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
brk_query = { workspace = true }
|
||||
brk_types = { workspace = true }
|
||||
oas3 = "0.20" # OpenAPI 3.1 spec parsing
|
||||
schemars = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
To generate clients:
|
||||
|
||||
```bash
|
||||
# Create the output directory
|
||||
mkdir -p crates/brk_binder/clients
|
||||
|
||||
# Run the server (generates clients on startup)
|
||||
cargo run -p brk_server
|
||||
```
|
||||
@@ -1,42 +0,0 @@
|
||||
# brk_binder
|
||||
|
||||
Code generation for BRK client libraries.
|
||||
|
||||
## What It Enables
|
||||
|
||||
Generate typed metric catalogs and constants for JavaScript/TypeScript clients. Keeps frontend code in sync with available metrics without manual maintenance.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Metric catalog**: Generates `metrics.js` with all metric IDs and their supported indexes
|
||||
- **Compression**: Metric names compressed via word-to-base62 mapping for smaller bundles
|
||||
- **Mining pools**: Generates `pools.js` with pool ID to name mapping
|
||||
- **Version sync**: Generates `version.js` matching server version
|
||||
|
||||
## Core API
|
||||
|
||||
```rust,ignore
|
||||
generate_js_files(&query, &modules_path)?;
|
||||
```
|
||||
|
||||
## Generated Files
|
||||
|
||||
```
|
||||
modules/brk-client/generated/
|
||||
├── version.js # export const VERSION = "vX.Y.Z"
|
||||
├── metrics.js # INDEXES, COMPRESSED_METRIC_TO_INDEXES
|
||||
└── pools.js # POOL_ID_TO_POOL_NAME
|
||||
```
|
||||
|
||||
## Metric Compression
|
||||
|
||||
To minimize bundle size, metric names are compressed:
|
||||
1. Extract all words from metric names
|
||||
2. Sort by frequency
|
||||
3. Map to base52 codes (A-Z, a-z)
|
||||
4. Store compressed metric → index group mapping
|
||||
|
||||
## Built On
|
||||
|
||||
- `brk_query` for metric enumeration
|
||||
- `brk_types` for mining pool data
|
||||
@@ -1,8 +0,0 @@
|
||||
fn main() {
|
||||
let profile = std::env::var("PROFILE").unwrap_or_default();
|
||||
|
||||
if profile == "release" {
|
||||
println!("cargo:rustc-flag=-C");
|
||||
println!("cargo:rustc-flag=target-cpu=native");
|
||||
}
|
||||
}
|
||||
@@ -1,988 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Write as FmtWrite;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use brk_types::{Index, TreeNode};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
ClientMetadata, Endpoint, FieldNamePosition, IndexSetPattern, PatternField, StructuralPattern,
|
||||
TypeSchemas, extract_inner_type, get_fields_with_child_info, get_first_leaf_name,
|
||||
get_node_fields, get_pattern_instance_base, to_camel_case, to_pascal_case,
|
||||
};
|
||||
|
||||
/// Generate JavaScript + JSDoc client from metadata and OpenAPI endpoints
|
||||
pub fn generate_javascript_client(
|
||||
metadata: &ClientMetadata,
|
||||
endpoints: &[Endpoint],
|
||||
schemas: &TypeSchemas,
|
||||
output_dir: &Path,
|
||||
) -> io::Result<()> {
|
||||
let mut output = String::new();
|
||||
|
||||
// Header
|
||||
writeln!(output, "// Auto-generated BRK JavaScript client").unwrap();
|
||||
writeln!(output, "// Do not edit manually\n").unwrap();
|
||||
|
||||
// Generate type definitions from OpenAPI schemas
|
||||
generate_type_definitions(&mut output, schemas);
|
||||
|
||||
// Generate the base client class
|
||||
generate_base_client(&mut output);
|
||||
|
||||
// Generate index accessor factory functions
|
||||
generate_index_accessors(&mut output, &metadata.index_set_patterns);
|
||||
|
||||
// Generate structural pattern factory functions
|
||||
generate_structural_patterns(&mut output, &metadata.structural_patterns, metadata);
|
||||
|
||||
// Generate tree JSDoc typedefs
|
||||
generate_tree_typedefs(&mut output, &metadata.catalog, metadata);
|
||||
|
||||
// Generate the main client class with tree and API methods
|
||||
generate_main_client(&mut output, &metadata.catalog, metadata, endpoints);
|
||||
|
||||
fs::write(output_dir.join("client.js"), output)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate JSDoc type definitions from OpenAPI schemas
|
||||
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 {
|
||||
let js_type = schema_to_js_type_ctx(schema, Some(name));
|
||||
|
||||
if is_primitive_alias(schema) {
|
||||
// Simple type alias: @typedef {number} Height
|
||||
writeln!(output, "/** @typedef {{{}}} {} */", js_type, name).unwrap();
|
||||
} else if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
|
||||
// Object type with properties
|
||||
writeln!(output, "/**").unwrap();
|
||||
writeln!(output, " * @typedef {{Object}} {}", name).unwrap();
|
||||
for (prop_name, prop_schema) in props {
|
||||
let prop_type = schema_to_js_type_ctx(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 { "=" };
|
||||
writeln!(
|
||||
output,
|
||||
" * @property {{{}{}}} {}",
|
||||
prop_type, optional, prop_name
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(output, " */").unwrap();
|
||||
} else {
|
||||
// Other schemas - just typedef
|
||||
writeln!(output, "/** @typedef {{{}}} {} */", js_type, name).unwrap();
|
||||
}
|
||||
}
|
||||
writeln!(output).unwrap();
|
||||
}
|
||||
|
||||
/// Check if schema represents a primitive type alias (like Height = number)
|
||||
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()
|
||||
}
|
||||
|
||||
/// Convert a single JSON type string to JavaScript type
|
||||
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_ctx(s, current_type))
|
||||
.unwrap_or_else(|| "*".to_string());
|
||||
format!("{}[]", item_type)
|
||||
}
|
||||
"object" => {
|
||||
// Check if it has additionalProperties (dict-like)
|
||||
if let Some(add_props) = schema.get("additionalProperties") {
|
||||
let value_type = schema_to_js_type_ctx(add_props, current_type);
|
||||
// Use TypeScript index signature syntax for recursive types
|
||||
return format!("{{ [key: string]: {} }}", value_type);
|
||||
}
|
||||
"Object".to_string()
|
||||
}
|
||||
_ => "*".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert JSON Schema to JavaScript/JSDoc type with context for recursive types
|
||||
fn schema_to_js_type_ctx(schema: &Value, current_type: Option<&str>) -> String {
|
||||
// Handle allOf (try each element until we find a resolvable type)
|
||||
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_ctx(item, current_type);
|
||||
if resolved != "*" {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle $ref
|
||||
if let Some(ref_path) = schema.get("$ref").and_then(|r| r.as_str()) {
|
||||
return ref_path.rsplit('/').next().unwrap_or("*").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!("({})", literals.join("|"));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle type field (can be string or array of strings)
|
||||
if let Some(ty) = schema.get("type") {
|
||||
// Handle array of types like ["string", "null"] for Optional
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle single type string
|
||||
if let Some(ty_str) = ty.as_str() {
|
||||
return json_type_to_js(ty_str, schema, current_type);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle anyOf/oneOf
|
||||
if let Some(variants) = schema
|
||||
.get("anyOf")
|
||||
.or_else(|| schema.get("oneOf"))
|
||||
.and_then(|v| v.as_array())
|
||||
{
|
||||
let types: Vec<String> = variants
|
||||
.iter()
|
||||
.map(|v| schema_to_js_type_ctx(v, current_type))
|
||||
.collect();
|
||||
// Filter out * and null for cleaner unions
|
||||
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("|"));
|
||||
}
|
||||
|
||||
// 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" => "number".to_string(),
|
||||
"float" | "double" => "number".to_string(),
|
||||
"date" | "date-time" => "string".to_string(),
|
||||
_ => "*".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
"*".to_string()
|
||||
}
|
||||
|
||||
/// Generate the base BrkClient class with HTTP functionality
|
||||
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
|
||||
*/
|
||||
|
||||
const _isBrowser = typeof window !== 'undefined' && 'caches' in window;
|
||||
const _runIdle = (fn) => (globalThis.requestIdleCallback ?? setTimeout)(fn);
|
||||
|
||||
/** @type {{Promise<Cache | null>}} */
|
||||
const _cachePromise = _isBrowser
|
||||
? caches.open('__BRK_CLIENT__').catch(() => null)
|
||||
: Promise.resolve(null);
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}}
|
||||
}}
|
||||
|
||||
/**
|
||||
* A metric node that can fetch data for different indexes.
|
||||
* @template T
|
||||
*/
|
||||
class MetricNode {{
|
||||
/**
|
||||
* @param {{BrkClientBase}} client
|
||||
* @param {{string}} path
|
||||
*/
|
||||
constructor(client, path) {{
|
||||
this._client = client;
|
||||
this._path = path;
|
||||
}}
|
||||
|
||||
/**
|
||||
* Fetch all data points for this metric.
|
||||
* @param {{(value: T[]) => void}} [onUpdate] - Called when data is available (may be called twice: cache then fresh)
|
||||
* @returns {{Promise<T[] | null>}}
|
||||
*/
|
||||
get(onUpdate) {{
|
||||
return this._client.get(this._path, onUpdate);
|
||||
}}
|
||||
|
||||
/**
|
||||
* Fetch data points within a range.
|
||||
* @param {{string | number}} from
|
||||
* @param {{string | number}} to
|
||||
* @param {{(value: T[]) => void}} [onUpdate] - Called when data is available (may be called twice: cache then fresh)
|
||||
* @returns {{Promise<T[] | null>}}
|
||||
*/
|
||||
getRange(from, to, onUpdate) {{
|
||||
return this._client.get(`${{this._path}}?from=${{from}}&to=${{to}}`, onUpdate);
|
||||
}}
|
||||
}}
|
||||
|
||||
/**
|
||||
* Base HTTP client for making requests with caching support
|
||||
*/
|
||||
class BrkClientBase {{
|
||||
/**
|
||||
* @param {{BrkClientOptions|string}} options
|
||||
*/
|
||||
constructor(options) {{
|
||||
const isString = typeof options === 'string';
|
||||
this.baseUrl = isString ? options : options.baseUrl;
|
||||
this.timeout = isString ? 5000 : (options.timeout ?? 5000);
|
||||
}}
|
||||
|
||||
/**
|
||||
* Make a GET request with stale-while-revalidate caching
|
||||
* @template T
|
||||
* @param {{string}} path
|
||||
* @param {{(value: T) => void}} [onUpdate] - Called when data is available
|
||||
* @returns {{Promise<T | null>}}
|
||||
*/
|
||||
async get(path, onUpdate) {{
|
||||
const url = `${{this.baseUrl}}${{path}}`;
|
||||
const cache = await _cachePromise;
|
||||
const cachedRes = await cache?.match(url);
|
||||
const cachedJson = cachedRes ? await cachedRes.json() : null;
|
||||
|
||||
if (cachedJson) onUpdate?.(cachedJson);
|
||||
if (!globalThis.navigator?.onLine) return cachedJson;
|
||||
|
||||
try {{
|
||||
const res = await fetch(url, {{ signal: AbortSignal.timeout(this.timeout) }});
|
||||
if (!res.ok) throw new BrkError(`HTTP ${{res.status}}`, res.status);
|
||||
if (cachedRes?.headers.get('ETag') === res.headers.get('ETag')) return cachedJson;
|
||||
|
||||
const cloned = res.clone();
|
||||
const json = await res.json();
|
||||
onUpdate?.(json);
|
||||
if (cache) _runIdle(() => cache.put(url, cloned));
|
||||
return json;
|
||||
}} catch (e) {{
|
||||
if (cachedJson) return cachedJson;
|
||||
throw e;
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Generate index accessor factory functions
|
||||
fn generate_index_accessors(output: &mut String, patterns: &[IndexSetPattern]) {
|
||||
if patterns.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
writeln!(output, "// Index accessor factory functions\n").unwrap();
|
||||
|
||||
for pattern in patterns {
|
||||
// Generate JSDoc typedef for the accessor
|
||||
writeln!(output, "/**").unwrap();
|
||||
writeln!(output, " * @template T").unwrap();
|
||||
writeln!(output, " * @typedef {{Object}} {}", pattern.name).unwrap();
|
||||
for index in &pattern.indexes {
|
||||
let field_name = index_to_camel_case(index);
|
||||
writeln!(output, " * @property {{MetricNode<T>}} {}", field_name).unwrap();
|
||||
}
|
||||
writeln!(output, " */\n").unwrap();
|
||||
|
||||
// Generate factory function
|
||||
writeln!(output, "/**").unwrap();
|
||||
writeln!(output, " * Create a {} accessor", pattern.name).unwrap();
|
||||
writeln!(output, " * @template T").unwrap();
|
||||
writeln!(output, " * @param {{BrkClientBase}} client").unwrap();
|
||||
writeln!(output, " * @param {{string}} basePath").unwrap();
|
||||
writeln!(output, " * @returns {{{}<T>}}", pattern.name).unwrap();
|
||||
writeln!(output, " */").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
"function create{}(client, basePath) {{",
|
||||
pattern.name
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " return {{").unwrap();
|
||||
|
||||
for (i, index) in pattern.indexes.iter().enumerate() {
|
||||
let field_name = index_to_camel_case(index);
|
||||
let path_segment = index.serialize_long();
|
||||
let comma = if i < pattern.indexes.len() - 1 {
|
||||
","
|
||||
} else {
|
||||
""
|
||||
};
|
||||
writeln!(
|
||||
output,
|
||||
" {}: new MetricNode(client, `${{basePath}}/{}`){}",
|
||||
field_name, path_segment, comma
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
writeln!(output, " }};").unwrap();
|
||||
writeln!(output, "}}\n").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an Index to a camelCase field name (e.g., DateIndex -> byDateIndex)
|
||||
fn index_to_camel_case(index: &Index) -> String {
|
||||
format!("by{}", to_pascal_case(index.serialize_long()))
|
||||
}
|
||||
|
||||
/// Generate structural pattern factory functions
|
||||
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 {
|
||||
// Check if this pattern is parameterizable (has field positions detected)
|
||||
let is_parameterizable = pattern.is_parameterizable();
|
||||
|
||||
// Generate JSDoc 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 = field_to_js_type_generic(field, metadata, pattern.is_generic);
|
||||
writeln!(
|
||||
output,
|
||||
" * @property {{{}}} {}",
|
||||
js_type,
|
||||
to_camel_case(&field.name)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(output, " */\n").unwrap();
|
||||
|
||||
// Generate factory function
|
||||
writeln!(output, "/**").unwrap();
|
||||
writeln!(output, " * Create a {} pattern node", pattern.name).unwrap();
|
||||
writeln!(output, " * @param {{BrkClientBase}} client").unwrap();
|
||||
if is_parameterizable {
|
||||
writeln!(output, " * @param {{string}} acc - Accumulated metric name").unwrap();
|
||||
} else {
|
||||
writeln!(output, " * @param {{string}} basePath").unwrap();
|
||||
}
|
||||
writeln!(output, " * @returns {{{}}}", pattern.name).unwrap();
|
||||
writeln!(output, " */").unwrap();
|
||||
|
||||
let param_name = if is_parameterizable {
|
||||
"acc"
|
||||
} else {
|
||||
"basePath"
|
||||
};
|
||||
writeln!(
|
||||
output,
|
||||
"function create{}(client, {}) {{",
|
||||
pattern.name, param_name
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " return {{").unwrap();
|
||||
|
||||
for (i, field) in pattern.fields.iter().enumerate() {
|
||||
let comma = if i < pattern.fields.len() - 1 {
|
||||
","
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
if is_parameterizable {
|
||||
generate_parameterized_field(output, field, pattern, metadata, comma);
|
||||
} else {
|
||||
generate_tree_path_field(output, field, metadata, comma);
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(output, " }};").unwrap();
|
||||
writeln!(output, "}}\n").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a field using parameterized (prepend/append) metric name construction
|
||||
fn generate_parameterized_field(
|
||||
output: &mut String,
|
||||
field: &PatternField,
|
||||
pattern: &StructuralPattern,
|
||||
metadata: &ClientMetadata,
|
||||
comma: &str,
|
||||
) {
|
||||
let field_name_js = to_camel_case(&field.name);
|
||||
|
||||
// For branch fields, pass the accumulated name to nested pattern
|
||||
if metadata.is_pattern_type(&field.rust_type) {
|
||||
// Get the field position to determine how to transform the accumulated name
|
||||
let child_acc = if let Some(pos) = pattern.get_field_position(&field.name) {
|
||||
match pos {
|
||||
FieldNamePosition::Append(suffix) => format!("`${{acc}}{}`", suffix),
|
||||
FieldNamePosition::Prepend(prefix) => format!("`{}{}`", prefix, "${acc}"),
|
||||
FieldNamePosition::Identity => "acc".to_string(),
|
||||
FieldNamePosition::SetBase(base) => format!("'{}'", base),
|
||||
}
|
||||
} else {
|
||||
// Fallback: append field name
|
||||
format!("`${{acc}}_{}`", field.name)
|
||||
};
|
||||
|
||||
writeln!(
|
||||
output,
|
||||
" {}: create{}(client, {}){}",
|
||||
field_name_js, field.rust_type, child_acc, comma
|
||||
)
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
|
||||
// For leaf fields, construct the metric path based on position
|
||||
let metric_expr = if let Some(pos) = pattern.get_field_position(&field.name) {
|
||||
match pos {
|
||||
FieldNamePosition::Append(suffix) => format!("`/${{acc}}{suffix}`"),
|
||||
FieldNamePosition::Prepend(prefix) => format!("`/{prefix}${{acc}}`"),
|
||||
FieldNamePosition::Identity => "`/${acc}`".to_string(),
|
||||
FieldNamePosition::SetBase(base) => format!("'/{base}'"),
|
||||
}
|
||||
} else {
|
||||
// Fallback: use field name appended
|
||||
format!("`/${{acc}}_{}`", field.name)
|
||||
};
|
||||
|
||||
if metadata.field_uses_accessor(field) {
|
||||
let accessor = metadata.find_index_set_pattern(&field.indexes).unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" {}: create{}(client, {}){}",
|
||||
field_name_js, accessor.name, metric_expr, comma
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" {}: new MetricNode(client, {}){}",
|
||||
field_name_js, metric_expr, comma
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a field using tree path construction (fallback for non-parameterizable patterns)
|
||||
fn generate_tree_path_field(
|
||||
output: &mut String,
|
||||
field: &PatternField,
|
||||
metadata: &ClientMetadata,
|
||||
comma: &str,
|
||||
) {
|
||||
let field_name_js = to_camel_case(&field.name);
|
||||
|
||||
if metadata.is_pattern_type(&field.rust_type) {
|
||||
writeln!(
|
||||
output,
|
||||
" {}: create{}(client, `${{basePath}}/{}`){}",
|
||||
field_name_js, field.rust_type, field.name, comma
|
||||
)
|
||||
.unwrap();
|
||||
} else if metadata.field_uses_accessor(field) {
|
||||
let accessor = metadata.find_index_set_pattern(&field.indexes).unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" {}: create{}(client, `${{basePath}}/{}`){}",
|
||||
field_name_js, accessor.name, field.name, comma
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" {}: new MetricNode(client, `${{basePath}}/{}`){}",
|
||||
field_name_js, field.name, comma
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert pattern field to JavaScript/JSDoc type, with optional generic support
|
||||
fn field_to_js_type_generic(
|
||||
field: &PatternField,
|
||||
metadata: &ClientMetadata,
|
||||
is_generic: bool,
|
||||
) -> String {
|
||||
field_to_js_type_with_generic_value(field, metadata, is_generic, None)
|
||||
}
|
||||
|
||||
/// Convert pattern field to JavaScript/JSDoc type.
|
||||
/// - `is_generic`: If true and field.rust_type is "T", use T in the output
|
||||
/// - `generic_value_type`: For branch fields that reference a generic pattern, this is the concrete type to substitute
|
||||
fn field_to_js_type_with_generic_value(
|
||||
field: &PatternField,
|
||||
metadata: &ClientMetadata,
|
||||
is_generic: bool,
|
||||
generic_value_type: Option<&str>,
|
||||
) -> String {
|
||||
// For generic patterns, use T instead of concrete value type
|
||||
// Also extract inner type from wrappers like Close<Dollars> -> Dollars
|
||||
let value_type = if is_generic && field.rust_type == "T" {
|
||||
"T".to_string()
|
||||
} else {
|
||||
extract_inner_type(&field.rust_type)
|
||||
};
|
||||
|
||||
if metadata.is_pattern_type(&field.rust_type) {
|
||||
// Check if this pattern is generic and we have a value type
|
||||
if metadata.is_pattern_generic(&field.rust_type)
|
||||
&& let Some(vt) = generic_value_type
|
||||
{
|
||||
return format!("{}<{}>", field.rust_type, vt);
|
||||
}
|
||||
field.rust_type.clone()
|
||||
} else if let Some(accessor) = metadata.find_index_set_pattern(&field.indexes) {
|
||||
// Leaf with accessor - use value_type as the generic
|
||||
format!("{}<{}>", accessor.name, value_type)
|
||||
} else {
|
||||
// Leaf - use value_type as the generic
|
||||
format!("MetricNode<{}>", value_type)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Generate tree typedefs
|
||||
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 = HashSet::new();
|
||||
generate_tree_typedef(
|
||||
output,
|
||||
"CatalogTree",
|
||||
catalog,
|
||||
&pattern_lookup,
|
||||
metadata,
|
||||
&mut generated,
|
||||
);
|
||||
}
|
||||
|
||||
/// Recursively generate tree typedefs
|
||||
fn generate_tree_typedef(
|
||||
output: &mut String,
|
||||
name: &str,
|
||||
node: &TreeNode,
|
||||
pattern_lookup: &std::collections::HashMap<Vec<PatternField>, String>,
|
||||
metadata: &ClientMetadata,
|
||||
generated: &mut HashSet<String>,
|
||||
) {
|
||||
let TreeNode::Branch(children) = node else {
|
||||
return;
|
||||
};
|
||||
|
||||
let fields_with_child_info = get_fields_with_child_info(children, name, pattern_lookup);
|
||||
let fields: Vec<PatternField> = fields_with_child_info
|
||||
.iter()
|
||||
.map(|(f, _)| f.clone())
|
||||
.collect();
|
||||
|
||||
// Skip if this matches a pattern (already generated)
|
||||
if pattern_lookup.contains_key(&fields) && pattern_lookup.get(&fields) != Some(&name.to_string())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if generated.contains(name) {
|
||||
return;
|
||||
}
|
||||
generated.insert(name.to_string());
|
||||
|
||||
writeln!(output, "/**").unwrap();
|
||||
writeln!(output, " * @typedef {{Object}} {}", name).unwrap();
|
||||
|
||||
for (field, child_fields) in &fields_with_child_info {
|
||||
let generic_value_type = child_fields
|
||||
.as_ref()
|
||||
.and_then(|cf| metadata.get_generic_value_type(&field.rust_type, cf));
|
||||
let js_type = field_to_js_type_with_generic_value(
|
||||
field,
|
||||
metadata,
|
||||
false,
|
||||
generic_value_type.as_deref(),
|
||||
);
|
||||
writeln!(
|
||||
output,
|
||||
" * @property {{{}}} {}",
|
||||
js_type,
|
||||
to_camel_case(&field.name)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
writeln!(output, " */\n").unwrap();
|
||||
|
||||
// Generate child typedefs
|
||||
for (child_name, child_node) in children {
|
||||
if let TreeNode::Branch(grandchildren) = child_node {
|
||||
let child_fields = get_node_fields(grandchildren, pattern_lookup);
|
||||
if !pattern_lookup.contains_key(&child_fields) {
|
||||
let child_type_name = format!("{}_{}", name, to_pascal_case(child_name));
|
||||
generate_tree_typedef(
|
||||
output,
|
||||
&child_type_name,
|
||||
child_node,
|
||||
pattern_lookup,
|
||||
metadata,
|
||||
generated,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate main client
|
||||
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 catalog tree and API methods"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " * @extends BrkClientBase").unwrap();
|
||||
writeln!(output, " */").unwrap();
|
||||
writeln!(output, "class BrkClient extends BrkClientBase {{").unwrap();
|
||||
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 {{CatalogTree}} */").unwrap();
|
||||
writeln!(output, " this.tree = this._buildTree('');").unwrap();
|
||||
writeln!(output, " }}\n").unwrap();
|
||||
|
||||
// Generate _buildTree method
|
||||
writeln!(output, " /**").unwrap();
|
||||
writeln!(output, " * @private").unwrap();
|
||||
writeln!(output, " * @param {{string}} basePath").unwrap();
|
||||
writeln!(output, " * @returns {{CatalogTree}}").unwrap();
|
||||
writeln!(output, " */").unwrap();
|
||||
writeln!(output, " _buildTree(basePath) {{").unwrap();
|
||||
writeln!(output, " return {{").unwrap();
|
||||
generate_tree_initializer(output, catalog, "", 3, &pattern_lookup, metadata);
|
||||
writeln!(output, " }};").unwrap();
|
||||
writeln!(output, " }}\n").unwrap();
|
||||
|
||||
// Generate API methods
|
||||
generate_api_methods(output, endpoints);
|
||||
|
||||
writeln!(output, "}}\n").unwrap();
|
||||
|
||||
// Export
|
||||
writeln!(
|
||||
output,
|
||||
"export {{ BrkClient, BrkClientBase, BrkError, MetricNode }};"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Generate tree initializer
|
||||
fn generate_tree_initializer(
|
||||
output: &mut String,
|
||||
node: &TreeNode,
|
||||
accumulated_name: &str,
|
||||
indent: usize,
|
||||
pattern_lookup: &std::collections::HashMap<Vec<PatternField>, String>,
|
||||
metadata: &ClientMetadata,
|
||||
) {
|
||||
let indent_str = " ".repeat(indent);
|
||||
|
||||
if let TreeNode::Branch(children) = node {
|
||||
for (i, (child_name, child_node)) in children.iter().enumerate() {
|
||||
let field_name = to_camel_case(child_name);
|
||||
let comma = if i < children.len() - 1 { "," } else { "" };
|
||||
|
||||
match child_node {
|
||||
TreeNode::Leaf(leaf) => {
|
||||
// Use leaf.name() (vec.name()) for API path, not tree path
|
||||
let metric_path = format!("/{}", leaf.name());
|
||||
if let Some(accessor) = metadata.find_index_set_pattern(leaf.indexes()) {
|
||||
writeln!(
|
||||
output,
|
||||
"{}{}: create{}(this, '{}'){}",
|
||||
indent_str, field_name, accessor.name, metric_path, comma
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
"{}{}: new MetricNode(this, '{}'){}",
|
||||
indent_str, field_name, metric_path, comma
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
TreeNode::Branch(grandchildren) => {
|
||||
let child_fields = get_node_fields(grandchildren, pattern_lookup);
|
||||
if let Some(pattern_name) = pattern_lookup.get(&child_fields) {
|
||||
// For parameterized patterns, derive accumulated metric name from first leaf
|
||||
let pattern = metadata
|
||||
.structural_patterns
|
||||
.iter()
|
||||
.find(|p| &p.name == pattern_name);
|
||||
let is_parameterizable =
|
||||
pattern.map(|p| p.is_parameterizable()).unwrap_or(false);
|
||||
|
||||
let arg = if is_parameterizable {
|
||||
// Get the metric base from the first leaf descendant
|
||||
get_pattern_instance_base(child_node, child_name)
|
||||
} else {
|
||||
// Fallback to tree path for non-parameterizable patterns
|
||||
if accumulated_name.is_empty() {
|
||||
format!("/{}", child_name)
|
||||
} else {
|
||||
format!("{}/{}", accumulated_name, child_name)
|
||||
}
|
||||
};
|
||||
|
||||
writeln!(
|
||||
output,
|
||||
"{}{}: create{}(this, '{}'){}",
|
||||
indent_str, field_name, pattern_name, arg, comma
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
// Not a pattern - recurse with accumulated name
|
||||
let child_acc =
|
||||
infer_child_accumulated_name(child_node, accumulated_name, child_name);
|
||||
writeln!(output, "{}{}: {{", indent_str, field_name).unwrap();
|
||||
generate_tree_initializer(
|
||||
output,
|
||||
child_node,
|
||||
&child_acc,
|
||||
indent + 1,
|
||||
pattern_lookup,
|
||||
metadata,
|
||||
);
|
||||
writeln!(output, "{}}}{}", indent_str, comma).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Infer the accumulated metric name for a child node
|
||||
fn infer_child_accumulated_name(node: &TreeNode, parent_acc: &str, field_name: &str) -> String {
|
||||
// Try to infer from first leaf descendant
|
||||
if let Some(leaf_name) = get_first_leaf_name(node) {
|
||||
// Look for field_name in the leaf metric name
|
||||
if let Some(pos) = leaf_name.find(field_name) {
|
||||
// The field_name appears in the metric - use it as base
|
||||
if pos == 0 {
|
||||
// At start - this is the base
|
||||
return field_name.to_string();
|
||||
} else if leaf_name.chars().nth(pos - 1) == Some('_') {
|
||||
// After underscore - likely an append pattern
|
||||
if parent_acc.is_empty() {
|
||||
return field_name.to_string();
|
||||
}
|
||||
return format!("{}_{}", parent_acc, field_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: append field name
|
||||
if parent_acc.is_empty() {
|
||||
field_name.to_string()
|
||||
} else {
|
||||
format!("{}_{}", parent_acc, field_name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate API methods
|
||||
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 return_type = endpoint.response_type.as_deref().unwrap_or("*");
|
||||
|
||||
writeln!(output, " /**").unwrap();
|
||||
if let Some(summary) = &endpoint.summary {
|
||||
writeln!(output, " * {}", summary).unwrap();
|
||||
}
|
||||
|
||||
for param in &endpoint.path_params {
|
||||
let desc = param.description.as_deref().unwrap_or("");
|
||||
writeln!(
|
||||
output,
|
||||
" * @param {{{}}} {} {}",
|
||||
param.param_type, param.name, desc
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
for param in &endpoint.query_params {
|
||||
let optional = if param.required { "" } else { "=" };
|
||||
let desc = param.description.as_deref().unwrap_or("");
|
||||
writeln!(
|
||||
output,
|
||||
" * @param {{{}{}}} [{}] {}",
|
||||
param.param_type, optional, param.name, desc
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
writeln!(output, " * @returns {{Promise<{}>}}", return_type).unwrap();
|
||||
writeln!(output, " */").unwrap();
|
||||
|
||||
let params = build_method_params(endpoint);
|
||||
writeln!(output, " async {}({}) {{", method_name, params).unwrap();
|
||||
|
||||
let path = build_path_template(&endpoint.path, &endpoint.path_params);
|
||||
|
||||
if endpoint.query_params.is_empty() {
|
||||
writeln!(output, " return this.get(`{}`);", path).unwrap();
|
||||
} else {
|
||||
writeln!(output, " const params = new URLSearchParams();").unwrap();
|
||||
for param in &endpoint.query_params {
|
||||
if param.required {
|
||||
writeln!(
|
||||
output,
|
||||
" params.set('{}', String({}));",
|
||||
param.name, param.name
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" if ({} !== undefined) params.set('{}', String({}));",
|
||||
param.name, param.name, param.name
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
writeln!(output, " const query = params.toString();").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" return this.get(`{}${{query ? '?' + query : ''}}`);",
|
||||
path
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
writeln!(output, " }}\n").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(param.name.clone());
|
||||
}
|
||||
for param in &endpoint.query_params {
|
||||
params.push(param.name.clone());
|
||||
}
|
||||
params.join(", ")
|
||||
}
|
||||
|
||||
fn build_path_template(path: &str, path_params: &[super::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
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
fs, io,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use brk_query::Query;
|
||||
use brk_types::{Index, pools};
|
||||
|
||||
use crate::VERSION;
|
||||
|
||||
const AUTO_GENERATED_DISCLAIMER: &str = "//
|
||||
// File auto-generated, any modifications will be overwritten
|
||||
//";
|
||||
|
||||
pub fn generate_js_files(query: &Query, modules_path: &Path) -> io::Result<()> {
|
||||
let path = modules_path.join("brk-client");
|
||||
|
||||
if !fs::exists(&path)? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let path = path.join("generated");
|
||||
fs::create_dir_all(&path)?;
|
||||
|
||||
generate_version_file(&path)?;
|
||||
generate_metrics_file(query, &path)?;
|
||||
generate_pools_file(&path)
|
||||
}
|
||||
|
||||
fn generate_version_file(parent: &Path) -> io::Result<()> {
|
||||
let path = parent.join(Path::new("version.js"));
|
||||
|
||||
let contents = format!(
|
||||
"{AUTO_GENERATED_DISCLAIMER}
|
||||
|
||||
export const VERSION = \"v{VERSION}\";
|
||||
"
|
||||
);
|
||||
|
||||
fs::write(path, contents)
|
||||
}
|
||||
|
||||
fn generate_pools_file(parent: &Path) -> io::Result<()> {
|
||||
let path = parent.join(Path::new("pools.js"));
|
||||
|
||||
let pools = pools();
|
||||
|
||||
let mut contents = format!("{AUTO_GENERATED_DISCLAIMER}\n");
|
||||
|
||||
contents += "
|
||||
/**
|
||||
* @typedef {typeof POOL_ID_TO_POOL_NAME} PoolIdToPoolName
|
||||
* @typedef {keyof PoolIdToPoolName} PoolId
|
||||
*/
|
||||
|
||||
export const POOL_ID_TO_POOL_NAME = /** @type {const} */ ({
|
||||
";
|
||||
|
||||
let mut sorted_pools: Vec<_> = pools.iter().collect();
|
||||
sorted_pools.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
|
||||
contents += &sorted_pools
|
||||
.iter()
|
||||
.map(|pool| {
|
||||
let slug = pool.slug();
|
||||
format!(" {slug}: \"{}\",", pool.name)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
contents += "\n});\n";
|
||||
|
||||
fs::write(path, contents)
|
||||
}
|
||||
|
||||
fn generate_metrics_file(query: &Query, parent: &Path) -> io::Result<()> {
|
||||
let path = parent.join(Path::new("metrics.js"));
|
||||
|
||||
let indexes = Index::all();
|
||||
|
||||
let mut contents = format!(
|
||||
"{AUTO_GENERATED_DISCLAIMER}
|
||||
|
||||
export const INDEXES = /** @type {{const}} */ ([
|
||||
{}
|
||||
]);
|
||||
|
||||
/**
|
||||
* @typedef {{typeof INDEXES[number]}} IndexName
|
||||
*/
|
||||
",
|
||||
indexes
|
||||
.iter()
|
||||
.map(|i| format!(" \"{}\"", i.serialize_long()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n")
|
||||
);
|
||||
|
||||
// contents += &indexes
|
||||
// .iter()
|
||||
// .map(|i| format!(" * @typedef {{\"{}\"}} {i}", i.serialize_long()))
|
||||
// .collect::<Vec<_>>()
|
||||
// .join("\n");
|
||||
|
||||
// contents += &format!(
|
||||
// "
|
||||
// * @typedef {{{}}} Index
|
||||
// */
|
||||
// ",
|
||||
// indexes
|
||||
// .iter()
|
||||
// .map(|i| i.to_string())
|
||||
// .collect::<Vec<_>>()
|
||||
// .join(" | ")
|
||||
// );
|
||||
|
||||
let mut unique_index_groups = BTreeMap::new();
|
||||
|
||||
let mut word_to_freq: BTreeMap<_, usize> = BTreeMap::new();
|
||||
query.metric_to_index_to_vec().keys().for_each(|metric| {
|
||||
metric.split("_").for_each(|word| {
|
||||
*word_to_freq.entry(word).or_default() += 1;
|
||||
});
|
||||
});
|
||||
let mut word_to_freq = word_to_freq.into_iter().collect::<Vec<_>>();
|
||||
word_to_freq.sort_unstable_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
|
||||
let words = word_to_freq
|
||||
.into_iter()
|
||||
.map(|(str, _)| str)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
contents += &format!(
|
||||
"
|
||||
export const INDEX_TO_WORD = [
|
||||
{}
|
||||
];
|
||||
|
||||
",
|
||||
words
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, word)| format!("\"{word}\", // {}", index_to_letters(index)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n ")
|
||||
);
|
||||
|
||||
let word_to_base62 = words
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, w)| (w, index_to_letters(i)))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let mut ser_metric_to_indexes = "
|
||||
/** @type {Record<string, IndexName[]>} */
|
||||
export const COMPRESSED_METRIC_TO_INDEXES = {
|
||||
"
|
||||
.to_string();
|
||||
|
||||
query
|
||||
.metric_to_index_to_vec()
|
||||
.iter()
|
||||
.for_each(|(metric, index_to_vec)| {
|
||||
let indexes = index_to_vec
|
||||
.keys()
|
||||
.map(|i| format!("\"{}\"", i.serialize_long()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let indexes = format!("[{indexes}]");
|
||||
let unique = unique_index_groups.len();
|
||||
let index = index_to_letters(*unique_index_groups.entry(indexes).or_insert(unique));
|
||||
|
||||
let compressed_metric = metric.split('_').fold(String::new(), |mut acc, w| {
|
||||
if !acc.is_empty() {
|
||||
acc.push('_');
|
||||
}
|
||||
acc.push_str(&word_to_base62[w]);
|
||||
acc
|
||||
});
|
||||
|
||||
ser_metric_to_indexes += &format!(" {compressed_metric}: {index},\n");
|
||||
});
|
||||
|
||||
ser_metric_to_indexes += "};
|
||||
";
|
||||
|
||||
let mut sorted_groups: Vec<_> = unique_index_groups.into_iter().collect();
|
||||
sorted_groups.sort_by_key(|(_, index)| *index);
|
||||
sorted_groups.into_iter().for_each(|(group, index)| {
|
||||
let index = index_to_letters(index);
|
||||
contents += &format!("/** @type {{IndexName[]}} */\nconst {index} = {group};\n");
|
||||
});
|
||||
|
||||
contents += &ser_metric_to_indexes;
|
||||
|
||||
fs::write(path, contents)
|
||||
}
|
||||
|
||||
fn index_to_letters(mut index: usize) -> String {
|
||||
if index < 52 {
|
||||
return (index_to_char(index) as char).to_string();
|
||||
}
|
||||
let mut result = [0u8; 8];
|
||||
let mut pos = 8;
|
||||
loop {
|
||||
pos -= 1;
|
||||
result[pos] = index_to_char(index % 52);
|
||||
index /= 52;
|
||||
if index == 0 {
|
||||
break;
|
||||
}
|
||||
index -= 1;
|
||||
}
|
||||
unsafe { String::from_utf8_unchecked(result[pos..].to_vec()) }
|
||||
}
|
||||
|
||||
fn index_to_char(index: usize) -> u8 {
|
||||
match index {
|
||||
0..=25 => b'A' + index as u8,
|
||||
26..=51 => b'a' + (index - 26) as u8,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
// fn letters_to_index(s: &str) -> usize {
|
||||
// let mut result = 0;
|
||||
// for byte in s.bytes() {
|
||||
// let value = char_to_index(byte) as usize;
|
||||
// result = result * 52 + value + 1;
|
||||
// }
|
||||
// result - 1
|
||||
// }
|
||||
|
||||
// fn char_to_index(byte: u8) -> u8 {
|
||||
// match byte {
|
||||
// b'A'..=b'Z' => byte - b'A',
|
||||
// b'a'..=b'z' => byte - b'a' + 26,
|
||||
// _ => 255, // Invalid
|
||||
// }
|
||||
// }
|
||||
@@ -1,273 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
|
||||
use oas3::Spec;
|
||||
use oas3::spec::{ObjectOrReference, Operation, ParameterIn, PathItem, Schema, SchemaTypeSet};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Type schema extracted from OpenAPI components
|
||||
pub type TypeSchemas = BTreeMap<String, Value>;
|
||||
|
||||
/// Endpoint information extracted from OpenAPI spec
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Endpoint {
|
||||
/// HTTP method (GET, POST, etc.)
|
||||
pub method: String,
|
||||
/// Path template (e.g., "/blocks/{hash}")
|
||||
pub path: String,
|
||||
/// Operation ID (e.g., "getBlockByHash")
|
||||
pub operation_id: Option<String>,
|
||||
/// Summary/description
|
||||
pub summary: Option<String>,
|
||||
/// Tags for grouping
|
||||
pub tags: Vec<String>,
|
||||
/// Path parameters
|
||||
pub path_params: Vec<Parameter>,
|
||||
/// Query parameters
|
||||
pub query_params: Vec<Parameter>,
|
||||
/// Response type (simplified)
|
||||
pub response_type: Option<String>,
|
||||
/// Whether this endpoint is deprecated
|
||||
pub deprecated: bool,
|
||||
}
|
||||
|
||||
impl Endpoint {
|
||||
/// Returns true if this endpoint should be included in client generation.
|
||||
/// Only non-deprecated GET endpoints are included.
|
||||
pub fn should_generate(&self) -> bool {
|
||||
self.method == "GET" && !self.deprecated
|
||||
}
|
||||
|
||||
/// Returns the operation ID or generates one from the path.
|
||||
/// The returned string uses the raw case from the spec (typically camelCase).
|
||||
pub fn operation_name(&self) -> String {
|
||||
if let Some(op_id) = &self.operation_id {
|
||||
return op_id.clone();
|
||||
}
|
||||
// Generate from path: /api/blocks/{hash} -> "get_api_blocks_by_hash"
|
||||
let parts: Vec<String> = self
|
||||
.path
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|segment| {
|
||||
if let Some(param) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
|
||||
format!("by_{}", param)
|
||||
} else {
|
||||
segment.to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
format!("get_{}", parts.join("_"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameter information
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Parameter {
|
||||
pub name: String,
|
||||
pub required: bool,
|
||||
pub param_type: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse OpenAPI spec from JSON string
|
||||
///
|
||||
/// Pre-processes the JSON to handle oas3 limitations:
|
||||
/// - Removes unsupported siblings from `$ref` objects (oas3 only supports `summary` and `description`)
|
||||
pub fn parse_openapi_json(json: &str) -> io::Result<Spec> {
|
||||
let mut value: Value =
|
||||
serde_json::from_str(json).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
// Clean up for oas3 compatibility
|
||||
clean_for_oas3(&mut value);
|
||||
|
||||
let cleaned_json =
|
||||
serde_json::to_string(&value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
oas3::from_json(&cleaned_json).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
|
||||
/// Extract type schemas from OpenAPI JSON
|
||||
pub fn extract_schemas(json: &str) -> TypeSchemas {
|
||||
let Ok(value) = serde_json::from_str::<Value>(json) else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
|
||||
value
|
||||
.get("components")
|
||||
.and_then(|c| c.get("schemas"))
|
||||
.and_then(|s| s.as_object())
|
||||
.map(|schemas| {
|
||||
schemas
|
||||
.iter()
|
||||
.map(|(name, schema)| (name.clone(), schema.clone()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Clean up OpenAPI spec for oas3 compatibility.
|
||||
/// - Removes unsupported siblings from $ref objects (oas3 only supports summary and description)
|
||||
/// - Converts boolean schemas to object schemas (oas3 doesn't handle `"schema": true`)
|
||||
fn clean_for_oas3(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
// Handle $ref with unsupported siblings
|
||||
if map.contains_key("$ref") {
|
||||
map.retain(|k, _| k == "$ref" || k == "summary" || k == "description");
|
||||
} else {
|
||||
// Convert boolean schemas to empty object schemas
|
||||
if let Some(schema) = map.get_mut("schema")
|
||||
&& schema.is_boolean()
|
||||
{
|
||||
*schema = Value::Object(serde_json::Map::new());
|
||||
}
|
||||
for v in map.values_mut() {
|
||||
clean_for_oas3(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
for v in arr {
|
||||
clean_for_oas3(v);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract all endpoints from OpenAPI spec
|
||||
pub fn extract_endpoints(spec: &Spec) -> Vec<Endpoint> {
|
||||
let mut endpoints = Vec::new();
|
||||
|
||||
let Some(paths) = &spec.paths else {
|
||||
return endpoints;
|
||||
};
|
||||
|
||||
for (path, path_item) in paths {
|
||||
for (method, operation) in get_operations(path_item) {
|
||||
if let Some(endpoint) = extract_endpoint(path, &method, operation) {
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
endpoints
|
||||
}
|
||||
|
||||
fn get_operations(path_item: &PathItem) -> Vec<(String, &Operation)> {
|
||||
let mut ops = Vec::new();
|
||||
if let Some(op) = &path_item.get {
|
||||
ops.push(("GET".to_string(), op));
|
||||
}
|
||||
if let Some(op) = &path_item.post {
|
||||
ops.push(("POST".to_string(), op));
|
||||
}
|
||||
if let Some(op) = &path_item.put {
|
||||
ops.push(("PUT".to_string(), op));
|
||||
}
|
||||
if let Some(op) = &path_item.delete {
|
||||
ops.push(("DELETE".to_string(), op));
|
||||
}
|
||||
if let Some(op) = &path_item.patch {
|
||||
ops.push(("PATCH".to_string(), op));
|
||||
}
|
||||
ops
|
||||
}
|
||||
|
||||
fn extract_endpoint(path: &str, method: &str, operation: &Operation) -> Option<Endpoint> {
|
||||
let path_params = extract_parameters(operation, ParameterIn::Path);
|
||||
let query_params = extract_parameters(operation, ParameterIn::Query);
|
||||
|
||||
let response_type = extract_response_type(operation);
|
||||
|
||||
Some(Endpoint {
|
||||
method: method.to_string(),
|
||||
path: path.to_string(),
|
||||
operation_id: operation.operation_id.clone(),
|
||||
summary: operation
|
||||
.summary
|
||||
.clone()
|
||||
.or_else(|| operation.description.clone()),
|
||||
tags: operation.tags.clone(),
|
||||
path_params,
|
||||
query_params,
|
||||
response_type,
|
||||
deprecated: operation.deprecated.unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_parameters(operation: &Operation, location: ParameterIn) -> Vec<Parameter> {
|
||||
operation
|
||||
.parameters
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
ObjectOrReference::Object(param) if param.location == location => Some(Parameter {
|
||||
name: param.name.clone(),
|
||||
required: param.required.unwrap_or(false),
|
||||
param_type: "string".to_string(), // Simplified
|
||||
description: param.description.clone(),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extract_response_type(operation: &Operation) -> Option<String> {
|
||||
let responses = operation.responses.as_ref()?;
|
||||
|
||||
// Look for 200 OK response
|
||||
let response = responses.get("200")?;
|
||||
|
||||
match response {
|
||||
ObjectOrReference::Object(response) => {
|
||||
// Look for JSON content
|
||||
let content = response.content.get("application/json")?;
|
||||
|
||||
match &content.schema {
|
||||
Some(ObjectOrReference::Ref { ref_path, .. }) => {
|
||||
// Extract type name from reference like "#/components/schemas/Block"
|
||||
Some(ref_path.rsplit('/').next()?.to_string())
|
||||
}
|
||||
Some(ObjectOrReference::Object(schema)) => schema_to_type_name(schema),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
ObjectOrReference::Ref { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn schema_type_from_schema(schema: &Schema) -> Option<String> {
|
||||
match schema {
|
||||
Schema::Boolean(_) => Some("boolean".to_string()),
|
||||
Schema::Object(obj_or_ref) => match obj_or_ref.as_ref() {
|
||||
ObjectOrReference::Object(obj_schema) => schema_to_type_name(obj_schema),
|
||||
ObjectOrReference::Ref { ref_path, .. } => {
|
||||
ref_path.rsplit('/').next().map(|s| s.to_string())
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn schema_to_type_name(schema: &oas3::spec::ObjectSchema) -> Option<String> {
|
||||
let schema_type = schema.schema_type.as_ref()?;
|
||||
|
||||
match schema_type {
|
||||
SchemaTypeSet::Single(t) => match t {
|
||||
oas3::spec::SchemaType::String => Some("string".to_string()),
|
||||
oas3::spec::SchemaType::Number => Some("number".to_string()),
|
||||
oas3::spec::SchemaType::Integer => Some("number".to_string()),
|
||||
oas3::spec::SchemaType::Boolean => Some("boolean".to_string()),
|
||||
oas3::spec::SchemaType::Array => {
|
||||
let inner = match &schema.items {
|
||||
Some(boxed_schema) => schema_type_from_schema(boxed_schema),
|
||||
None => Some("*".to_string()),
|
||||
};
|
||||
inner.map(|t| format!("{}[]", t))
|
||||
}
|
||||
oas3::spec::SchemaType::Object => Some("Object".to_string()),
|
||||
oas3::spec::SchemaType::Null => Some("null".to_string()),
|
||||
},
|
||||
SchemaTypeSet::Multiple(_) => Some("*".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -1,962 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Write as FmtWrite;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use brk_types::{Index, TreeNode};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
ClientMetadata, Endpoint, FieldNamePosition, IndexSetPattern, PatternField, StructuralPattern,
|
||||
TypeSchemas, extract_inner_type, get_fields_with_child_info, get_node_fields,
|
||||
get_pattern_instance_base, is_enum_schema, to_pascal_case, to_snake_case,
|
||||
};
|
||||
|
||||
/// Generate Python client from metadata and OpenAPI endpoints
|
||||
pub fn generate_python_client(
|
||||
metadata: &ClientMetadata,
|
||||
endpoints: &[Endpoint],
|
||||
schemas: &TypeSchemas,
|
||||
output_dir: &Path,
|
||||
) -> io::Result<()> {
|
||||
let mut output = String::new();
|
||||
|
||||
// Header
|
||||
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 typing import TypeVar, Generic, Any, Optional, List, Literal, TypedDict"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, "import httpx\n").unwrap();
|
||||
|
||||
// Type variable for generic MetricNode
|
||||
writeln!(output, "T = TypeVar('T')\n").unwrap();
|
||||
|
||||
// Generate type definitions from OpenAPI schemas (now includes leaf types from catalog)
|
||||
generate_type_definitions(&mut output, schemas);
|
||||
|
||||
// Generate base client class
|
||||
generate_base_client(&mut output);
|
||||
|
||||
// Generate MetricNode class
|
||||
generate_metric_node(&mut output);
|
||||
|
||||
// Generate index accessor classes
|
||||
generate_index_accessors(&mut output, &metadata.index_set_patterns);
|
||||
|
||||
// Generate structural pattern classes
|
||||
generate_structural_patterns(&mut output, &metadata.structural_patterns, metadata);
|
||||
|
||||
// Generate tree classes
|
||||
generate_tree_classes(&mut output, &metadata.catalog, metadata);
|
||||
|
||||
// Generate main client with tree and API methods
|
||||
generate_main_client(&mut output, endpoints);
|
||||
|
||||
fs::write(output_dir.join("client.py"), output)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate Python type definitions from OpenAPI schemas
|
||||
fn generate_type_definitions(output: &mut String, schemas: &TypeSchemas) {
|
||||
if schemas.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
writeln!(output, "# Type definitions\n").unwrap();
|
||||
|
||||
// Sort types by dependencies (types that reference other types must come after)
|
||||
let sorted_names = topological_sort_schemas(schemas);
|
||||
|
||||
for name in sorted_names {
|
||||
let Some(schema) = schemas.get(&name) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
|
||||
// Object type -> TypedDict
|
||||
writeln!(output, "class {}(TypedDict):", name).unwrap();
|
||||
for (prop_name, prop_schema) in props {
|
||||
let prop_type = schema_to_python_type_ctx(prop_schema, Some(&name));
|
||||
let safe_name = escape_python_keyword(prop_name);
|
||||
writeln!(output, " {}: {}", safe_name, prop_type).unwrap();
|
||||
}
|
||||
writeln!(output).unwrap();
|
||||
} else if is_enum_schema(schema) {
|
||||
// Enum type -> Literal union
|
||||
let py_type = schema_to_python_type_ctx(schema, Some(&name));
|
||||
writeln!(output, "{} = {}", name, py_type).unwrap();
|
||||
} else {
|
||||
// Primitive type alias
|
||||
let py_type = schema_to_python_type_ctx(schema, Some(&name));
|
||||
writeln!(output, "{} = {}", name, py_type).unwrap();
|
||||
}
|
||||
}
|
||||
writeln!(output).unwrap();
|
||||
}
|
||||
|
||||
/// Topologically sort schema names so dependencies come before dependents.
|
||||
/// Types that reference other types (via $ref) must be defined after their dependencies.
|
||||
fn topological_sort_schemas(schemas: &TypeSchemas) -> Vec<String> {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
// Build dependency graph
|
||||
let mut deps: HashMap<String, HashSet<String>> = HashMap::new();
|
||||
for (name, schema) in schemas {
|
||||
let mut type_deps = HashSet::new();
|
||||
collect_schema_refs(schema, &mut type_deps);
|
||||
// Only keep deps that are in our schemas
|
||||
type_deps.retain(|d| schemas.contains_key(d));
|
||||
deps.insert(name.clone(), type_deps);
|
||||
}
|
||||
|
||||
// Kahn's algorithm for topological sort
|
||||
let mut in_degree: HashMap<String, usize> = HashMap::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: HashSet<_> = 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 std::collections::HashSet<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_path.rsplit('/').next()
|
||||
{
|
||||
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_ctx(s, current_type))
|
||||
.unwrap_or_else(|| "Any".to_string());
|
||||
format!("List[{}]", item_type)
|
||||
}
|
||||
"object" => {
|
||||
// Check if it has additionalProperties (dict-like)
|
||||
if let Some(add_props) = schema.get("additionalProperties") {
|
||||
let value_type = schema_to_python_type_ctx(add_props, current_type);
|
||||
return format!("dict[str, {}]", value_type);
|
||||
}
|
||||
"dict".to_string()
|
||||
}
|
||||
_ => "Any".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert JSON Schema to Python type with context for detecting self-references
|
||||
fn schema_to_python_type_ctx(schema: &Value, current_type: Option<&str>) -> String {
|
||||
// Handle allOf (try each element until we find a resolvable type)
|
||||
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_ctx(item, current_type);
|
||||
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_path.rsplit('/').next().unwrap_or("Any");
|
||||
// Quote self-references to handle recursive types
|
||||
if current_type == Some(type_name) {
|
||||
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(", "));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle type field (can be string or array of strings)
|
||||
if let Some(ty) = schema.get("type") {
|
||||
// Handle array of types like ["string", "null"] for Optional
|
||||
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") // Filter out null for cleaner Optional handling
|
||||
.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 = types.join(" | ");
|
||||
return if has_null {
|
||||
format!("Optional[{}]", union)
|
||||
} else {
|
||||
union
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle single type string
|
||||
if let Some(ty_str) = ty.as_str() {
|
||||
return json_type_to_python(ty_str, schema, current_type);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle anyOf/oneOf
|
||||
if let Some(variants) = schema
|
||||
.get("anyOf")
|
||||
.or_else(|| schema.get("oneOf"))
|
||||
.and_then(|v| v.as_array())
|
||||
{
|
||||
let types: Vec<String> = variants
|
||||
.iter()
|
||||
.map(|v| schema_to_python_type_ctx(v, current_type))
|
||||
.collect();
|
||||
// Filter out Any and null for cleaner unions
|
||||
let filtered: Vec<_> = types.iter().filter(|t| *t != "Any").collect();
|
||||
if !filtered.is_empty() {
|
||||
return filtered
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | ");
|
||||
}
|
||||
return 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()
|
||||
}
|
||||
|
||||
/// Make a name safe for Python: escape keywords and prefix digit-starting names
|
||||
fn escape_python_keyword(name: &str) -> String {
|
||||
const PYTHON_KEYWORDS: &[&str] = &[
|
||||
"False", "None", "True", "and", "as", "assert", "async", "await", "break", "class",
|
||||
"continue", "def", "del", "elif", "else", "except", "finally", "for", "from", "global",
|
||||
"if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return",
|
||||
"try", "while", "with", "yield",
|
||||
];
|
||||
// Names starting with digit need underscore prefix
|
||||
let name = if name
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c.is_ascii_digit())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
format!("_{}", name)
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
// Reserved keywords get underscore suffix
|
||||
if PYTHON_KEYWORDS.contains(&name.as_str()) {
|
||||
format!("{}_", name)
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the base BrkClient class with HTTP functionality
|
||||
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):
|
||||
self.base_url = base_url
|
||||
self.timeout = timeout
|
||||
self._client = httpx.Client(timeout=timeout)
|
||||
|
||||
def get(self, path: str) -> Any:
|
||||
"""Make a GET request."""
|
||||
try:
|
||||
response = self._client.get(f"{{self.base_url}}{{path}}")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise BrkError(f"HTTP error: {{e.response.status_code}}", e.response.status_code)
|
||||
except httpx.RequestError as e:
|
||||
raise BrkError(str(e))
|
||||
|
||||
def close(self):
|
||||
"""Close the HTTP client."""
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Generate the MetricNode class
|
||||
fn generate_metric_node(output: &mut String) {
|
||||
writeln!(
|
||||
output,
|
||||
r#"class MetricNode(Generic[T]):
|
||||
"""A metric node that can fetch data for different indexes."""
|
||||
|
||||
def __init__(self, client: BrkClientBase, path: str):
|
||||
self._client = client
|
||||
self._path = path
|
||||
|
||||
def get(self) -> List[T]:
|
||||
"""Fetch all data points for this metric."""
|
||||
return self._client.get(self._path)
|
||||
|
||||
def get_range(self, from_date: str, to_date: str) -> List[T]:
|
||||
"""Fetch data points within a date range."""
|
||||
return self._client.get(f"{{self._path}}?from={{from_date}}&to={{to_date}}")
|
||||
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Generate index accessor classes
|
||||
fn generate_index_accessors(output: &mut String, patterns: &[IndexSetPattern]) {
|
||||
if patterns.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
writeln!(output, "# Index accessor classes\n").unwrap();
|
||||
|
||||
for pattern in patterns {
|
||||
writeln!(output, "class {}(Generic[T]):", pattern.name).unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" \"\"\"Index accessor for metrics with {} indexes.\"\"\"",
|
||||
pattern.indexes.len()
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " ").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" def __init__(self, client: BrkClientBase, base_path: str):"
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
for index in &pattern.indexes {
|
||||
let field_name = index_to_snake_case(index);
|
||||
let path_segment = index.serialize_long();
|
||||
writeln!(
|
||||
output,
|
||||
" self.{}: MetricNode[T] = MetricNode(client, f'{{base_path}}/{}')",
|
||||
field_name, path_segment
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
writeln!(output).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an Index to a snake_case field name (e.g., DateIndex -> by_date_index)
|
||||
fn index_to_snake_case(index: &Index) -> String {
|
||||
format!("by_{}", to_snake_case(index.serialize_long()))
|
||||
}
|
||||
|
||||
/// Generate structural pattern classes
|
||||
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 {
|
||||
let is_parameterizable = pattern.is_parameterizable();
|
||||
|
||||
// For generic patterns, inherit from Generic[T]
|
||||
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();
|
||||
writeln!(output, " ").unwrap();
|
||||
|
||||
if is_parameterizable {
|
||||
writeln!(
|
||||
output,
|
||||
" def __init__(self, client: BrkClientBase, acc: str):"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" \"\"\"Create pattern node with accumulated metric name.\"\"\""
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" def __init__(self, client: BrkClientBase, base_path: str):"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
for field in &pattern.fields {
|
||||
if is_parameterizable {
|
||||
generate_parameterized_python_field(output, field, pattern, metadata);
|
||||
} else {
|
||||
generate_tree_path_python_field(output, field, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(output).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a field using parameterized (prepend/append) metric name construction
|
||||
fn generate_parameterized_python_field(
|
||||
output: &mut String,
|
||||
field: &PatternField,
|
||||
pattern: &StructuralPattern,
|
||||
metadata: &ClientMetadata,
|
||||
) {
|
||||
let field_name = to_snake_case(&field.name);
|
||||
let py_type = field_to_python_type_generic(field, metadata, pattern.is_generic);
|
||||
|
||||
// For branch fields, pass the accumulated name to nested pattern
|
||||
if metadata.is_pattern_type(&field.rust_type) {
|
||||
let child_acc = if let Some(pos) = pattern.get_field_position(&field.name) {
|
||||
match pos {
|
||||
FieldNamePosition::Append(suffix) => format!("f'{{acc}}{}'", suffix),
|
||||
FieldNamePosition::Prepend(prefix) => format!("f'{}{{acc}}'", prefix),
|
||||
FieldNamePosition::Identity => "acc".to_string(),
|
||||
FieldNamePosition::SetBase(base) => format!("'{}'", base),
|
||||
}
|
||||
} else {
|
||||
format!("f'{{acc}}_{}'", field.name)
|
||||
};
|
||||
|
||||
writeln!(
|
||||
output,
|
||||
" self.{}: {} = {}(client, {})",
|
||||
field_name, py_type, field.rust_type, child_acc
|
||||
)
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
|
||||
// For leaf fields, construct the metric path based on position
|
||||
let metric_expr = if let Some(pos) = pattern.get_field_position(&field.name) {
|
||||
match pos {
|
||||
FieldNamePosition::Append(suffix) => format!("f'/{{acc}}{}'", suffix),
|
||||
FieldNamePosition::Prepend(prefix) => format!("f'/{}{{acc}}'", prefix),
|
||||
FieldNamePosition::Identity => "f'/{acc}'".to_string(),
|
||||
FieldNamePosition::SetBase(base) => format!("'/{}'", base),
|
||||
}
|
||||
} else {
|
||||
format!("f'/{{acc}}_{}'", field.name)
|
||||
};
|
||||
|
||||
if metadata.field_uses_accessor(field) {
|
||||
let accessor = metadata.find_index_set_pattern(&field.indexes).unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" self.{}: {} = {}(client, {})",
|
||||
field_name, py_type, accessor.name, metric_expr
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" self.{}: {} = MetricNode(client, {})",
|
||||
field_name, py_type, metric_expr
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a field using tree path construction (fallback for non-parameterizable patterns)
|
||||
fn generate_tree_path_python_field(
|
||||
output: &mut String,
|
||||
field: &PatternField,
|
||||
metadata: &ClientMetadata,
|
||||
) {
|
||||
let field_name = to_snake_case(&field.name);
|
||||
let py_type = field_to_python_type(field, metadata);
|
||||
|
||||
if metadata.is_pattern_type(&field.rust_type) {
|
||||
writeln!(
|
||||
output,
|
||||
" self.{}: {} = {}(client, f'{{base_path}}/{}')",
|
||||
field_name, py_type, field.rust_type, field.name
|
||||
)
|
||||
.unwrap();
|
||||
} else if metadata.field_uses_accessor(field) {
|
||||
let accessor = metadata.find_index_set_pattern(&field.indexes).unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" self.{}: {} = {}(client, f'{{base_path}}/{}')",
|
||||
field_name, py_type, accessor.name, field.name
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" self.{}: {} = MetricNode(client, f'{{base_path}}/{}')",
|
||||
field_name, py_type, field.name
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert pattern field to Python type annotation
|
||||
fn field_to_python_type(field: &PatternField, metadata: &ClientMetadata) -> String {
|
||||
field_to_python_type_generic(field, metadata, false)
|
||||
}
|
||||
|
||||
/// Convert pattern field to Python type annotation, with optional generic support
|
||||
fn field_to_python_type_generic(
|
||||
field: &PatternField,
|
||||
metadata: &ClientMetadata,
|
||||
is_generic: bool,
|
||||
) -> String {
|
||||
field_to_python_type_with_generic_value(field, metadata, is_generic, None)
|
||||
}
|
||||
|
||||
/// Convert pattern field to Python type annotation.
|
||||
/// - `is_generic`: If true and field.rust_type is "T", use T in the output
|
||||
/// - `generic_value_type`: For branch fields that reference a generic pattern, this is the concrete type to substitute
|
||||
fn field_to_python_type_with_generic_value(
|
||||
field: &PatternField,
|
||||
metadata: &ClientMetadata,
|
||||
is_generic: bool,
|
||||
generic_value_type: Option<&str>,
|
||||
) -> String {
|
||||
// For generic patterns, use T instead of concrete value type
|
||||
// Also extract inner type from wrappers like Close<Dollars> -> Dollars
|
||||
let value_type = if is_generic && field.rust_type == "T" {
|
||||
"T".to_string()
|
||||
} else {
|
||||
extract_inner_type(&field.rust_type)
|
||||
};
|
||||
|
||||
if metadata.is_pattern_type(&field.rust_type) {
|
||||
// Check if this pattern is generic and we have a value type
|
||||
if metadata.is_pattern_generic(&field.rust_type)
|
||||
&& let Some(vt) = generic_value_type
|
||||
{
|
||||
return format!("{}[{}]", field.rust_type, vt);
|
||||
}
|
||||
field.rust_type.clone()
|
||||
} else if let Some(accessor) = metadata.find_index_set_pattern(&field.indexes) {
|
||||
// Leaf with accessor - use value_type as the generic
|
||||
format!("{}[{}]", accessor.name, value_type)
|
||||
} else {
|
||||
// Leaf - use value_type as the generic
|
||||
format!("MetricNode[{}]", value_type)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Generate tree classes
|
||||
fn generate_tree_classes(output: &mut String, catalog: &TreeNode, metadata: &ClientMetadata) {
|
||||
writeln!(output, "# Catalog tree classes\n").unwrap();
|
||||
|
||||
let pattern_lookup = metadata.pattern_lookup();
|
||||
let mut generated = HashSet::new();
|
||||
generate_tree_class(
|
||||
output,
|
||||
"CatalogTree",
|
||||
catalog,
|
||||
&pattern_lookup,
|
||||
metadata,
|
||||
&mut generated,
|
||||
);
|
||||
}
|
||||
|
||||
/// Recursively generate tree classes
|
||||
fn generate_tree_class(
|
||||
output: &mut String,
|
||||
name: &str,
|
||||
node: &TreeNode,
|
||||
pattern_lookup: &std::collections::HashMap<Vec<PatternField>, String>,
|
||||
metadata: &ClientMetadata,
|
||||
generated: &mut HashSet<String>,
|
||||
) {
|
||||
let TreeNode::Branch(children) = node else {
|
||||
return;
|
||||
};
|
||||
|
||||
let fields_with_child_info = get_fields_with_child_info(children, name, pattern_lookup);
|
||||
let fields: Vec<PatternField> = fields_with_child_info
|
||||
.iter()
|
||||
.map(|(f, _)| f.clone())
|
||||
.collect();
|
||||
|
||||
// Skip if this matches a pattern (already generated)
|
||||
if pattern_lookup.contains_key(&fields) && pattern_lookup.get(&fields) != Some(&name.to_string())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if generated.contains(name) {
|
||||
return;
|
||||
}
|
||||
generated.insert(name.to_string());
|
||||
|
||||
writeln!(output, "class {}:", name).unwrap();
|
||||
writeln!(output, " \"\"\"Catalog tree node.\"\"\"").unwrap();
|
||||
writeln!(output, " ").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" def __init__(self, client: BrkClientBase, base_path: str = ''):"
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
for ((field, child_fields_opt), (child_name, child_node)) in
|
||||
fields_with_child_info.iter().zip(children.iter())
|
||||
{
|
||||
let generic_value_type = child_fields_opt
|
||||
.as_ref()
|
||||
.and_then(|cf| metadata.get_generic_value_type(&field.rust_type, cf));
|
||||
let py_type = field_to_python_type_with_generic_value(
|
||||
field,
|
||||
metadata,
|
||||
false,
|
||||
generic_value_type.as_deref(),
|
||||
);
|
||||
let field_name_py = to_snake_case(&field.name);
|
||||
|
||||
if metadata.is_pattern_type(&field.rust_type) {
|
||||
let pattern = metadata.find_pattern(&field.rust_type);
|
||||
let is_parameterizable = pattern.is_some_and(|p| p.is_parameterizable());
|
||||
|
||||
if is_parameterizable {
|
||||
let metric_base = get_pattern_instance_base(child_node, child_name);
|
||||
writeln!(
|
||||
output,
|
||||
" self.{}: {} = {}(client, '{}')",
|
||||
field_name_py, py_type, field.rust_type, metric_base
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" self.{}: {} = {}(client, f'{{base_path}}/{}')",
|
||||
field_name_py, py_type, field.rust_type, field.name
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
} else if metadata.field_uses_accessor(field) {
|
||||
let metric_path = if let TreeNode::Leaf(leaf) = child_node {
|
||||
format!("/{}", leaf.name())
|
||||
} else {
|
||||
format!("{{base_path}}/{}", field.name)
|
||||
};
|
||||
let accessor = metadata.find_index_set_pattern(&field.indexes).unwrap();
|
||||
if metric_path.contains("{base_path}") {
|
||||
writeln!(
|
||||
output,
|
||||
" self.{}: {} = {}(client, f'{}')",
|
||||
field_name_py, py_type, accessor.name, metric_path
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" self.{}: {} = {}(client, '{}')",
|
||||
field_name_py, py_type, accessor.name, metric_path
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
} else {
|
||||
let metric_path = if let TreeNode::Leaf(leaf) = child_node {
|
||||
format!("/{}", leaf.name())
|
||||
} else {
|
||||
format!("{{base_path}}/{}", field.name)
|
||||
};
|
||||
if metric_path.contains("{base_path}") {
|
||||
writeln!(
|
||||
output,
|
||||
" self.{}: {} = MetricNode(client, f'{}')",
|
||||
field_name_py, py_type, metric_path
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" self.{}: {} = MetricNode(client, '{}')",
|
||||
field_name_py, py_type, metric_path
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(output).unwrap();
|
||||
|
||||
// Generate child classes
|
||||
for (child_name, child_node) in children {
|
||||
if let TreeNode::Branch(grandchildren) = child_node {
|
||||
let child_fields = get_node_fields(grandchildren, pattern_lookup);
|
||||
if !pattern_lookup.contains_key(&child_fields) {
|
||||
let child_class_name = format!("{}_{}", name, to_pascal_case(child_name));
|
||||
generate_tree_class(
|
||||
output,
|
||||
&child_class_name,
|
||||
child_node,
|
||||
pattern_lookup,
|
||||
metadata,
|
||||
generated,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the main client class
|
||||
fn generate_main_client(output: &mut String, endpoints: &[Endpoint]) {
|
||||
writeln!(output, "class BrkClient(BrkClientBase):").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" \"\"\"Main BRK client with catalog tree and API methods.\"\"\""
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " ").unwrap();
|
||||
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.tree = CatalogTree(self)").unwrap();
|
||||
writeln!(output).unwrap();
|
||||
|
||||
// Generate API methods
|
||||
generate_api_methods(output, endpoints);
|
||||
}
|
||||
|
||||
/// Generate API methods from OpenAPI endpoints
|
||||
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 return_type = endpoint
|
||||
.response_type
|
||||
.as_deref()
|
||||
.map(js_type_to_python)
|
||||
.unwrap_or_else(|| "Any".to_string());
|
||||
|
||||
// Build method signature
|
||||
let params = build_method_params(endpoint);
|
||||
writeln!(
|
||||
output,
|
||||
" def {}(self{}) -> {}:",
|
||||
method_name, params, return_type
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Docstring
|
||||
if let Some(summary) = &endpoint.summary {
|
||||
writeln!(output, " \"\"\"{}\"\"\"", summary).unwrap();
|
||||
}
|
||||
|
||||
// Build path
|
||||
let path = build_path_template(&endpoint.path, &endpoint.path_params);
|
||||
|
||||
if endpoint.query_params.is_empty() {
|
||||
if endpoint.path_params.is_empty() {
|
||||
writeln!(output, " return self.get('{}')", path).unwrap();
|
||||
} else {
|
||||
writeln!(output, " return self.get(f'{}')", path).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);
|
||||
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,
|
||||
" return self.get(f'{}{{\"?\" + query if query else \"\"}}')",
|
||||
path
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
writeln!(output).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint_to_method_name(endpoint: &Endpoint) -> String {
|
||||
to_snake_case(&endpoint.operation_name())
|
||||
}
|
||||
|
||||
/// Convert JS-style type to Python type (e.g., "Txid[]" -> "List[Txid]", "number" -> "int")
|
||||
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 {
|
||||
"number" => "int".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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_method_params(endpoint: &Endpoint) -> String {
|
||||
let mut params = Vec::new();
|
||||
for param in &endpoint.path_params {
|
||||
let safe_name = escape_python_keyword(¶m.name);
|
||||
params.push(format!(", {}: str", safe_name));
|
||||
}
|
||||
for param in &endpoint.query_params {
|
||||
let safe_name = escape_python_keyword(¶m.name);
|
||||
if param.required {
|
||||
params.push(format!(", {}: str", safe_name));
|
||||
} else {
|
||||
params.push(format!(", {}: Optional[str] = None", safe_name));
|
||||
}
|
||||
}
|
||||
params.join("")
|
||||
}
|
||||
|
||||
fn build_path_template(path: &str, path_params: &[super::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
|
||||
}
|
||||
@@ -1,770 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Write as FmtWrite;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use brk_types::{Index, TreeNode};
|
||||
|
||||
use crate::{
|
||||
ClientMetadata, Endpoint, FieldNamePosition, IndexSetPattern, PatternField, StructuralPattern,
|
||||
extract_inner_type, get_fields_with_child_info, get_node_fields, get_pattern_instance_base,
|
||||
to_pascal_case, to_snake_case,
|
||||
};
|
||||
|
||||
/// Generate Rust client from metadata and OpenAPI endpoints
|
||||
pub fn generate_rust_client(
|
||||
metadata: &ClientMetadata,
|
||||
endpoints: &[Endpoint],
|
||||
output_dir: &Path,
|
||||
) -> io::Result<()> {
|
||||
let mut output = String::new();
|
||||
|
||||
// Header
|
||||
writeln!(output, "// Auto-generated BRK Rust client").unwrap();
|
||||
writeln!(output, "// Do not edit manually\n").unwrap();
|
||||
writeln!(output, "#![allow(non_camel_case_types)]").unwrap();
|
||||
writeln!(output, "#![allow(dead_code)]\n").unwrap();
|
||||
|
||||
// Imports
|
||||
generate_imports(&mut output);
|
||||
|
||||
// Generate base client
|
||||
generate_base_client(&mut output);
|
||||
|
||||
// Generate MetricNode
|
||||
generate_metric_node(&mut output);
|
||||
|
||||
// Generate index accessor structs (for each unique set of indexes)
|
||||
generate_index_accessors(&mut output, &metadata.index_set_patterns);
|
||||
|
||||
// Generate pattern structs (reusable, appearing 2+ times)
|
||||
generate_pattern_structs(&mut output, &metadata.structural_patterns, metadata);
|
||||
|
||||
// Generate tree - each node uses its pattern or is generated inline
|
||||
generate_tree(&mut output, &metadata.catalog, metadata);
|
||||
|
||||
// Generate main client with API methods
|
||||
generate_main_client(&mut output, endpoints);
|
||||
|
||||
fs::write(output_dir.join("client.rs"), output)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_imports(output: &mut String) {
|
||||
writeln!(
|
||||
output,
|
||||
r#"use std::marker::PhantomData;
|
||||
use serde::de::DeserializeOwned;
|
||||
use brk_types::*;
|
||||
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn generate_base_client(output: &mut String) {
|
||||
writeln!(
|
||||
output,
|
||||
r#"/// Error type for BRK client operations.
|
||||
#[derive(Debug)]
|
||||
pub struct BrkError {{
|
||||
pub message: String,
|
||||
}}
|
||||
|
||||
impl std::fmt::Display for BrkError {{
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {{
|
||||
write!(f, "{{}}", self.message)
|
||||
}}
|
||||
}}
|
||||
|
||||
impl std::error::Error for BrkError {{}}
|
||||
|
||||
/// Result type for BRK client operations.
|
||||
pub type Result<T> = std::result::Result<T, BrkError>;
|
||||
|
||||
/// Options for configuring the BRK client.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BrkClientOptions {{
|
||||
pub base_url: String,
|
||||
pub timeout_ms: u64,
|
||||
}}
|
||||
|
||||
impl Default for BrkClientOptions {{
|
||||
fn default() -> Self {{
|
||||
Self {{
|
||||
base_url: "http://localhost:3000".to_string(),
|
||||
timeout_ms: 30000,
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
/// Base HTTP client for making requests.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BrkClientBase {{
|
||||
base_url: String,
|
||||
client: reqwest::blocking::Client,
|
||||
}}
|
||||
|
||||
impl BrkClientBase {{
|
||||
/// Create a new client with the given base URL.
|
||||
pub fn new(base_url: impl Into<String>) -> Result<Self> {{
|
||||
let base_url = base_url.into();
|
||||
let client = reqwest::blocking::Client::new();
|
||||
Ok(Self {{ base_url, client }})
|
||||
}}
|
||||
|
||||
/// Create a new client with options.
|
||||
pub fn with_options(options: BrkClientOptions) -> Result<Self> {{
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_millis(options.timeout_ms))
|
||||
.build()
|
||||
.map_err(|e| BrkError {{ message: e.to_string() }})?;
|
||||
Ok(Self {{
|
||||
base_url: options.base_url,
|
||||
client,
|
||||
}})
|
||||
}}
|
||||
|
||||
/// Make a GET request.
|
||||
pub fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {{
|
||||
let url = format!("{{}}{{}}", self.base_url, path);
|
||||
self.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.map_err(|e| BrkError {{ message: e.to_string() }})?
|
||||
.json()
|
||||
.map_err(|e| BrkError {{ message: e.to_string() }})
|
||||
}}
|
||||
}}
|
||||
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn generate_metric_node(output: &mut String) {
|
||||
writeln!(
|
||||
output,
|
||||
r#"/// A metric node that can fetch data for different indexes.
|
||||
pub struct MetricNode<'a, T> {{
|
||||
client: &'a BrkClientBase,
|
||||
path: String,
|
||||
_marker: PhantomData<T>,
|
||||
}}
|
||||
|
||||
impl<'a, T: DeserializeOwned> MetricNode<'a, T> {{
|
||||
pub fn new(client: &'a BrkClientBase, path: String) -> Self {{
|
||||
Self {{
|
||||
client,
|
||||
path,
|
||||
_marker: PhantomData,
|
||||
}}
|
||||
}}
|
||||
|
||||
/// Fetch all data points for this metric.
|
||||
pub fn get(&self) -> Result<Vec<T>> {{
|
||||
self.client.get(&self.path)
|
||||
}}
|
||||
|
||||
/// Fetch data points within a date range.
|
||||
pub fn get_range(&self, from: &str, to: &str) -> Result<Vec<T>> {{
|
||||
let path = format!("{{}}?from={{}}&to={{}}", self.path, from, to);
|
||||
self.client.get(&path)
|
||||
}}
|
||||
}}
|
||||
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Generate index accessor structs for each unique set of indexes
|
||||
fn generate_index_accessors(output: &mut String, patterns: &[IndexSetPattern]) {
|
||||
if patterns.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
writeln!(output, "// Index accessor structs\n").unwrap();
|
||||
|
||||
for pattern in patterns {
|
||||
writeln!(
|
||||
output,
|
||||
"/// Index accessor for metrics with {} indexes.",
|
||||
pattern.indexes.len()
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, "pub struct {}<'a, T> {{", pattern.name).unwrap();
|
||||
|
||||
for index in &pattern.indexes {
|
||||
let field_name = index_to_field_name(index);
|
||||
writeln!(output, " pub {}: MetricNode<'a, T>,", field_name).unwrap();
|
||||
}
|
||||
|
||||
writeln!(output, " _marker: PhantomData<T>,").unwrap();
|
||||
writeln!(output, "}}\n").unwrap();
|
||||
|
||||
// Generate impl block with constructor
|
||||
writeln!(
|
||||
output,
|
||||
"impl<'a, T: DeserializeOwned> {}<'a, T> {{",
|
||||
pattern.name
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" pub fn new(client: &'a BrkClientBase, base_path: &str) -> Self {{"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " Self {{").unwrap();
|
||||
|
||||
for index in &pattern.indexes {
|
||||
let field_name = index_to_field_name(index);
|
||||
let path_segment = index.serialize_long();
|
||||
writeln!(
|
||||
output,
|
||||
" {}: MetricNode::new(client, format!(\"{{base_path}}/{}\")),",
|
||||
field_name, path_segment
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
writeln!(output, " _marker: PhantomData,").unwrap();
|
||||
writeln!(output, " }}").unwrap();
|
||||
writeln!(output, " }}").unwrap();
|
||||
writeln!(output, "}}\n").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an Index to a snake_case field name (e.g., DateIndex -> by_date_index)
|
||||
fn index_to_field_name(index: &Index) -> String {
|
||||
format!("by_{}", to_snake_case(index.serialize_long()))
|
||||
}
|
||||
|
||||
/// Generate pattern structs (those appearing 2+ times)
|
||||
fn generate_pattern_structs(
|
||||
output: &mut String,
|
||||
patterns: &[StructuralPattern],
|
||||
metadata: &ClientMetadata,
|
||||
) {
|
||||
if patterns.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
writeln!(output, "// Reusable pattern structs\n").unwrap();
|
||||
|
||||
for pattern in patterns {
|
||||
let is_parameterizable = pattern.is_parameterizable();
|
||||
let generic_params = if pattern.is_generic {
|
||||
"<'a, T>"
|
||||
} else {
|
||||
"<'a>"
|
||||
};
|
||||
|
||||
writeln!(output, "/// Pattern struct for repeated tree structure.").unwrap();
|
||||
writeln!(output, "pub struct {}{} {{", pattern.name, generic_params).unwrap();
|
||||
|
||||
for field in &pattern.fields {
|
||||
let field_name = to_snake_case(&field.name);
|
||||
let type_annotation =
|
||||
field_to_type_annotation_generic(field, metadata, pattern.is_generic);
|
||||
writeln!(output, " pub {}: {},", field_name, type_annotation).unwrap();
|
||||
}
|
||||
|
||||
writeln!(output, "}}\n").unwrap();
|
||||
|
||||
// Generate impl block with constructor
|
||||
writeln!(
|
||||
output,
|
||||
"impl{} {}{} {{",
|
||||
generic_params, pattern.name, generic_params
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if is_parameterizable {
|
||||
writeln!(
|
||||
output,
|
||||
" /// Create a new pattern node with accumulated metric name."
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" pub fn new(client: &'a BrkClientBase, acc: &str) -> Self {{"
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" pub fn new(client: &'a BrkClientBase, base_path: &str) -> Self {{"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(output, " Self {{").unwrap();
|
||||
|
||||
for field in &pattern.fields {
|
||||
if is_parameterizable {
|
||||
generate_parameterized_rust_field(output, field, pattern, metadata);
|
||||
} else {
|
||||
generate_tree_path_rust_field(output, field, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(output, " }}").unwrap();
|
||||
writeln!(output, " }}").unwrap();
|
||||
writeln!(output, "}}\n").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a field using parameterized (prepend/append) metric name construction
|
||||
fn generate_parameterized_rust_field(
|
||||
output: &mut String,
|
||||
field: &PatternField,
|
||||
pattern: &StructuralPattern,
|
||||
metadata: &ClientMetadata,
|
||||
) {
|
||||
let field_name = to_snake_case(&field.name);
|
||||
|
||||
// For branch fields, pass the accumulated name to nested pattern
|
||||
if metadata.is_pattern_type(&field.rust_type) {
|
||||
let child_acc = if let Some(pos) = pattern.get_field_position(&field.name) {
|
||||
match pos {
|
||||
FieldNamePosition::Append(suffix) => format!("&format!(\"{{acc}}{}\")", suffix),
|
||||
FieldNamePosition::Prepend(prefix) => format!("&format!(\"{}{{acc}}\")", prefix),
|
||||
FieldNamePosition::Identity => "acc".to_string(),
|
||||
FieldNamePosition::SetBase(base) => format!("\"{}\"", base),
|
||||
}
|
||||
} else {
|
||||
format!("&format!(\"{{acc}}_{}\")", field.name)
|
||||
};
|
||||
|
||||
writeln!(
|
||||
output,
|
||||
" {}: {}::new(client, {}),",
|
||||
field_name, field.rust_type, child_acc
|
||||
)
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
|
||||
// For leaf fields, construct the metric path based on position
|
||||
let metric_expr = if let Some(pos) = pattern.get_field_position(&field.name) {
|
||||
match pos {
|
||||
FieldNamePosition::Append(suffix) => format!("format!(\"/{{acc}}{}\")", suffix),
|
||||
FieldNamePosition::Prepend(prefix) => format!("format!(\"/{}{{acc}}\")", prefix),
|
||||
FieldNamePosition::Identity => "format!(\"/{acc}\")".to_string(),
|
||||
FieldNamePosition::SetBase(base) => format!("\"/{}\".to_string()", base),
|
||||
}
|
||||
} else {
|
||||
format!("format!(\"/{{acc}}_{}\")", field.name)
|
||||
};
|
||||
|
||||
if metadata.field_uses_accessor(field) {
|
||||
let accessor = metadata.find_index_set_pattern(&field.indexes).unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" {}: {}::new(client, &{}),",
|
||||
field_name, accessor.name, metric_expr
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" {}: MetricNode::new(client, {}),",
|
||||
field_name, metric_expr
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a field using tree path construction (fallback for non-parameterizable patterns)
|
||||
fn generate_tree_path_rust_field(
|
||||
output: &mut String,
|
||||
field: &PatternField,
|
||||
metadata: &ClientMetadata,
|
||||
) {
|
||||
let field_name = to_snake_case(&field.name);
|
||||
|
||||
if metadata.is_pattern_type(&field.rust_type) {
|
||||
writeln!(
|
||||
output,
|
||||
" {}: {}::new(client, &format!(\"{{base_path}}/{}\")),",
|
||||
field_name, field.rust_type, field.name
|
||||
)
|
||||
.unwrap();
|
||||
} else if metadata.field_uses_accessor(field) {
|
||||
let accessor = metadata.find_index_set_pattern(&field.indexes).unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" {}: {}::new(client, &format!(\"{{base_path}}/{}\")),",
|
||||
field_name, accessor.name, field.name
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" {}: MetricNode::new(client, format!(\"{{base_path}}/{}\")),",
|
||||
field_name, field.name
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a PatternField to the full type annotation, with optional generic support
|
||||
fn field_to_type_annotation_generic(
|
||||
field: &PatternField,
|
||||
metadata: &ClientMetadata,
|
||||
is_generic: bool,
|
||||
) -> String {
|
||||
field_to_type_annotation_with_generic(field, metadata, is_generic, None)
|
||||
}
|
||||
|
||||
/// Convert a PatternField to the full type annotation.
|
||||
/// - `is_generic`: If true and field.rust_type is "T", use T in the output
|
||||
/// - `generic_value_type`: For branch fields that reference a generic pattern, this is the concrete type to substitute
|
||||
fn field_to_type_annotation_with_generic(
|
||||
field: &PatternField,
|
||||
metadata: &ClientMetadata,
|
||||
is_generic: bool,
|
||||
generic_value_type: Option<&str>,
|
||||
) -> String {
|
||||
// For generic patterns, use T instead of concrete value type
|
||||
// Also extract inner type from wrappers like Close<Dollars> -> Dollars
|
||||
let value_type = if is_generic && field.rust_type == "T" {
|
||||
"T".to_string()
|
||||
} else {
|
||||
extract_inner_type(&field.rust_type)
|
||||
};
|
||||
|
||||
if metadata.is_pattern_type(&field.rust_type) {
|
||||
// Check if this pattern is generic and we have a value type
|
||||
if metadata.is_pattern_generic(&field.rust_type)
|
||||
&& let Some(vt) = generic_value_type
|
||||
{
|
||||
return format!("{}<'a, {}>", field.rust_type, vt);
|
||||
}
|
||||
format!("{}<'a>", field.rust_type)
|
||||
} else if let Some(accessor) = metadata.find_index_set_pattern(&field.indexes) {
|
||||
// Leaf with a reusable accessor pattern
|
||||
format!("{}<'a, {}>", accessor.name, value_type)
|
||||
} else {
|
||||
// Leaf with unique index set - use MetricNode directly
|
||||
format!("MetricNode<'a, {}>", value_type)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the catalog tree structure
|
||||
fn generate_tree(output: &mut String, catalog: &TreeNode, metadata: &ClientMetadata) {
|
||||
writeln!(output, "// Catalog tree\n").unwrap();
|
||||
|
||||
let pattern_lookup = metadata.pattern_lookup();
|
||||
let mut generated = HashSet::new();
|
||||
generate_tree_node(
|
||||
output,
|
||||
"CatalogTree",
|
||||
catalog,
|
||||
&pattern_lookup,
|
||||
metadata,
|
||||
&mut generated,
|
||||
);
|
||||
}
|
||||
|
||||
/// Recursively generate tree nodes
|
||||
fn generate_tree_node(
|
||||
output: &mut String,
|
||||
name: &str,
|
||||
node: &TreeNode,
|
||||
pattern_lookup: &std::collections::HashMap<Vec<PatternField>, String>,
|
||||
metadata: &ClientMetadata,
|
||||
generated: &mut HashSet<String>,
|
||||
) {
|
||||
let TreeNode::Branch(children) = node else {
|
||||
return;
|
||||
};
|
||||
|
||||
let fields_with_child_info = get_fields_with_child_info(children, name, pattern_lookup);
|
||||
let fields: Vec<PatternField> = fields_with_child_info
|
||||
.iter()
|
||||
.map(|(f, _)| f.clone())
|
||||
.collect();
|
||||
|
||||
// Skip if this matches a pattern (already generated separately)
|
||||
if let Some(pattern_name) = pattern_lookup.get(&fields)
|
||||
&& pattern_name != name
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if generated.contains(name) {
|
||||
return;
|
||||
}
|
||||
generated.insert(name.to_string());
|
||||
|
||||
writeln!(output, "/// Catalog tree node.").unwrap();
|
||||
writeln!(output, "pub struct {}<'a> {{", name).unwrap();
|
||||
|
||||
for (field, child_fields) in &fields_with_child_info {
|
||||
let field_name = to_snake_case(&field.name);
|
||||
let generic_value_type = child_fields
|
||||
.as_ref()
|
||||
.and_then(|cf| metadata.get_generic_value_type(&field.rust_type, cf));
|
||||
let type_annotation = field_to_type_annotation_with_generic(
|
||||
field,
|
||||
metadata,
|
||||
false,
|
||||
generic_value_type.as_deref(),
|
||||
);
|
||||
writeln!(output, " pub {}: {},", field_name, type_annotation).unwrap();
|
||||
}
|
||||
|
||||
writeln!(output, "}}\n").unwrap();
|
||||
|
||||
// Generate impl block
|
||||
writeln!(output, "impl<'a> {}<'a> {{", name).unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" pub fn new(client: &'a BrkClientBase, base_path: &str) -> Self {{"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " Self {{").unwrap();
|
||||
|
||||
for (field, (child_name, child_node)) in fields.iter().zip(children.iter()) {
|
||||
let field_name = to_snake_case(&field.name);
|
||||
if metadata.is_pattern_type(&field.rust_type) {
|
||||
let pattern = metadata.find_pattern(&field.rust_type);
|
||||
let is_parameterizable = pattern.is_some_and(|p| p.is_parameterizable());
|
||||
|
||||
if is_parameterizable {
|
||||
let metric_base = get_pattern_instance_base(child_node, child_name);
|
||||
writeln!(
|
||||
output,
|
||||
" {}: {}::new(client, \"{}\"),",
|
||||
field_name, field.rust_type, metric_base
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" {}: {}::new(client, &format!(\"{{base_path}}/{}\")),",
|
||||
field_name, field.rust_type, field.name
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
} else if metadata.field_uses_accessor(field) {
|
||||
let metric_path = if let TreeNode::Leaf(leaf) = child_node {
|
||||
format!("/{}", leaf.name())
|
||||
} else {
|
||||
format!("{{base_path}}/{}", field.name)
|
||||
};
|
||||
let accessor = metadata.find_index_set_pattern(&field.indexes).unwrap();
|
||||
if metric_path.contains("{base_path}") {
|
||||
writeln!(
|
||||
output,
|
||||
" {}: {}::new(client, &format!(\"{}\")),",
|
||||
field_name, accessor.name, metric_path
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" {}: {}::new(client, \"{}\"),",
|
||||
field_name, accessor.name, metric_path
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
} else {
|
||||
let metric_path = if let TreeNode::Leaf(leaf) = child_node {
|
||||
format!("/{}", leaf.name())
|
||||
} else {
|
||||
format!("{{base_path}}/{}", field.name)
|
||||
};
|
||||
if metric_path.contains("{base_path}") {
|
||||
writeln!(
|
||||
output,
|
||||
" {}: MetricNode::new(client, format!(\"{}\")),",
|
||||
field_name, metric_path
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" {}: MetricNode::new(client, \"{}\".to_string()),",
|
||||
field_name, metric_path
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(output, " }}").unwrap();
|
||||
writeln!(output, " }}").unwrap();
|
||||
writeln!(output, "}}\n").unwrap();
|
||||
|
||||
// Recursively generate child nodes that aren't patterns
|
||||
for (child_name, child_node) in children {
|
||||
if let TreeNode::Branch(grandchildren) = child_node {
|
||||
let child_fields = get_node_fields(grandchildren, pattern_lookup);
|
||||
if !pattern_lookup.contains_key(&child_fields) {
|
||||
let child_struct_name = format!("{}_{}", name, to_pascal_case(child_name));
|
||||
generate_tree_node(
|
||||
output,
|
||||
&child_struct_name,
|
||||
child_node,
|
||||
pattern_lookup,
|
||||
metadata,
|
||||
generated,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the main client struct
|
||||
fn generate_main_client(output: &mut String, endpoints: &[Endpoint]) {
|
||||
writeln!(
|
||||
output,
|
||||
r#"/// Main BRK client with catalog tree and API methods.
|
||||
pub struct BrkClient {{
|
||||
base: BrkClientBase,
|
||||
}}
|
||||
|
||||
impl BrkClient {{
|
||||
/// Create a new client with the given base URL.
|
||||
pub fn new(base_url: impl Into<String>) -> Result<Self> {{
|
||||
Ok(Self {{
|
||||
base: BrkClientBase::new(base_url)?,
|
||||
}})
|
||||
}}
|
||||
|
||||
/// Create a new client with options.
|
||||
pub fn with_options(options: BrkClientOptions) -> Result<Self> {{
|
||||
Ok(Self {{
|
||||
base: BrkClientBase::with_options(options)?,
|
||||
}})
|
||||
}}
|
||||
|
||||
/// Get the catalog tree for navigating metrics.
|
||||
pub fn tree(&self) -> CatalogTree<'_> {{
|
||||
CatalogTree::new(&self.base, "")
|
||||
}}
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Generate API methods
|
||||
generate_api_methods(output, endpoints);
|
||||
|
||||
writeln!(output, "}}").unwrap();
|
||||
}
|
||||
|
||||
/// Generate API methods from OpenAPI endpoints
|
||||
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 return_type = endpoint
|
||||
.response_type
|
||||
.as_deref()
|
||||
.map(js_type_to_rust)
|
||||
.unwrap_or_else(|| "serde_json::Value".to_string());
|
||||
|
||||
// Build doc comment
|
||||
writeln!(
|
||||
output,
|
||||
" /// {}",
|
||||
endpoint.summary.as_deref().unwrap_or(&method_name)
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Build method signature
|
||||
let params = build_method_params(endpoint);
|
||||
writeln!(
|
||||
output,
|
||||
" pub fn {}(&self{}) -> Result<{}> {{",
|
||||
method_name, params, return_type
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Build path
|
||||
let path = build_path_template(&endpoint.path, &endpoint.path_params);
|
||||
|
||||
if endpoint.query_params.is_empty() {
|
||||
writeln!(output, " self.base.get(&format!(\"{}\"))", path).unwrap();
|
||||
} else {
|
||||
writeln!(output, " let mut query = Vec::new();").unwrap();
|
||||
for param in &endpoint.query_params {
|
||||
if param.required {
|
||||
writeln!(
|
||||
output,
|
||||
" query.push(format!(\"{}={{}}\", {}));",
|
||||
param.name, param.name
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" if let Some(v) = {} {{ query.push(format!(\"{}={{}}\", v)); }}",
|
||||
param.name, param.name
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
writeln!(output, " let query_str = if query.is_empty() {{ String::new() }} else {{ format!(\"?{{}}\", query.join(\"&\")) }};").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" self.base.get(&format!(\"{}{{}}\", query_str))",
|
||||
path
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
writeln!(output, " }}\n").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();
|
||||
for param in &endpoint.path_params {
|
||||
params.push(format!(", {}: &str", param.name));
|
||||
}
|
||||
for param in &endpoint.query_params {
|
||||
if param.required {
|
||||
params.push(format!(", {}: &str", param.name));
|
||||
} else {
|
||||
params.push(format!(", {}: Option<&str>", param.name));
|
||||
}
|
||||
}
|
||||
params.join("")
|
||||
}
|
||||
|
||||
fn build_path_template(path: &str, path_params: &[super::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
|
||||
}
|
||||
|
||||
/// Convert JS-style type to Rust type (e.g., "Txid[]" -> "Vec<Txid>")
|
||||
fn js_type_to_rust(js_type: &str) -> String {
|
||||
if let Some(inner) = js_type.strip_suffix("[]") {
|
||||
format!("Vec<{}>", js_type_to_rust(inner))
|
||||
} else {
|
||||
match js_type {
|
||||
"string" => "String".to_string(),
|
||||
"number" => "f64".to_string(),
|
||||
"boolean" => "bool".to_string(),
|
||||
"*" => "serde_json::Value".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
//! Case conversion utilities for identifiers.
|
||||
|
||||
/// Convert a string to PascalCase (e.g., "fee_rate" -> "FeeRate").
|
||||
pub fn to_pascal_case(s: &str) -> String {
|
||||
s.replace('-', "_")
|
||||
.split('_')
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Convert a string to snake_case, handling Rust keywords.
|
||||
pub fn to_snake_case(s: &str) -> String {
|
||||
let sanitized = s.replace('-', "_");
|
||||
|
||||
// Prefix with _ if starts with digit
|
||||
let sanitized = if sanitized.chars().next().is_some_and(|c| c.is_ascii_digit()) {
|
||||
format!("_{}", sanitized)
|
||||
} else {
|
||||
sanitized
|
||||
};
|
||||
|
||||
// Handle Rust keywords
|
||||
match sanitized.as_str() {
|
||||
"type" | "const" | "static" | "match" | "if" | "else" | "loop" | "while" | "for"
|
||||
| "break" | "continue" | "return" | "fn" | "let" | "mut" | "ref" | "self" | "super"
|
||||
| "mod" | "use" | "pub" | "crate" | "extern" | "impl" | "trait" | "struct" | "enum"
|
||||
| "where" | "async" | "await" | "dyn" | "move" => format!("r#{}", sanitized),
|
||||
_ => sanitized,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a string to camelCase (e.g., "fee_rate" -> "feeRate").
|
||||
pub fn to_camel_case(s: &str) -> String {
|
||||
let pascal = to_pascal_case(s);
|
||||
let mut chars = pascal.chars();
|
||||
|
||||
let result = match chars.next() {
|
||||
None => String::new(),
|
||||
Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
|
||||
};
|
||||
|
||||
// Prefix with _ if starts with digit
|
||||
if result.chars().next().is_some_and(|c| c.is_ascii_digit()) {
|
||||
format!("_{}", result)
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
//! Types and utilities for client generation.
|
||||
|
||||
mod case;
|
||||
mod patterns;
|
||||
mod schema;
|
||||
mod tree;
|
||||
|
||||
pub use case::*;
|
||||
pub use schema::*;
|
||||
pub use tree::*;
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use brk_query::Vecs;
|
||||
use brk_types::Index;
|
||||
|
||||
/// How a field modifies the accumulated metric name.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FieldNamePosition {
|
||||
/// Field prepends a prefix: leaf.name() = prefix + accumulated
|
||||
Prepend(String),
|
||||
/// Field appends a suffix: leaf.name() = accumulated + suffix
|
||||
Append(String),
|
||||
/// Field IS the accumulated name (no modification)
|
||||
Identity,
|
||||
/// Field sets a new base name (used at pattern entry points)
|
||||
SetBase(String),
|
||||
}
|
||||
|
||||
/// Metadata extracted from brk_query for client generation.
|
||||
#[derive(Debug)]
|
||||
pub struct ClientMetadata {
|
||||
/// The catalog tree structure (with schemas in leaves)
|
||||
pub catalog: brk_types::TreeNode,
|
||||
/// Structural patterns - tree node shapes that repeat
|
||||
pub structural_patterns: Vec<StructuralPattern>,
|
||||
/// All indexes used across the catalog
|
||||
pub used_indexes: BTreeSet<Index>,
|
||||
/// Index set patterns - sets of indexes that appear together on metrics
|
||||
pub index_set_patterns: Vec<IndexSetPattern>,
|
||||
/// Maps concrete field signatures to pattern names
|
||||
pub concrete_to_pattern: HashMap<Vec<PatternField>, String>,
|
||||
}
|
||||
|
||||
impl ClientMetadata {
|
||||
/// Extract metadata from brk_query::Vecs.
|
||||
pub fn from_vecs(vecs: &Vecs) -> Self {
|
||||
let catalog = vecs.catalog().clone();
|
||||
let (structural_patterns, concrete_to_pattern) =
|
||||
patterns::detect_structural_patterns(&catalog);
|
||||
let (used_indexes, index_set_patterns) = tree::detect_index_patterns(&catalog);
|
||||
|
||||
ClientMetadata {
|
||||
catalog,
|
||||
structural_patterns,
|
||||
used_indexes,
|
||||
index_set_patterns,
|
||||
concrete_to_pattern,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find an index set pattern that matches the given indexes.
|
||||
pub fn find_index_set_pattern(&self, indexes: &BTreeSet<Index>) -> Option<&IndexSetPattern> {
|
||||
self.index_set_patterns
|
||||
.iter()
|
||||
.find(|p| &p.indexes == indexes)
|
||||
}
|
||||
|
||||
/// Check if a type is a structural pattern name.
|
||||
pub fn is_pattern_type(&self, type_name: &str) -> bool {
|
||||
self.structural_patterns.iter().any(|p| p.name == type_name)
|
||||
}
|
||||
|
||||
/// Find a pattern by name.
|
||||
pub fn find_pattern(&self, name: &str) -> Option<&StructuralPattern> {
|
||||
self.structural_patterns.iter().find(|p| p.name == name)
|
||||
}
|
||||
|
||||
/// Check if a pattern is generic.
|
||||
pub fn is_pattern_generic(&self, name: &str) -> bool {
|
||||
self.find_pattern(name).is_some_and(|p| p.is_generic)
|
||||
}
|
||||
|
||||
/// Extract the value type from concrete fields for a generic pattern.
|
||||
pub fn get_generic_value_type(
|
||||
&self,
|
||||
pattern_name: &str,
|
||||
fields: &[PatternField],
|
||||
) -> Option<String> {
|
||||
if !self.is_pattern_generic(pattern_name) {
|
||||
return None;
|
||||
}
|
||||
fields
|
||||
.iter()
|
||||
.find(|f| f.is_leaf())
|
||||
.map(|f| extract_inner_type(&f.rust_type))
|
||||
}
|
||||
|
||||
/// Build a lookup map from field signatures to pattern names.
|
||||
pub fn pattern_lookup(&self) -> HashMap<Vec<PatternField>, String> {
|
||||
let mut lookup = self.concrete_to_pattern.clone();
|
||||
for p in &self.structural_patterns {
|
||||
lookup.insert(p.fields.clone(), p.name.clone());
|
||||
}
|
||||
lookup
|
||||
}
|
||||
|
||||
/// Check if a field should use a shared index accessor.
|
||||
pub fn field_uses_accessor(&self, field: &PatternField) -> bool {
|
||||
self.find_index_set_pattern(&field.indexes).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// A pattern of indexes that appear together on multiple metrics.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IndexSetPattern {
|
||||
/// Pattern name (e.g., "DateHeightIndexes")
|
||||
pub name: String,
|
||||
/// The set of indexes
|
||||
pub indexes: BTreeSet<Index>,
|
||||
}
|
||||
|
||||
/// A structural pattern - a branch structure that appears multiple times.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StructuralPattern {
|
||||
/// Pattern name
|
||||
pub name: String,
|
||||
/// Ordered list of child fields
|
||||
pub fields: Vec<PatternField>,
|
||||
/// How each field modifies the accumulated name
|
||||
pub field_positions: HashMap<String, FieldNamePosition>,
|
||||
/// If true, all leaf fields use a type parameter T
|
||||
pub is_generic: bool,
|
||||
}
|
||||
|
||||
impl StructuralPattern {
|
||||
/// Returns true if this pattern contains any leaf fields.
|
||||
pub fn contains_leaves(&self) -> bool {
|
||||
self.fields.iter().any(|f| f.is_leaf())
|
||||
}
|
||||
|
||||
/// Returns true if all leaf fields have consistent name transformations.
|
||||
pub fn is_parameterizable(&self) -> bool {
|
||||
!self.field_positions.is_empty()
|
||||
&& self
|
||||
.fields
|
||||
.iter()
|
||||
.all(|f| f.is_branch() || self.field_positions.contains_key(&f.name))
|
||||
}
|
||||
|
||||
/// Get the field position for a given field name.
|
||||
pub fn get_field_position(&self, field_name: &str) -> Option<&FieldNamePosition> {
|
||||
self.field_positions.get(field_name)
|
||||
}
|
||||
}
|
||||
|
||||
/// A field in a structural pattern.
|
||||
#[derive(Debug, Clone, PartialOrd, Ord)]
|
||||
pub struct PatternField {
|
||||
/// Field name
|
||||
pub name: String,
|
||||
/// Rust type for leaves or pattern name for branches
|
||||
pub rust_type: String,
|
||||
/// JSON type from schema
|
||||
pub json_type: String,
|
||||
/// For leaves: the set of supported indexes. Empty for branches.
|
||||
pub indexes: BTreeSet<Index>,
|
||||
}
|
||||
|
||||
impl PatternField {
|
||||
/// Returns true if this is a leaf field (has indexes).
|
||||
pub fn is_leaf(&self) -> bool {
|
||||
!self.indexes.is_empty()
|
||||
}
|
||||
|
||||
/// Returns true if this is a branch field (no indexes).
|
||||
pub fn is_branch(&self) -> bool {
|
||||
self.indexes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::hash::Hash for PatternField {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.name.hash(state);
|
||||
self.rust_type.hash(state);
|
||||
self.json_type.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for PatternField {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.name == other.name
|
||||
&& self.rust_type == other.rust_type
|
||||
&& self.json_type == other.json_type
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for PatternField {}
|
||||
@@ -1,427 +0,0 @@
|
||||
//! Pattern detection for structural patterns in the metric tree.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use brk_types::TreeNode;
|
||||
|
||||
use super::{
|
||||
case::to_pascal_case, schema::schema_to_json_type, FieldNamePosition, PatternField,
|
||||
StructuralPattern,
|
||||
};
|
||||
|
||||
/// Detect structural patterns in the tree using a bottom-up approach.
|
||||
/// Returns (patterns, concrete_to_pattern_mapping).
|
||||
pub fn detect_structural_patterns(
|
||||
tree: &TreeNode,
|
||||
) -> (Vec<StructuralPattern>, HashMap<Vec<PatternField>, String>) {
|
||||
let mut signature_to_pattern: HashMap<Vec<PatternField>, String> = HashMap::new();
|
||||
let mut signature_counts: HashMap<Vec<PatternField>, usize> = HashMap::new();
|
||||
let mut normalized_to_name: HashMap<Vec<PatternField>, String> = HashMap::new();
|
||||
let mut name_counts: HashMap<String, usize> = HashMap::new();
|
||||
|
||||
// Process tree bottom-up to resolve all branch types
|
||||
resolve_branch_patterns(
|
||||
tree,
|
||||
"root",
|
||||
&mut signature_to_pattern,
|
||||
&mut signature_counts,
|
||||
&mut normalized_to_name,
|
||||
&mut name_counts,
|
||||
);
|
||||
|
||||
// Identify generic patterns
|
||||
let (generic_patterns, generic_mappings) = detect_generic_patterns(&signature_to_pattern);
|
||||
|
||||
// Build non-generic patterns: signatures appearing 2+ times that weren't merged into generics
|
||||
let mut patterns: Vec<StructuralPattern> = signature_to_pattern
|
||||
.iter()
|
||||
.filter(|(sig, _)| {
|
||||
signature_counts.get(*sig).copied().unwrap_or(0) >= 2
|
||||
&& !generic_mappings.contains_key(*sig)
|
||||
})
|
||||
.map(|(fields, name)| StructuralPattern {
|
||||
name: name.clone(),
|
||||
fields: fields.clone(),
|
||||
field_positions: HashMap::new(),
|
||||
is_generic: false,
|
||||
})
|
||||
.collect();
|
||||
|
||||
patterns.extend(generic_patterns);
|
||||
|
||||
// Build lookup for field position analysis
|
||||
let mut pattern_lookup: HashMap<Vec<PatternField>, String> = HashMap::new();
|
||||
for (sig, name) in &signature_to_pattern {
|
||||
if 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();
|
||||
|
||||
// Second pass: analyze field positions
|
||||
analyze_pattern_field_positions(tree, &mut patterns, &pattern_lookup);
|
||||
|
||||
patterns.sort_by(|a, b| b.fields.len().cmp(&a.fields.len()));
|
||||
(patterns, concrete_to_pattern)
|
||||
}
|
||||
|
||||
/// Detect generic patterns by grouping signatures by their normalized form.
|
||||
fn detect_generic_patterns(
|
||||
signature_to_pattern: &HashMap<Vec<PatternField>, String>,
|
||||
) -> (Vec<StructuralPattern>, HashMap<Vec<PatternField>, String>) {
|
||||
let mut normalized_groups: HashMap<Vec<PatternField>, Vec<(Vec<PatternField>, String)>> =
|
||||
HashMap::new();
|
||||
|
||||
for (fields, name) in signature_to_pattern {
|
||||
if let Some(normalized) = normalize_fields_for_generic(fields) {
|
||||
normalized_groups
|
||||
.entry(normalized)
|
||||
.or_default()
|
||||
.push((fields.clone(), name.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut patterns = Vec::new();
|
||||
let mut mappings: HashMap<Vec<PatternField>, String> = HashMap::new();
|
||||
|
||||
for (normalized_fields, group) in normalized_groups {
|
||||
if group.len() >= 2 {
|
||||
let generic_name = group[0].1.clone();
|
||||
for (concrete_fields, _) in &group {
|
||||
mappings.insert(concrete_fields.clone(), generic_name.clone());
|
||||
}
|
||||
patterns.push(StructuralPattern {
|
||||
name: generic_name,
|
||||
fields: normalized_fields,
|
||||
field_positions: HashMap::new(),
|
||||
is_generic: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
(patterns, mappings)
|
||||
}
|
||||
|
||||
/// Normalize fields by replacing concrete value types with "T".
|
||||
fn normalize_fields_for_generic(fields: &[PatternField]) -> Option<Vec<PatternField>> {
|
||||
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];
|
||||
if !leaf_types.iter().all(|t| *t == first_type) {
|
||||
return None;
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(normalized)
|
||||
}
|
||||
|
||||
/// Recursively resolve branch patterns bottom-up.
|
||||
fn resolve_branch_patterns(
|
||||
node: &TreeNode,
|
||||
field_name: &str,
|
||||
signature_to_pattern: &mut HashMap<Vec<PatternField>, String>,
|
||||
signature_counts: &mut HashMap<Vec<PatternField>, usize>,
|
||||
normalized_to_name: &mut HashMap<Vec<PatternField>, String>,
|
||||
name_counts: &mut HashMap<String, usize>,
|
||||
) -> Option<String> {
|
||||
let TreeNode::Branch(children) = node else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut fields: Vec<PatternField> = Vec::new();
|
||||
for (child_name, child_node) in children {
|
||||
let (rust_type, json_type, indexes) = match child_node {
|
||||
TreeNode::Leaf(leaf) => (
|
||||
leaf.value_type().to_string(),
|
||||
schema_to_json_type(&leaf.schema),
|
||||
leaf.indexes().clone(),
|
||||
),
|
||||
TreeNode::Branch(_) => {
|
||||
let pattern_name = resolve_branch_patterns(
|
||||
child_node,
|
||||
child_name,
|
||||
signature_to_pattern,
|
||||
signature_counts,
|
||||
normalized_to_name,
|
||||
name_counts,
|
||||
)
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
(pattern_name.clone(), pattern_name, BTreeSet::new())
|
||||
}
|
||||
};
|
||||
fields.push(PatternField {
|
||||
name: child_name.clone(),
|
||||
rust_type,
|
||||
json_type,
|
||||
indexes,
|
||||
});
|
||||
}
|
||||
|
||||
fields.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
*signature_counts.entry(fields.clone()).or_insert(0) += 1;
|
||||
|
||||
let pattern_name = if let Some(existing) = signature_to_pattern.get(&fields) {
|
||||
existing.clone()
|
||||
} else {
|
||||
let normalized = normalize_fields_for_naming(&fields);
|
||||
let name = normalized_to_name
|
||||
.entry(normalized)
|
||||
.or_insert_with(|| generate_pattern_name(field_name, name_counts))
|
||||
.clone();
|
||||
signature_to_pattern.insert(fields, name.clone());
|
||||
name
|
||||
};
|
||||
|
||||
Some(pattern_name)
|
||||
}
|
||||
|
||||
/// Normalize fields for naming (same structure = same name).
|
||||
fn normalize_fields_for_naming(fields: &[PatternField]) -> Vec<PatternField> {
|
||||
fields
|
||||
.iter()
|
||||
.map(|f| {
|
||||
if f.is_branch() {
|
||||
f.clone()
|
||||
} else {
|
||||
PatternField {
|
||||
name: f.name.clone(),
|
||||
rust_type: "_".to_string(),
|
||||
json_type: "_".to_string(),
|
||||
indexes: f.indexes.clone(),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate a unique pattern name.
|
||||
fn generate_pattern_name(field_name: &str, name_counts: &mut HashMap<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)
|
||||
}
|
||||
}
|
||||
|
||||
// Field position analysis
|
||||
|
||||
fn analyze_pattern_field_positions(
|
||||
tree: &TreeNode,
|
||||
patterns: &mut [StructuralPattern],
|
||||
pattern_lookup: &HashMap<Vec<PatternField>, String>,
|
||||
) {
|
||||
let mut instances: HashMap<String, Vec<(String, String, String)>> = HashMap::new();
|
||||
collect_pattern_instances(tree, "", &mut instances, pattern_lookup);
|
||||
|
||||
for pattern in patterns.iter_mut() {
|
||||
if let Some(pattern_instances) = instances.get(&pattern.name) {
|
||||
pattern.field_positions = analyze_field_positions_from_instances(pattern_instances);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_pattern_instances(
|
||||
node: &TreeNode,
|
||||
accumulated_name: &str,
|
||||
instances: &mut HashMap<String, Vec<(String, String, String)>>,
|
||||
pattern_lookup: &HashMap<Vec<PatternField>, String>,
|
||||
) {
|
||||
let TreeNode::Branch(children) = node else {
|
||||
return;
|
||||
};
|
||||
|
||||
let fields = get_node_fields_for_analysis(children, pattern_lookup);
|
||||
if let Some(pattern_name) = pattern_lookup.get(&fields) {
|
||||
for (field_name, child_node) in children {
|
||||
if let TreeNode::Leaf(leaf) = child_node {
|
||||
instances.entry(pattern_name.clone()).or_default().push((
|
||||
accumulated_name.to_string(),
|
||||
field_name.clone(),
|
||||
leaf.name().to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (field_name, child_node) in children {
|
||||
let child_accumulated = match child_node {
|
||||
TreeNode::Leaf(leaf) => leaf.name().to_string(),
|
||||
TreeNode::Branch(_) => {
|
||||
if let Some(desc_leaf_name) = get_descendant_leaf_name(child_node) {
|
||||
infer_accumulated_name(accumulated_name, field_name, &desc_leaf_name)
|
||||
} else if accumulated_name.is_empty() {
|
||||
field_name.clone()
|
||||
} else {
|
||||
format!("{}_{}", accumulated_name, field_name)
|
||||
}
|
||||
}
|
||||
};
|
||||
collect_pattern_instances(child_node, &child_accumulated, instances, pattern_lookup);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_descendant_leaf_name(node: &TreeNode) -> Option<String> {
|
||||
match node {
|
||||
TreeNode::Leaf(leaf) => Some(leaf.name().to_string()),
|
||||
TreeNode::Branch(children) => children.values().find_map(get_descendant_leaf_name),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_node_fields_for_analysis(
|
||||
children: &BTreeMap<String, TreeNode>,
|
||||
pattern_lookup: &HashMap<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.value_type().to_string(),
|
||||
schema_to_json_type(&leaf.schema),
|
||||
leaf.indexes().clone(),
|
||||
),
|
||||
TreeNode::Branch(grandchildren) => {
|
||||
let child_fields = get_node_fields_for_analysis(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,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
fields.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
fields
|
||||
}
|
||||
|
||||
fn analyze_field_positions_from_instances(
|
||||
instances: &[(String, String, String)],
|
||||
) -> HashMap<String, FieldNamePosition> {
|
||||
let mut field_instances: HashMap<String, Vec<(String, String)>> = HashMap::new();
|
||||
for (acc, field, leaf) in instances {
|
||||
field_instances
|
||||
.entry(field.clone())
|
||||
.or_default()
|
||||
.push((acc.clone(), leaf.clone()));
|
||||
}
|
||||
|
||||
let mut positions = HashMap::new();
|
||||
for (field_name, field_data) in field_instances {
|
||||
if let Some(position) = detect_field_position(&field_data) {
|
||||
positions.insert(field_name, position);
|
||||
}
|
||||
}
|
||||
positions
|
||||
}
|
||||
|
||||
fn detect_field_position(data: &[(String, String)]) -> Option<FieldNamePosition> {
|
||||
if data.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (first_acc, first_leaf) = &data[0];
|
||||
|
||||
// Identity
|
||||
if first_acc == first_leaf {
|
||||
return Some(FieldNamePosition::Identity);
|
||||
}
|
||||
|
||||
// Append
|
||||
if let Some(suffix) = first_leaf.strip_prefix(first_acc.as_str()) {
|
||||
let suffix = suffix.to_string();
|
||||
if data.iter().all(|(acc, leaf)| {
|
||||
if acc.is_empty() {
|
||||
leaf == suffix.trim_start_matches('_')
|
||||
} else {
|
||||
leaf.strip_prefix(acc.as_str()) == Some(&suffix)
|
||||
}
|
||||
}) {
|
||||
return Some(FieldNamePosition::Append(suffix));
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend
|
||||
if let Some(prefix) = first_leaf.strip_suffix(first_acc.as_str()) {
|
||||
let prefix = prefix.to_string();
|
||||
if data.iter().all(|(acc, leaf)| {
|
||||
if acc.is_empty() {
|
||||
leaf == prefix.trim_end_matches('_')
|
||||
} else {
|
||||
leaf.strip_suffix(acc.as_str()) == Some(&prefix)
|
||||
}
|
||||
}) {
|
||||
return Some(FieldNamePosition::Prepend(prefix));
|
||||
}
|
||||
}
|
||||
|
||||
// SetBase
|
||||
if first_acc.is_empty() {
|
||||
return Some(FieldNamePosition::SetBase(first_leaf.clone()));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
//! Tree traversal utilities.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use brk_types::{Index, TreeNode};
|
||||
|
||||
use super::{PatternField, case::to_pascal_case, schema::schema_to_json_type};
|
||||
|
||||
/// Get the first leaf name from a tree node.
|
||||
pub fn get_first_leaf_name(node: &TreeNode) -> Option<String> {
|
||||
match node {
|
||||
TreeNode::Leaf(leaf) => Some(leaf.name().to_string()),
|
||||
TreeNode::Branch(children) => children.values().find_map(get_first_leaf_name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the metric base for a pattern instance by analyzing the first leaf descendant.
|
||||
pub fn get_pattern_instance_base(node: &TreeNode, field_name: &str) -> String {
|
||||
if let Some(leaf_name) = get_first_leaf_name(node)
|
||||
&& leaf_name.contains(field_name)
|
||||
{
|
||||
return field_name.to_string();
|
||||
}
|
||||
field_name.to_string()
|
||||
}
|
||||
|
||||
/// Get the field signature for a branch node's children.
|
||||
pub fn get_node_fields(
|
||||
children: &BTreeMap<String, TreeNode>,
|
||||
pattern_lookup: &HashMap<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.value_type().to_string(),
|
||||
schema_to_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,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
fields.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
fields
|
||||
}
|
||||
|
||||
/// Like get_node_fields but takes a parent name for generating child pattern names.
|
||||
pub fn get_node_fields_with_parent(
|
||||
children: &BTreeMap<String, TreeNode>,
|
||||
parent_name: &str,
|
||||
pattern_lookup: &HashMap<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.value_type().to_string(),
|
||||
schema_to_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(|| format!("{}_{}", parent_name, to_pascal_case(name)));
|
||||
(pattern_name.clone(), pattern_name, BTreeSet::new())
|
||||
}
|
||||
};
|
||||
PatternField {
|
||||
name: name.clone(),
|
||||
rust_type,
|
||||
json_type,
|
||||
indexes,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
fields.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
fields
|
||||
}
|
||||
|
||||
/// Get fields with child field information for generic pattern lookup.
|
||||
/// Returns (field, child_fields) pairs where child_fields is Some for branches.
|
||||
pub fn get_fields_with_child_info(
|
||||
children: &BTreeMap<String, TreeNode>,
|
||||
parent_name: &str,
|
||||
pattern_lookup: &HashMap<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.value_type().to_string(),
|
||||
schema_to_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(|| format!("{}_{}", parent_name, to_pascal_case(name)));
|
||||
(
|
||||
pattern_name.clone(),
|
||||
pattern_name,
|
||||
BTreeSet::new(),
|
||||
Some(child_fields),
|
||||
)
|
||||
}
|
||||
};
|
||||
(
|
||||
PatternField {
|
||||
name: name.clone(),
|
||||
rust_type,
|
||||
json_type,
|
||||
indexes,
|
||||
},
|
||||
child_fields,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Detect index patterns (sets of indexes that appear together on multiple metrics).
|
||||
pub fn detect_index_patterns(tree: &TreeNode) -> (BTreeSet<Index>, Vec<super::IndexSetPattern>) {
|
||||
let mut used_indexes: BTreeSet<Index> = BTreeSet::new();
|
||||
let mut index_sets: Vec<BTreeSet<Index>> = Vec::new();
|
||||
|
||||
collect_indexes_from_tree(tree, &mut used_indexes, &mut index_sets);
|
||||
|
||||
// Count occurrences of each unique index set
|
||||
let mut index_set_counts: Vec<(BTreeSet<Index>, usize)> = Vec::new();
|
||||
for index_set in index_sets {
|
||||
if let Some(entry) = index_set_counts.iter_mut().find(|(s, _)| s == &index_set) {
|
||||
entry.1 += 1;
|
||||
} else {
|
||||
index_set_counts.push((index_set, 1));
|
||||
}
|
||||
}
|
||||
|
||||
// Build patterns for index sets appearing 2+ times
|
||||
let mut patterns: Vec<super::IndexSetPattern> = index_set_counts
|
||||
.into_iter()
|
||||
.filter(|(indexes, count)| *count >= 2 && !indexes.is_empty())
|
||||
.enumerate()
|
||||
.map(|(i, (indexes, _))| super::IndexSetPattern {
|
||||
name: if i == 0 {
|
||||
"Indexes".to_string()
|
||||
} else {
|
||||
format!("Indexes{}", i + 1)
|
||||
},
|
||||
indexes,
|
||||
})
|
||||
.collect();
|
||||
|
||||
patterns.sort_by(|a, b| b.indexes.len().cmp(&a.indexes.len()));
|
||||
(used_indexes, patterns)
|
||||
}
|
||||
|
||||
fn collect_indexes_from_tree(
|
||||
node: &TreeNode,
|
||||
used_indexes: &mut BTreeSet<Index>,
|
||||
index_sets: &mut Vec<BTreeSet<Index>>,
|
||||
) {
|
||||
match node {
|
||||
TreeNode::Leaf(leaf) => {
|
||||
used_indexes.extend(leaf.indexes().iter().cloned());
|
||||
index_sets.push(leaf.indexes().clone());
|
||||
}
|
||||
TreeNode::Branch(children) => {
|
||||
for child in children.values() {
|
||||
collect_indexes_from_tree(child, used_indexes, index_sets);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,96 @@
|
||||
//! 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, 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)),
|
||||
("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,929 @@
|
||||
//! 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);
|
||||
}};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! Rust API method generation.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::{Endpoint, VERSION, generators::write_description, to_snake_case};
|
||||
|
||||
use super::types::js_type_to_rust;
|
||||
|
||||
/// Generate the main BrkClient struct.
|
||||
pub fn generate_main_client(output: &mut String, endpoints: &[Endpoint]) {
|
||||
writeln!(
|
||||
output,
|
||||
r#"/// Main BRK client with series tree and API methods.
|
||||
pub struct BrkClient {{
|
||||
base: Arc<BrkClientBase>,
|
||||
series: SeriesTree,
|
||||
}}
|
||||
|
||||
impl BrkClient {{
|
||||
/// Client version.
|
||||
pub const VERSION: &'static str = "v{VERSION}";
|
||||
|
||||
/// Create a new client with the given base URL.
|
||||
pub fn new(base_url: impl Into<String>) -> Self {{
|
||||
let base = Arc::new(BrkClientBase::new(base_url));
|
||||
let series = SeriesTree::new(base.clone(), String::new());
|
||||
Self {{ base, series }}
|
||||
}}
|
||||
|
||||
/// Create a new client with options.
|
||||
pub fn with_options(options: BrkClientOptions) -> Self {{
|
||||
let base = Arc::new(BrkClientBase::with_options(options));
|
||||
let series = SeriesTree::new(base.clone(), String::new());
|
||||
Self {{ base, series }}
|
||||
}}
|
||||
|
||||
/// Get the series tree for navigating series.
|
||||
pub fn series(&self) -> &SeriesTree {{
|
||||
&self.series
|
||||
}}
|
||||
|
||||
/// Create a dynamic series endpoint builder for any series/index combination.
|
||||
///
|
||||
/// Use this for programmatic access when the series name is determined at runtime.
|
||||
/// For type-safe access, use the `series()` tree instead.
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// let data = client.series("realized_price", Index::Height)
|
||||
/// .last(10)
|
||||
/// .json::<f64>()?;
|
||||
/// ```
|
||||
pub fn series_endpoint(&self, series: impl Into<SeriesName>, index: Index) -> SeriesEndpoint<serde_json::Value> {{
|
||||
SeriesEndpoint::new(
|
||||
self.base.clone(),
|
||||
Arc::from(series.into().as_str()),
|
||||
index,
|
||||
)
|
||||
}}
|
||||
|
||||
/// Create a dynamic date-based series endpoint builder.
|
||||
///
|
||||
/// Returns `Err` if the index is not date-based.
|
||||
pub fn date_series_endpoint(&self, series: impl Into<SeriesName>, index: Index) -> Result<DateSeriesEndpoint<serde_json::Value>> {{
|
||||
if !index.is_date_based() {{
|
||||
return Err(BrkError {{ message: format!("{{}} is not a date-based index", index.name()) }});
|
||||
}}
|
||||
Ok(DateSeriesEndpoint::new(
|
||||
self.base.clone(),
|
||||
Arc::from(series.into().as_str()),
|
||||
index,
|
||||
))
|
||||
}}
|
||||
"#,
|
||||
VERSION = VERSION
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
generate_api_methods(output, endpoints);
|
||||
|
||||
writeln!(output, "}}").unwrap();
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
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);
|
||||
|
||||
let params = build_method_params(endpoint);
|
||||
writeln!(
|
||||
output,
|
||||
" pub fn {}(&self{}) -> Result<{}> {{",
|
||||
method_name, params, return_type
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (path, index_arg) = build_path_template(endpoint);
|
||||
let fetch_method = if endpoint.returns_binary() {
|
||||
"get_bytes"
|
||||
} else if endpoint.returns_json() {
|
||||
"get_json"
|
||||
} else {
|
||||
"get_text"
|
||||
};
|
||||
|
||||
if endpoint.query_params.is_empty() {
|
||||
writeln!(
|
||||
output,
|
||||
" self.base.{}(&format!(\"{}\"{}))",
|
||||
fetch_method, path, index_arg
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
write_query_assembly(output, endpoint, &path, index_arg);
|
||||
|
||||
if endpoint.supports_csv {
|
||||
writeln!(output, " if format == Some(Format::CSV) {{").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" self.base.get_text(&path).map(FormatResponse::Csv)"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " }} else {{").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" self.base.{}(&path).map(FormatResponse::Json)",
|
||||
fetch_method
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " }}").unwrap();
|
||||
} else {
|
||||
writeln!(output, " self.base.{}(&path)", fetch_method).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);
|
||||
|
||||
let mut params = build_method_params(endpoint);
|
||||
if endpoint.request_body.is_some() {
|
||||
params.push_str(", body: &str");
|
||||
}
|
||||
writeln!(
|
||||
output,
|
||||
" pub fn {}(&self{}) -> Result<{}> {{",
|
||||
method_name, params, return_type
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (path, index_arg) = build_path_template(endpoint);
|
||||
let body_arg = if endpoint.request_body.is_some() {
|
||||
"body"
|
||||
} else {
|
||||
"\"\""
|
||||
};
|
||||
let fetch_method = if endpoint.returns_binary() {
|
||||
"post_bytes"
|
||||
} else if endpoint.returns_json() {
|
||||
"post_json"
|
||||
} else {
|
||||
"post_text"
|
||||
};
|
||||
|
||||
if endpoint.query_params.is_empty() {
|
||||
writeln!(
|
||||
output,
|
||||
" self.base.{}(&format!(\"{}\"{}), {})",
|
||||
fetch_method, path, index_arg, body_arg
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
write_query_assembly(output, endpoint, &path, index_arg);
|
||||
writeln!(
|
||||
output,
|
||||
" self.base.{}(&path, {})",
|
||||
fetch_method, body_arg
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
writeln!(output, " }}\n").unwrap();
|
||||
}
|
||||
|
||||
fn build_return_type(endpoint: &Endpoint) -> String {
|
||||
let base = if endpoint.returns_binary() {
|
||||
"Vec<u8>".to_string()
|
||||
} else if endpoint.returns_text() {
|
||||
"String".to_string()
|
||||
} else {
|
||||
endpoint
|
||||
.schema_name()
|
||||
.map(js_type_to_rust)
|
||||
.unwrap_or_else(|| "String".to_string())
|
||||
};
|
||||
if endpoint.supports_csv {
|
||||
format!("FormatResponse<{}>", base)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
fn write_method_doc(output: &mut String, endpoint: &Endpoint) {
|
||||
let method_name = endpoint_to_method_name(endpoint);
|
||||
writeln!(
|
||||
output,
|
||||
" /// {}",
|
||||
endpoint.summary.as_deref().unwrap_or(&method_name)
|
||||
)
|
||||
.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();
|
||||
}
|
||||
|
||||
fn write_query_assembly(output: &mut String, endpoint: &Endpoint, path: &str, index_arg: &str) {
|
||||
writeln!(output, " let mut query = Vec::new();").unwrap();
|
||||
for param in &endpoint.query_params {
|
||||
let ident = sanitize_ident(¶m.name);
|
||||
let is_array = param.param_type.ends_with("[]");
|
||||
if is_array {
|
||||
writeln!(
|
||||
output,
|
||||
" for v in {} {{ query.push(format!(\"{}={{}}\", v)); }}",
|
||||
ident, param.name
|
||||
)
|
||||
.unwrap();
|
||||
} else if param.required {
|
||||
writeln!(
|
||||
output,
|
||||
" query.push(format!(\"{}={{}}\", {}));",
|
||||
param.name, ident
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" if let Some(v) = {} {{ query.push(format!(\"{}={{}}\", v)); }}",
|
||||
ident, param.name
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
writeln!(output, " let query_str = if query.is_empty() {{ String::new() }} else {{ format!(\"?{{}}\", query.join(\"&\")) }};").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" let path = format!(\"{}{{}}\"{}, query_str);",
|
||||
path, index_arg
|
||||
)
|
||||
.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();
|
||||
for param in &endpoint.path_params {
|
||||
let rust_type = param_type_to_rust(¶m.param_type);
|
||||
params.push(format!(", {}: {}", sanitize_ident(¶m.name), rust_type));
|
||||
}
|
||||
for param in &endpoint.query_params {
|
||||
let rust_type = param_type_to_rust(¶m.param_type);
|
||||
let name = sanitize_ident(¶m.name);
|
||||
if param.required {
|
||||
params.push(format!(", {}: {}", name, rust_type));
|
||||
} else {
|
||||
params.push(format!(", {}: Option<{}>", name, rust_type));
|
||||
}
|
||||
}
|
||||
params.join("")
|
||||
}
|
||||
|
||||
/// Strip characters invalid in Rust identifiers (e.g. `[]` from `txId[]`).
|
||||
fn sanitize_ident(name: &str) -> String {
|
||||
name.replace(['[', ']'], "")
|
||||
}
|
||||
|
||||
/// Convert parameter type to Rust type for function signatures.
|
||||
fn param_type_to_rust(param_type: &str) -> String {
|
||||
if let Some(inner) = param_type.strip_suffix("[]") {
|
||||
return format!("&[{}]", param_type_to_rust(inner));
|
||||
}
|
||||
match param_type {
|
||||
"string" | "*" => "&str".to_string(),
|
||||
"integer" | "number" => "i64".to_string(),
|
||||
"boolean" => "bool".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build path template and extra format args for Index params.
|
||||
fn build_path_template(endpoint: &Endpoint) -> (String, &'static str) {
|
||||
let has_index_param = endpoint
|
||||
.path_params
|
||||
.iter()
|
||||
.any(|p| p.name == "index" && p.param_type == "Index");
|
||||
if has_index_param {
|
||||
(endpoint.path.replace("{index}", "{}"), ", index.name()")
|
||||
} else {
|
||||
(endpoint.path.clone(), "")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
//! Rust base client and pattern factory generation.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::{
|
||||
ClientMetadata, GenericSyntax, IndexSetPattern, RustSyntax, StructuralPattern,
|
||||
escape_rust_keyword, generate_parameterized_field, index_to_field_name, to_snake_case,
|
||||
};
|
||||
|
||||
/// Generate import statements.
|
||||
pub fn generate_imports(output: &mut String) {
|
||||
writeln!(
|
||||
output,
|
||||
r#"use std::sync::Arc;
|
||||
use std::ops::{{Bound, RangeBounds}};
|
||||
use serde::de::DeserializeOwned;
|
||||
pub use brk_cohort::*;
|
||||
pub use brk_types::*;
|
||||
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Generate the base BrkClientBase struct and error types.
|
||||
pub fn generate_base_client(output: &mut String) {
|
||||
writeln!(
|
||||
output,
|
||||
r#"/// Error type for BRK client operations.
|
||||
#[derive(Debug)]
|
||||
pub struct BrkError {{
|
||||
pub message: String,
|
||||
}}
|
||||
|
||||
impl std::fmt::Display for BrkError {{
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {{
|
||||
write!(f, "{{}}", self.message)
|
||||
}}
|
||||
}}
|
||||
|
||||
impl std::error::Error for BrkError {{}}
|
||||
|
||||
/// Result type for BRK client operations.
|
||||
pub type Result<T> = std::result::Result<T, BrkError>;
|
||||
|
||||
/// Options for configuring the BRK client.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BrkClientOptions {{
|
||||
pub base_url: String,
|
||||
pub timeout_secs: u64,
|
||||
}}
|
||||
|
||||
impl Default for BrkClientOptions {{
|
||||
fn default() -> Self {{
|
||||
Self {{
|
||||
base_url: "http://localhost:3000".to_string(),
|
||||
timeout_secs: 30,
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
/// Base HTTP client for making requests. Reuses connections via ureq::Agent.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BrkClientBase {{
|
||||
agent: ureq::Agent,
|
||||
base_url: String,
|
||||
}}
|
||||
|
||||
impl BrkClientBase {{
|
||||
/// Create a new client with the given base URL.
|
||||
pub fn new(base_url: impl Into<String>) -> Self {{
|
||||
Self::with_options(BrkClientOptions {{ base_url: base_url.into(), ..Default::default() }})
|
||||
}}
|
||||
|
||||
/// Create a new client with options.
|
||||
pub fn with_options(options: BrkClientOptions) -> Self {{
|
||||
let agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(std::time::Duration::from_secs(options.timeout_secs)))
|
||||
.build()
|
||||
.into();
|
||||
Self {{
|
||||
agent,
|
||||
base_url: options.base_url.trim_end_matches('/').to_string(),
|
||||
}}
|
||||
}}
|
||||
|
||||
fn url(&self, path: &str) -> String {{
|
||||
format!("{{}}{{}}", self.base_url, path)
|
||||
}}
|
||||
|
||||
/// Make a GET request and deserialize JSON response.
|
||||
pub fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {{
|
||||
self.agent.get(&self.url(path))
|
||||
.call()
|
||||
.and_then(|mut r| r.body_mut().read_json())
|
||||
.map_err(|e| BrkError {{ message: e.to_string() }})
|
||||
}}
|
||||
|
||||
/// Make a GET request and return raw text response.
|
||||
pub fn get_text(&self, path: &str) -> Result<String> {{
|
||||
self.agent.get(&self.url(path))
|
||||
.call()
|
||||
.and_then(|mut r| r.body_mut().read_to_string())
|
||||
.map_err(|e| BrkError {{ message: e.to_string() }})
|
||||
}}
|
||||
|
||||
/// Make a GET request and return raw bytes response.
|
||||
pub fn get_bytes(&self, path: &str) -> Result<Vec<u8>> {{
|
||||
self.agent.get(&self.url(path))
|
||||
.call()
|
||||
.and_then(|mut r| r.body_mut().read_to_vec())
|
||||
.map_err(|e| BrkError {{ message: e.to_string() }})
|
||||
}}
|
||||
|
||||
/// Make a POST request and deserialize JSON response.
|
||||
pub fn post_json<T: DeserializeOwned>(&self, path: &str, body: &str) -> Result<T> {{
|
||||
self.agent.post(&self.url(path))
|
||||
.send(body)
|
||||
.and_then(|mut r| r.body_mut().read_json())
|
||||
.map_err(|e| BrkError {{ message: e.to_string() }})
|
||||
}}
|
||||
|
||||
/// Make a POST request and return raw text response.
|
||||
pub fn post_text(&self, path: &str, body: &str) -> Result<String> {{
|
||||
self.agent.post(&self.url(path))
|
||||
.send(body)
|
||||
.and_then(|mut r| r.body_mut().read_to_string())
|
||||
.map_err(|e| BrkError {{ message: e.to_string() }})
|
||||
}}
|
||||
|
||||
/// Make a POST request and return raw bytes response.
|
||||
pub fn post_bytes(&self, path: &str, body: &str) -> Result<Vec<u8>> {{
|
||||
self.agent.post(&self.url(path))
|
||||
.send(body)
|
||||
.and_then(|mut r| r.body_mut().read_to_vec())
|
||||
.map_err(|e| BrkError {{ message: e.to_string() }})
|
||||
}}
|
||||
}}
|
||||
|
||||
/// Build series name with suffix.
|
||||
#[inline]
|
||||
fn _m(acc: &str, s: &str) -> String {{
|
||||
if s.is_empty() {{ acc.to_string() }}
|
||||
else if acc.is_empty() {{ s.to_string() }}
|
||||
else {{ format!("{{acc}}_{{s}}") }}
|
||||
}}
|
||||
|
||||
/// Build series name with prefix.
|
||||
#[inline]
|
||||
fn _p(prefix: &str, acc: &str) -> String {{
|
||||
if acc.is_empty() {{ prefix.to_string() }} else {{ format!("{{prefix}}_{{acc}}") }}
|
||||
}}
|
||||
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Generate the SeriesPattern trait.
|
||||
pub fn generate_series_pattern_trait(output: &mut String) {
|
||||
writeln!(
|
||||
output,
|
||||
r#"/// Non-generic trait for series patterns (usable in collections).
|
||||
pub trait AnySeriesPattern {{
|
||||
/// Get the series name.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Get the list of available indexes for this series.
|
||||
fn indexes(&self) -> &'static [Index];
|
||||
}}
|
||||
|
||||
/// Generic trait for series patterns with endpoint access.
|
||||
pub trait SeriesPattern<T>: AnySeriesPattern {{
|
||||
/// Get an endpoint builder for a specific index, if supported.
|
||||
fn get(&self, index: Index) -> Option<SeriesEndpoint<T>>;
|
||||
}}
|
||||
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Generate the SeriesEndpoint structs with typestate pattern.
|
||||
pub fn generate_endpoint(output: &mut String) {
|
||||
writeln!(
|
||||
output,
|
||||
r#"/// Shared endpoint configuration.
|
||||
#[derive(Clone)]
|
||||
struct EndpointConfig {{
|
||||
client: Arc<BrkClientBase>,
|
||||
name: Arc<str>,
|
||||
index: Index,
|
||||
start: Option<i64>,
|
||||
end: Option<i64>,
|
||||
}}
|
||||
|
||||
impl EndpointConfig {{
|
||||
fn new(client: Arc<BrkClientBase>, name: Arc<str>, index: Index) -> Self {{
|
||||
Self {{ client, name, index, start: None, end: None }}
|
||||
}}
|
||||
|
||||
fn path(&self) -> String {{
|
||||
format!("/api/series/{{}}/{{}}", self.name, self.index.name())
|
||||
}}
|
||||
|
||||
fn build_path(&self, format: Option<&str>) -> String {{
|
||||
let mut params = Vec::new();
|
||||
if let Some(s) = self.start {{ params.push(format!("start={{}}", s)); }}
|
||||
if let Some(e) = self.end {{ params.push(format!("end={{}}", e)); }}
|
||||
if let Some(fmt) = format {{ params.push(format!("format={{}}", fmt)); }}
|
||||
let p = self.path();
|
||||
if params.is_empty() {{ p }} else {{ format!("{{}}?{{}}", p, params.join("&")) }}
|
||||
}}
|
||||
|
||||
fn get_json<T: DeserializeOwned>(&self, format: Option<&str>) -> Result<T> {{
|
||||
self.client.get_json(&self.build_path(format))
|
||||
}}
|
||||
|
||||
fn get_text(&self, format: Option<&str>) -> Result<String> {{
|
||||
self.client.get_text(&self.build_path(format))
|
||||
}}
|
||||
|
||||
fn get_len(&self) -> Result<i64> {{
|
||||
self.client.get_json(&format!("/api/series/{{}}/{{}}/len", self.name, self.index.name()))
|
||||
}}
|
||||
|
||||
fn get_version(&self) -> Result<Version> {{
|
||||
self.client.get_json(&format!("/api/series/{{}}/{{}}/version", self.name, self.index.name()))
|
||||
}}
|
||||
}}
|
||||
|
||||
/// Builder for series endpoint queries.
|
||||
///
|
||||
/// Parameterized by element type `T` and response type `D` (defaults to `SeriesData<T>`).
|
||||
/// For date-based indexes, use `DateSeriesEndpoint<T>` which sets `D = DateSeriesData<T>`.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```ignore
|
||||
/// let data = endpoint.fetch()?; // all data
|
||||
/// let data = endpoint.get(5).fetch()?; // single item
|
||||
/// let data = endpoint.range(..10).fetch()?; // first 10
|
||||
/// let data = endpoint.range(100..200).fetch()?; // range [100, 200)
|
||||
/// let data = endpoint.take(10).fetch()?; // first 10 (convenience)
|
||||
/// let data = endpoint.last(10).fetch()?; // last 10
|
||||
/// let data = endpoint.skip(100).take(10).fetch()?; // iterator-style
|
||||
/// ```
|
||||
pub struct SeriesEndpoint<T, D = SeriesData<T>> {{
|
||||
config: EndpointConfig,
|
||||
_marker: std::marker::PhantomData<fn() -> (T, D)>,
|
||||
}}
|
||||
|
||||
/// Builder for date-based series endpoint queries.
|
||||
///
|
||||
/// Like `SeriesEndpoint` but returns `DateSeriesData` and provides
|
||||
/// date-based access methods (`get_date`, `date_range`).
|
||||
pub type DateSeriesEndpoint<T> = SeriesEndpoint<T, DateSeriesData<T>>;
|
||||
|
||||
impl<T: DeserializeOwned, D: DeserializeOwned> SeriesEndpoint<T, D> {{
|
||||
pub fn new(client: Arc<BrkClientBase>, name: Arc<str>, index: Index) -> Self {{
|
||||
Self {{ config: EndpointConfig::new(client, name, index), _marker: std::marker::PhantomData }}
|
||||
}}
|
||||
|
||||
/// Select a specific index position.
|
||||
pub fn get(mut self, index: usize) -> SingleItemBuilder<T, D> {{
|
||||
self.config.start = Some(index as i64);
|
||||
self.config.end = Some(index as i64 + 1);
|
||||
SingleItemBuilder {{ config: self.config, _marker: std::marker::PhantomData }}
|
||||
}}
|
||||
|
||||
/// Select a range using Rust range syntax.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```ignore
|
||||
/// endpoint.range(..10) // first 10
|
||||
/// endpoint.range(100..110) // indices 100-109
|
||||
/// endpoint.range(100..) // from 100 to end
|
||||
/// ```
|
||||
pub fn range<R: RangeBounds<usize>>(mut self, range: R) -> RangeBuilder<T, D> {{
|
||||
self.config.start = match range.start_bound() {{
|
||||
Bound::Included(&n) => Some(n as i64),
|
||||
Bound::Excluded(&n) => Some(n as i64 + 1),
|
||||
Bound::Unbounded => None,
|
||||
}};
|
||||
self.config.end = match range.end_bound() {{
|
||||
Bound::Included(&n) => Some(n as i64 + 1),
|
||||
Bound::Excluded(&n) => Some(n as i64),
|
||||
Bound::Unbounded => None,
|
||||
}};
|
||||
RangeBuilder {{ config: self.config, _marker: std::marker::PhantomData }}
|
||||
}}
|
||||
|
||||
/// Take the first n items.
|
||||
pub fn take(self, n: usize) -> RangeBuilder<T, D> {{
|
||||
self.range(..n)
|
||||
}}
|
||||
|
||||
/// Take the last n items.
|
||||
pub fn last(mut self, n: usize) -> RangeBuilder<T, D> {{
|
||||
if n == 0 {{
|
||||
self.config.end = Some(0);
|
||||
}} else {{
|
||||
self.config.start = Some(-(n as i64));
|
||||
}}
|
||||
RangeBuilder {{ config: self.config, _marker: std::marker::PhantomData }}
|
||||
}}
|
||||
|
||||
/// Skip the first n items. Chain with `take(n)` to get a range.
|
||||
pub fn skip(mut self, n: usize) -> SkippedBuilder<T, D> {{
|
||||
self.config.start = Some(n as i64);
|
||||
SkippedBuilder {{ config: self.config, _marker: std::marker::PhantomData }}
|
||||
}}
|
||||
|
||||
/// Fetch all data as parsed JSON.
|
||||
pub fn fetch(self) -> Result<D> {{
|
||||
self.config.get_json(None)
|
||||
}}
|
||||
|
||||
/// Fetch all data as CSV string.
|
||||
pub fn fetch_csv(self) -> Result<String> {{
|
||||
self.config.get_text(Some("csv"))
|
||||
}}
|
||||
|
||||
/// Total number of data points for this series.
|
||||
#[allow(clippy::len_without_is_empty)]
|
||||
pub fn len(&self) -> Result<i64> {{
|
||||
self.config.get_len()
|
||||
}}
|
||||
|
||||
/// Current version of the series.
|
||||
pub fn version(&self) -> Result<Version> {{
|
||||
self.config.get_version()
|
||||
}}
|
||||
|
||||
/// Get the base endpoint path.
|
||||
pub fn path(&self) -> String {{
|
||||
self.config.path()
|
||||
}}
|
||||
}}
|
||||
|
||||
/// Date-specific methods available only on `DateSeriesEndpoint`.
|
||||
impl<T: DeserializeOwned> SeriesEndpoint<T, DateSeriesData<T>> {{
|
||||
/// Select a specific date position (for day-precision or coarser indexes).
|
||||
pub fn get_date(self, date: Date) -> SingleItemBuilder<T, DateSeriesData<T>> {{
|
||||
let index = self.config.index.date_to_index(date).unwrap_or(0);
|
||||
self.get(index)
|
||||
}}
|
||||
|
||||
/// Select a date range (for day-precision or coarser indexes).
|
||||
pub fn date_range(self, start: Date, end: Date) -> RangeBuilder<T, DateSeriesData<T>> {{
|
||||
let s = self.config.index.date_to_index(start).unwrap_or(0);
|
||||
let e = self.config.index.date_to_index(end).unwrap_or(0);
|
||||
self.range(s..e)
|
||||
}}
|
||||
|
||||
/// Select a specific timestamp position (works for all date-based indexes including sub-daily).
|
||||
pub fn get_timestamp(self, ts: Timestamp) -> SingleItemBuilder<T, DateSeriesData<T>> {{
|
||||
let index = self.config.index.timestamp_to_index(ts).unwrap_or(0);
|
||||
self.get(index)
|
||||
}}
|
||||
|
||||
/// Select a timestamp range (works for all date-based indexes including sub-daily).
|
||||
pub fn timestamp_range(self, start: Timestamp, end: Timestamp) -> RangeBuilder<T, DateSeriesData<T>> {{
|
||||
let s = self.config.index.timestamp_to_index(start).unwrap_or(0);
|
||||
let e = self.config.index.timestamp_to_index(end).unwrap_or(0);
|
||||
self.range(s..e)
|
||||
}}
|
||||
}}
|
||||
|
||||
/// Builder for single item access.
|
||||
pub struct SingleItemBuilder<T, D = SeriesData<T>> {{
|
||||
config: EndpointConfig,
|
||||
_marker: std::marker::PhantomData<fn() -> (T, D)>,
|
||||
}}
|
||||
|
||||
/// Date-aware single item builder.
|
||||
pub type DateSingleItemBuilder<T> = SingleItemBuilder<T, DateSeriesData<T>>;
|
||||
|
||||
impl<T: DeserializeOwned, D: DeserializeOwned> SingleItemBuilder<T, D> {{
|
||||
/// Fetch the single item.
|
||||
pub fn fetch(self) -> Result<D> {{
|
||||
self.config.get_json(None)
|
||||
}}
|
||||
|
||||
/// Fetch the single item as CSV.
|
||||
pub fn fetch_csv(self) -> Result<String> {{
|
||||
self.config.get_text(Some("csv"))
|
||||
}}
|
||||
}}
|
||||
|
||||
/// Builder after calling `skip(n)`. Chain with `take(n)` to specify count.
|
||||
pub struct SkippedBuilder<T, D = SeriesData<T>> {{
|
||||
config: EndpointConfig,
|
||||
_marker: std::marker::PhantomData<fn() -> (T, D)>,
|
||||
}}
|
||||
|
||||
/// Date-aware skipped builder.
|
||||
pub type DateSkippedBuilder<T> = SkippedBuilder<T, DateSeriesData<T>>;
|
||||
|
||||
impl<T: DeserializeOwned, D: DeserializeOwned> SkippedBuilder<T, D> {{
|
||||
/// Take n items after the skipped position.
|
||||
pub fn take(mut self, n: usize) -> RangeBuilder<T, D> {{
|
||||
let start = self.config.start.unwrap_or(0);
|
||||
self.config.end = Some(start + n as i64);
|
||||
RangeBuilder {{ config: self.config, _marker: std::marker::PhantomData }}
|
||||
}}
|
||||
|
||||
/// Fetch from the skipped position to the end.
|
||||
pub fn fetch(self) -> Result<D> {{
|
||||
self.config.get_json(None)
|
||||
}}
|
||||
|
||||
/// Fetch from the skipped position to the end as CSV.
|
||||
pub fn fetch_csv(self) -> Result<String> {{
|
||||
self.config.get_text(Some("csv"))
|
||||
}}
|
||||
}}
|
||||
|
||||
/// Builder with range fully specified.
|
||||
pub struct RangeBuilder<T, D = SeriesData<T>> {{
|
||||
config: EndpointConfig,
|
||||
_marker: std::marker::PhantomData<fn() -> (T, D)>,
|
||||
}}
|
||||
|
||||
/// Date-aware range builder.
|
||||
pub type DateRangeBuilder<T> = RangeBuilder<T, DateSeriesData<T>>;
|
||||
|
||||
impl<T: DeserializeOwned, D: DeserializeOwned> RangeBuilder<T, D> {{
|
||||
/// Fetch the range as parsed JSON.
|
||||
pub fn fetch(self) -> Result<D> {{
|
||||
self.config.get_json(None)
|
||||
}}
|
||||
|
||||
/// Fetch the range as CSV string.
|
||||
pub fn fetch_csv(self) -> Result<String> {{
|
||||
self.config.get_text(Some("csv"))
|
||||
}}
|
||||
}}
|
||||
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Generate index accessor structs.
|
||||
pub fn generate_index_accessors(output: &mut String, patterns: &[IndexSetPattern]) {
|
||||
if patterns.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate static index arrays
|
||||
writeln!(output, "// Static index arrays").unwrap();
|
||||
for (i, pattern) in patterns.iter().enumerate() {
|
||||
write!(output, "const _I{}: &[Index] = &[", i + 1).unwrap();
|
||||
for (j, index) in pattern.indexes.iter().enumerate() {
|
||||
if j > 0 {
|
||||
write!(output, ", ").unwrap();
|
||||
}
|
||||
write!(output, "Index::{}", index).unwrap();
|
||||
}
|
||||
writeln!(output, "];").unwrap();
|
||||
}
|
||||
writeln!(output).unwrap();
|
||||
|
||||
// Generate helper functions
|
||||
writeln!(
|
||||
output,
|
||||
r#"#[inline]
|
||||
fn _ep<T: DeserializeOwned>(c: &Arc<BrkClientBase>, n: &Arc<str>, i: Index) -> SeriesEndpoint<T> {{
|
||||
SeriesEndpoint::new(c.clone(), n.clone(), i)
|
||||
}}
|
||||
|
||||
#[inline]
|
||||
fn _dep<T: DeserializeOwned>(c: &Arc<BrkClientBase>, n: &Arc<str>, i: Index) -> DateSeriesEndpoint<T> {{
|
||||
DateSeriesEndpoint::new(c.clone(), n.clone(), i)
|
||||
}}
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Generate index accessor structs
|
||||
writeln!(output, "// Index accessor structs\n").unwrap();
|
||||
|
||||
for (i, pattern) in patterns.iter().enumerate() {
|
||||
let by_name = format!("{}By", pattern.name);
|
||||
let idx_const = format!("_I{}", i + 1);
|
||||
|
||||
// Generate the "By" struct
|
||||
writeln!(output, "pub struct {}<T> {{ client: Arc<BrkClientBase>, name: Arc<str>, _marker: std::marker::PhantomData<T> }}", by_name).unwrap();
|
||||
writeln!(output, "impl<T: DeserializeOwned> {}<T> {{", by_name).unwrap();
|
||||
for index in &pattern.indexes {
|
||||
let method_name = index_to_field_name(index);
|
||||
if index.is_date_based() {
|
||||
writeln!(
|
||||
output,
|
||||
" pub fn {}(&self) -> DateSeriesEndpoint<T> {{ _dep(&self.client, &self.name, Index::{}) }}",
|
||||
method_name, index
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" pub fn {}(&self) -> SeriesEndpoint<T> {{ _ep(&self.client, &self.name, Index::{}) }}",
|
||||
method_name, index
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
writeln!(output, "}}\n").unwrap();
|
||||
|
||||
// Generate the main accessor struct
|
||||
writeln!(
|
||||
output,
|
||||
"pub struct {}<T> {{ name: Arc<str>, pub by: {}<T> }}",
|
||||
pattern.name, by_name
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, "impl<T: DeserializeOwned> {}<T> {{", pattern.name).unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" pub fn new(client: Arc<BrkClientBase>, name: String) -> Self {{ let name: Arc<str> = name.into(); Self {{ name: name.clone(), by: {} {{ client, name, _marker: std::marker::PhantomData }} }} }}",
|
||||
by_name
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " pub fn name(&self) -> &str {{ &self.name }}").unwrap();
|
||||
writeln!(output, "}}\n").unwrap();
|
||||
|
||||
// Implement AnySeriesPattern trait
|
||||
writeln!(
|
||||
output,
|
||||
"impl<T> AnySeriesPattern for {}<T> {{ fn name(&self) -> &str {{ &self.name }} fn indexes(&self) -> &'static [Index] {{ {} }} }}",
|
||||
pattern.name, idx_const
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Implement SeriesPattern<T> trait
|
||||
writeln!(
|
||||
output,
|
||||
"impl<T: DeserializeOwned> SeriesPattern<T> for {}<T> {{ fn get(&self, index: Index) -> Option<SeriesEndpoint<T>> {{ {}.contains(&index).then(|| _ep(&self.by.client, &self.by.name, index)) }} }}\n",
|
||||
pattern.name, idx_const
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate structural pattern structs.
|
||||
pub fn generate_pattern_structs(
|
||||
output: &mut String,
|
||||
patterns: &[StructuralPattern],
|
||||
metadata: &ClientMetadata,
|
||||
) {
|
||||
if patterns.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
writeln!(output, "// Reusable pattern structs\n").unwrap();
|
||||
|
||||
for pattern in patterns {
|
||||
let generic_params = if pattern.is_generic { "<T>" } else { "" };
|
||||
|
||||
// Generate struct definition
|
||||
writeln!(output, "/// Pattern struct for repeated tree structure.").unwrap();
|
||||
writeln!(output, "pub struct {}{} {{", pattern.name, generic_params).unwrap();
|
||||
|
||||
for field in &pattern.fields {
|
||||
let field_name = escape_rust_keyword(&to_snake_case(&field.name));
|
||||
let type_annotation = metadata.field_type_annotation(
|
||||
field,
|
||||
pattern.is_generic,
|
||||
None,
|
||||
GenericSyntax::RUST,
|
||||
);
|
||||
writeln!(output, " pub {}: {},", field_name, type_annotation).unwrap();
|
||||
}
|
||||
|
||||
writeln!(output, "}}\n").unwrap();
|
||||
|
||||
// Skip constructor for non-parameterizable patterns (inlined at tree level)
|
||||
if !metadata.is_parameterizable(&pattern.name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let impl_generic = if pattern.is_generic {
|
||||
"<T: DeserializeOwned>"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
writeln!(
|
||||
output,
|
||||
"impl{} {}{} {{",
|
||||
impl_generic, pattern.name, generic_params
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
writeln!(
|
||||
output,
|
||||
" /// Create a new pattern node with accumulated series name."
|
||||
)
|
||||
.unwrap();
|
||||
if pattern.is_templated() {
|
||||
writeln!(
|
||||
output,
|
||||
" pub fn new(client: Arc<BrkClientBase>, acc: String, disc: String) -> Self {{"
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
output,
|
||||
" pub fn new(client: Arc<BrkClientBase>, acc: String) -> Self {{"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(output, " Self {{").unwrap();
|
||||
|
||||
let syntax = RustSyntax;
|
||||
for field in &pattern.fields {
|
||||
generate_parameterized_field(output, &syntax, field, pattern, metadata, " ");
|
||||
}
|
||||
|
||||
writeln!(output, " }}").unwrap();
|
||||
writeln!(output, " }}").unwrap();
|
||||
writeln!(output, "}}\n").unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Rust client generation.
|
||||
//!
|
||||
//! This module generates a Rust client with full type safety for the BRK API.
|
||||
|
||||
pub mod api;
|
||||
pub mod client;
|
||||
pub mod tree;
|
||||
mod types;
|
||||
|
||||
use std::{fmt::Write, io, path::Path};
|
||||
|
||||
use super::write_if_changed;
|
||||
use crate::{ClientMetadata, Endpoint};
|
||||
|
||||
/// Generate Rust client from metadata and OpenAPI endpoints.
|
||||
///
|
||||
/// `output_path` is the full path to the output file (e.g., "crates/brk_client/src/lib.rs").
|
||||
pub fn generate_rust_client(
|
||||
metadata: &ClientMetadata,
|
||||
endpoints: &[Endpoint],
|
||||
output_path: &Path,
|
||||
) -> io::Result<()> {
|
||||
let mut output = String::new();
|
||||
|
||||
writeln!(output, "// Auto-generated BRK Rust client").unwrap();
|
||||
writeln!(output, "// Do not edit manually\n").unwrap();
|
||||
writeln!(output, "#![allow(non_camel_case_types)]").unwrap();
|
||||
writeln!(output, "#![allow(non_snake_case)]").unwrap();
|
||||
writeln!(output, "#![allow(dead_code)]").unwrap();
|
||||
writeln!(output, "#![allow(unused_variables)]").unwrap();
|
||||
writeln!(output, "#![allow(clippy::useless_format)]").unwrap();
|
||||
writeln!(output, "#![allow(clippy::unnecessary_to_owned)]\n").unwrap();
|
||||
|
||||
client::generate_imports(&mut output);
|
||||
client::generate_base_client(&mut output);
|
||||
client::generate_series_pattern_trait(&mut output);
|
||||
client::generate_endpoint(&mut output);
|
||||
client::generate_index_accessors(&mut output, &metadata.index_set_patterns);
|
||||
client::generate_pattern_structs(&mut output, &metadata.structural_patterns, metadata);
|
||||
tree::generate_tree(&mut output, &metadata.catalog, metadata);
|
||||
api::generate_main_client(&mut output, endpoints);
|
||||
|
||||
write_if_changed(output_path, &output)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Rust tree structure generation.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt::Write;
|
||||
|
||||
use brk_types::TreeNode;
|
||||
|
||||
use crate::{
|
||||
ClientMetadata, GenericSyntax, LanguageSyntax, PatternField, RustSyntax, build_child_path,
|
||||
escape_rust_keyword, generate_leaf_field, generate_tree_node_field, prepare_tree_node,
|
||||
to_snake_case,
|
||||
};
|
||||
|
||||
/// Generate tree structs.
|
||||
pub fn generate_tree(output: &mut String, catalog: &TreeNode, metadata: &ClientMetadata) {
|
||||
writeln!(output, "// Series tree\n").unwrap();
|
||||
|
||||
let pattern_lookup = metadata.pattern_lookup();
|
||||
let mut generated = BTreeSet::new();
|
||||
generate_tree_node(
|
||||
output,
|
||||
"SeriesTree",
|
||||
"",
|
||||
catalog,
|
||||
pattern_lookup,
|
||||
metadata,
|
||||
&mut generated,
|
||||
);
|
||||
}
|
||||
|
||||
fn generate_tree_node(
|
||||
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 struct definition
|
||||
writeln!(output, "/// Series tree node.").unwrap();
|
||||
writeln!(output, "pub struct {} {{", name).unwrap();
|
||||
|
||||
for child in &ctx.children {
|
||||
let field_name = escape_rust_keyword(&to_snake_case(child.name));
|
||||
let type_annotation = if child.should_inline {
|
||||
child.inline_type_name.clone()
|
||||
} else {
|
||||
metadata.field_type_annotation(&child.field, false, None, GenericSyntax::RUST)
|
||||
};
|
||||
writeln!(output, " pub {}: {},", field_name, type_annotation).unwrap();
|
||||
}
|
||||
|
||||
writeln!(output, "}}\n").unwrap();
|
||||
|
||||
// Generate impl block
|
||||
writeln!(output, "impl {} {{", name).unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" pub fn new(client: Arc<BrkClientBase>, base_path: String) -> Self {{"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " Self {{").unwrap();
|
||||
|
||||
let syntax = RustSyntax;
|
||||
for child in &ctx.children {
|
||||
let field_name = escape_rust_keyword(&to_snake_case(child.name));
|
||||
|
||||
if child.is_leaf {
|
||||
if let TreeNode::Leaf(leaf) = child.node {
|
||||
generate_leaf_field(
|
||||
output,
|
||||
&syntax,
|
||||
"client.clone()",
|
||||
child.name,
|
||||
leaf,
|
||||
metadata,
|
||||
" ",
|
||||
);
|
||||
}
|
||||
} else if child.should_inline {
|
||||
// Inline struct type - only for nodes that don't match any pattern
|
||||
let path_expr = syntax.path_expr("base_path", &format!("_{}", child.name));
|
||||
writeln!(
|
||||
output,
|
||||
" {}: {}::new(client.clone(), {}),",
|
||||
field_name, child.inline_type_name, path_expr
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
generate_tree_node_field(
|
||||
output,
|
||||
&syntax,
|
||||
&child.field,
|
||||
metadata,
|
||||
" ",
|
||||
"client.clone()",
|
||||
&child.base_result,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(output, " }}").unwrap();
|
||||
writeln!(output, " }}").unwrap();
|
||||
writeln!(output, "}}\n").unwrap();
|
||||
|
||||
// Generate child structs
|
||||
for child in &ctx.children {
|
||||
if child.should_inline {
|
||||
let child_path = build_child_path(path, child.name);
|
||||
generate_tree_node(
|
||||
output,
|
||||
&child.inline_type_name,
|
||||
&child_path,
|
||||
child.node,
|
||||
pattern_lookup,
|
||||
metadata,
|
||||
generated,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Rust type conversion utilities.
|
||||
|
||||
/// Convert JS-style type to Rust type.
|
||||
pub fn js_type_to_rust(js_type: &str) -> String {
|
||||
if let Some(inner) = js_type.strip_suffix("[]") {
|
||||
format!("Vec<{}>", js_type_to_rust(inner))
|
||||
} else {
|
||||
match js_type {
|
||||
"string" => "String".to_string(),
|
||||
"integer" => "i64".to_string(),
|
||||
"number" => "f64".to_string(),
|
||||
"boolean" => "bool".to_string(),
|
||||
"*" | "Object" => "serde_json::Value".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +1,89 @@
|
||||
//! Client library generator for BRK.
|
||||
//!
|
||||
//! This crate generates typed client libraries in multiple languages (Rust, JavaScript, Python)
|
||||
//! from the BRK metric catalog and OpenAPI specification.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use brk_binder::generate_clients;
|
||||
//! use brk_query::Vecs;
|
||||
//! use std::path::Path;
|
||||
//!
|
||||
//! let vecs = Vecs::load("path/to/data")?;
|
||||
//! let openapi_json = std::fs::read_to_string("openapi.json")?;
|
||||
//! generate_clients(&vecs, &openapi_json, Path::new("output"))?;
|
||||
//! ```
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The generator works in several phases:
|
||||
//!
|
||||
//! 1. **Metadata extraction** - Analyzes the metric catalog tree to detect:
|
||||
//! - Structural patterns (repeated tree shapes)
|
||||
//! - Index set patterns (common index combinations)
|
||||
//! - Generic patterns (structures that differ only in value type)
|
||||
//!
|
||||
//! 2. **Schema collection** - Merges OpenAPI schemas with schemars-generated type schemas
|
||||
//!
|
||||
//! 3. **Code generation** - Produces language-specific clients:
|
||||
//! - Rust: Uses `brk_types` directly, generates structs with lifetimes
|
||||
//! - JavaScript: Generates JSDoc-typed ES modules with factory functions
|
||||
//! - Python: Generates typed classes with TypedDict and Generic support
|
||||
#![allow(clippy::type_complexity)]
|
||||
|
||||
use std::{collections::btree_map::Entry, fs::create_dir_all, io, path::Path};
|
||||
use std::{collections::btree_map::Entry, fs::create_dir_all, io, path::PathBuf};
|
||||
|
||||
use brk_query::Vecs;
|
||||
|
||||
mod javascript;
|
||||
mod js;
|
||||
/// Output path configuration for each language client.
|
||||
///
|
||||
/// Each path should be the full path to the output file, not just a directory.
|
||||
/// Parent directories will be created automatically if they don't exist.
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// let paths = ClientOutputPaths::new()
|
||||
/// .rust("crates/brk_client/src/lib.rs")
|
||||
/// .javascript("modules/brk-client/index.js")
|
||||
/// .python("packages/brk_client/__init__.py");
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ClientOutputPaths {
|
||||
/// Full path to Rust client file (e.g., "crates/brk_client/src/lib.rs")
|
||||
pub rust: Option<PathBuf>,
|
||||
/// Full path to JavaScript client file (e.g., "modules/brk-client/index.js")
|
||||
pub javascript: Option<PathBuf>,
|
||||
/// Full path to Python client file (e.g., "packages/brk_client/__init__.py")
|
||||
pub python: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl ClientOutputPaths {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn rust(mut self, path: impl Into<PathBuf>) -> Self {
|
||||
self.rust = Some(path.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn javascript(mut self, path: impl Into<PathBuf>) -> Self {
|
||||
self.javascript = Some(path.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn python(mut self, path: impl Into<PathBuf>) -> Self {
|
||||
self.python = Some(path.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
mod analysis;
|
||||
mod backends;
|
||||
mod generate;
|
||||
mod generators;
|
||||
mod openapi;
|
||||
mod python;
|
||||
mod rust;
|
||||
mod syntax;
|
||||
mod types;
|
||||
|
||||
pub use javascript::*;
|
||||
pub use js::*;
|
||||
pub use analysis::*;
|
||||
pub use backends::*;
|
||||
pub use generate::*;
|
||||
pub use generators::*;
|
||||
pub use openapi::*;
|
||||
pub use python::*;
|
||||
pub use rust::*;
|
||||
pub use syntax::*;
|
||||
pub use types::*;
|
||||
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Generate all client libraries from the query vecs and OpenAPI JSON
|
||||
pub fn generate_clients(vecs: &Vecs, openapi_json: &str, output_dir: &Path) -> io::Result<()> {
|
||||
/// Generate all client libraries from the query vecs and OpenAPI JSON.
|
||||
///
|
||||
/// Uses `ClientOutputPaths` to specify the output file path for each language.
|
||||
/// Only languages with a configured path will be generated.
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// let paths = ClientOutputPaths::new()
|
||||
/// .rust("crates/brk_client/src/lib.rs")
|
||||
/// .javascript("modules/brk-client/index.js")
|
||||
/// .python("packages/brk_client/__init__.py");
|
||||
///
|
||||
/// generate_clients(&vecs, &openapi_json, &paths)?;
|
||||
/// ```
|
||||
pub fn generate_clients(
|
||||
vecs: &Vecs,
|
||||
openapi_json: &str,
|
||||
output_paths: &ClientOutputPaths,
|
||||
) -> io::Result<()> {
|
||||
let metadata = ClientMetadata::from_vecs(vecs);
|
||||
|
||||
// Parse OpenAPI spec
|
||||
@@ -71,19 +102,28 @@ pub fn generate_clients(vecs: &Vecs, openapi_json: &str, output_dir: &Path) -> i
|
||||
}
|
||||
|
||||
// Generate Rust client (uses real brk_types, no schema conversion needed)
|
||||
let rust_path = output_dir.join("rust");
|
||||
create_dir_all(&rust_path)?;
|
||||
generate_rust_client(&metadata, &endpoints, &rust_path)?;
|
||||
if let Some(rust_path) = &output_paths.rust {
|
||||
if let Some(parent) = rust_path.parent() {
|
||||
create_dir_all(parent)?;
|
||||
}
|
||||
generate_rust_client(&metadata, &endpoints, rust_path)?;
|
||||
}
|
||||
|
||||
// Generate JavaScript client (needs schemas for type definitions)
|
||||
let js_path = output_dir.join("javascript");
|
||||
create_dir_all(&js_path)?;
|
||||
generate_javascript_client(&metadata, &endpoints, &schemas, &js_path)?;
|
||||
if let Some(js_path) = &output_paths.javascript {
|
||||
if let Some(parent) = js_path.parent() {
|
||||
create_dir_all(parent)?;
|
||||
}
|
||||
generate_javascript_client(&metadata, &endpoints, &schemas, js_path)?;
|
||||
}
|
||||
|
||||
// Generate Python client (needs schemas for type definitions)
|
||||
let python_path = output_dir.join("python");
|
||||
create_dir_all(&python_path)?;
|
||||
generate_python_client(&metadata, &endpoints, &schemas, &python_path)?;
|
||||
if let Some(python_path) = &output_paths.python {
|
||||
if let Some(parent) = python_path.parent() {
|
||||
create_dir_all(parent)?;
|
||||
}
|
||||
generate_python_client(&metadata, &endpoints, &schemas, python_path)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -102,7 +142,7 @@ fn collect_leaf_type_schemas(node: &TreeNode, schemas: &mut TypeSchemas) {
|
||||
collect_schema_definitions(&leaf.schema, schemas);
|
||||
|
||||
// Get the type name for this leaf
|
||||
let type_name = extract_inner_type(leaf.value_type());
|
||||
let type_name = extract_inner_type(leaf.kind());
|
||||
|
||||
if let Entry::Vacant(e) = schemas.entry(type_name) {
|
||||
// Unwrap single-element allOf
|
||||
@@ -134,21 +174,13 @@ fn collect_leaf_type_schemas(node: &TreeNode, schemas: &mut TypeSchemas) {
|
||||
/// Collect type definitions from schemars-generated schema's definitions section.
|
||||
/// Schemars uses `definitions` or `$defs` to store referenced types.
|
||||
fn collect_schema_definitions(schema: &Value, schemas: &mut TypeSchemas) {
|
||||
// Check for definitions (JSON Schema draft-07 style)
|
||||
if let Some(defs) = schema.get("definitions").and_then(|d| d.as_object()) {
|
||||
for (name, def_schema) in defs {
|
||||
// Use the definition name as-is (schemars names match $ref paths)
|
||||
if !schemas.contains_key(name) {
|
||||
schemas.insert(name.clone(), def_schema.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for $defs (JSON Schema draft 2019-09+ style)
|
||||
if let Some(defs) = schema.get("$defs").and_then(|d| d.as_object()) {
|
||||
for (name, def_schema) in defs {
|
||||
if !schemas.contains_key(name) {
|
||||
schemas.insert(name.clone(), def_schema.clone());
|
||||
// Check both JSON Schema draft-07 style ("definitions") and draft 2019-09+ style ("$defs")
|
||||
for key in ["definitions", "$defs"] {
|
||||
if let Some(defs) = schema.get(key).and_then(|d| d.as_object()) {
|
||||
for (name, def_schema) in defs {
|
||||
if !schemas.contains_key(name) {
|
||||
schemas.insert(name.clone(), def_schema.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use crate::openapi::{Parameter, ResponseKind};
|
||||
|
||||
/// Request body shape for POST/PUT/PATCH endpoints.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RequestBody {
|
||||
/// Body content type as a name (e.g. "string" for text/plain, "Foo" for an `application/json` $ref).
|
||||
pub body_type: String,
|
||||
/// Whether the body is required.
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
/// Endpoint information extracted from OpenAPI spec.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Endpoint {
|
||||
/// HTTP method (GET, POST, etc.)
|
||||
pub method: String,
|
||||
/// Path template (e.g., "/blocks/{hash}")
|
||||
pub path: String,
|
||||
/// Operation ID (e.g., "getBlockByHash")
|
||||
pub operation_id: Option<String>,
|
||||
/// Short summary
|
||||
pub summary: Option<String>,
|
||||
/// Detailed description
|
||||
pub description: Option<String>,
|
||||
/// Path parameters
|
||||
pub path_params: Vec<Parameter>,
|
||||
/// Query parameters
|
||||
pub query_params: Vec<Parameter>,
|
||||
/// Request body, if any (POST/PUT/PATCH).
|
||||
pub request_body: Option<RequestBody>,
|
||||
/// Body kind for the 200 response.
|
||||
pub response_kind: ResponseKind,
|
||||
/// Whether this endpoint is deprecated
|
||||
pub deprecated: bool,
|
||||
/// Whether this endpoint supports CSV format (text/csv content type)
|
||||
pub supports_csv: bool,
|
||||
}
|
||||
|
||||
impl Endpoint {
|
||||
/// Returns true if this endpoint should be included in client generation.
|
||||
/// Non-deprecated GET and POST endpoints are included.
|
||||
pub fn should_generate(&self) -> bool {
|
||||
!self.deprecated && (self.method == "GET" || self.method == "POST")
|
||||
}
|
||||
|
||||
/// Returns true if this endpoint returns JSON.
|
||||
pub fn returns_json(&self) -> bool {
|
||||
matches!(self.response_kind, ResponseKind::Json(_))
|
||||
}
|
||||
|
||||
/// Returns true if this endpoint returns binary data (application/octet-stream).
|
||||
pub fn returns_binary(&self) -> bool {
|
||||
matches!(self.response_kind, ResponseKind::Binary)
|
||||
}
|
||||
|
||||
/// Returns true if this endpoint returns plain text (typed or opaque).
|
||||
pub fn returns_text(&self) -> bool {
|
||||
matches!(self.response_kind, ResponseKind::Text(_))
|
||||
}
|
||||
|
||||
/// Schema name attached to the response, if any.
|
||||
pub fn schema_name(&self) -> Option<&str> {
|
||||
self.response_kind.schema_name()
|
||||
}
|
||||
|
||||
/// Returns the operation ID or generates one from the path.
|
||||
/// The returned string uses the raw case from the spec (typically camelCase).
|
||||
pub fn operation_name(&self) -> String {
|
||||
if let Some(op_id) = &self.operation_id {
|
||||
return op_id.clone();
|
||||
}
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let mut prev_segment = "";
|
||||
|
||||
for segment in self.path.split('/').filter(|s| !s.is_empty()) {
|
||||
if segment == "api" {
|
||||
continue;
|
||||
}
|
||||
if let Some(param) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
|
||||
let prev_normalized = prev_segment.replace('-', "_");
|
||||
if !prev_normalized.ends_with(param) {
|
||||
parts.push(format!("by_{}", param));
|
||||
}
|
||||
} else {
|
||||
let normalized = segment.replace('-', "_");
|
||||
parts.push(normalized);
|
||||
prev_segment = segment;
|
||||
}
|
||||
}
|
||||
format!("get_{}", parts.join("_"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
mod endpoint;
|
||||
mod parameter;
|
||||
mod response_kind;
|
||||
mod text_schema;
|
||||
|
||||
pub use endpoint::{Endpoint, RequestBody};
|
||||
pub use parameter::Parameter;
|
||||
pub use response_kind::ResponseKind;
|
||||
pub use text_schema::TextSchema;
|
||||
|
||||
use std::{collections::BTreeMap, io};
|
||||
|
||||
use crate::ref_to_type_name;
|
||||
use oas3::Spec;
|
||||
use oas3::spec::{
|
||||
ObjectOrReference, ObjectSchema, Operation, ParameterIn, PathItem, Schema, SchemaType,
|
||||
SchemaTypeSet,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Type schema extracted from OpenAPI components
|
||||
pub type TypeSchemas = BTreeMap<String, Value>;
|
||||
|
||||
/// Parse OpenAPI spec from JSON string
|
||||
///
|
||||
/// Pre-processes the JSON to handle oas3 limitations:
|
||||
/// - Removes unsupported siblings from `$ref` objects (oas3 only supports `summary` and `description`)
|
||||
pub fn parse_openapi_json(json: &str) -> io::Result<Spec> {
|
||||
let mut value: Value =
|
||||
serde_json::from_str(json).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
// Clean up for oas3 compatibility
|
||||
clean_for_oas3(&mut value);
|
||||
|
||||
let cleaned_json =
|
||||
serde_json::to_string(&value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
oas3::from_json(&cleaned_json).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
|
||||
/// Extract type schemas from OpenAPI JSON
|
||||
pub fn extract_schemas(json: &str) -> TypeSchemas {
|
||||
let Ok(value) = serde_json::from_str::<Value>(json) else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
|
||||
value
|
||||
.get("components")
|
||||
.and_then(|c| c.get("schemas"))
|
||||
.and_then(|s| s.as_object())
|
||||
.map(|schemas| {
|
||||
schemas
|
||||
.iter()
|
||||
.map(|(name, schema)| (name.clone(), schema.clone()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Clean up OpenAPI spec for oas3 compatibility.
|
||||
/// - Removes unsupported siblings from $ref objects (oas3 only supports summary and description)
|
||||
/// - Converts boolean schemas to object schemas (oas3 doesn't handle `"schema": true`)
|
||||
fn clean_for_oas3(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
// Handle $ref with unsupported siblings
|
||||
if map.contains_key("$ref") {
|
||||
map.retain(|k, _| k == "$ref" || k == "summary" || k == "description");
|
||||
} else {
|
||||
// Convert boolean schemas to empty object schemas
|
||||
if let Some(schema) = map.get_mut("schema")
|
||||
&& schema.is_boolean()
|
||||
{
|
||||
*schema = Value::Object(serde_json::Map::new());
|
||||
}
|
||||
for v in map.values_mut() {
|
||||
clean_for_oas3(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
for v in arr {
|
||||
clean_for_oas3(v);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract all endpoints from OpenAPI spec
|
||||
pub fn extract_endpoints(spec: &Spec) -> Vec<Endpoint> {
|
||||
let mut endpoints = Vec::new();
|
||||
|
||||
let Some(paths) = &spec.paths else {
|
||||
return endpoints;
|
||||
};
|
||||
|
||||
for (path, path_item) in paths {
|
||||
for (method, operation) in get_operations(path_item) {
|
||||
if let Some(endpoint) = extract_endpoint(path, method, operation, spec) {
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
endpoints
|
||||
}
|
||||
|
||||
fn get_operations(path_item: &PathItem) -> Vec<(&'static str, &Operation)> {
|
||||
[
|
||||
("GET", &path_item.get),
|
||||
("POST", &path_item.post),
|
||||
("PUT", &path_item.put),
|
||||
("DELETE", &path_item.delete),
|
||||
("PATCH", &path_item.patch),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(method, op)| op.as_ref().map(|o| (method, o)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extract_endpoint(
|
||||
path: &str,
|
||||
method: &str,
|
||||
operation: &Operation,
|
||||
spec: &Spec,
|
||||
) -> Option<Endpoint> {
|
||||
let path_params = extract_path_parameters(path, operation);
|
||||
let query_params = extract_parameters(operation, ParameterIn::Query);
|
||||
|
||||
let response_kind = extract_response_kind(operation, spec);
|
||||
let request_body = extract_request_body(operation);
|
||||
let supports_csv = check_csv_support(operation);
|
||||
|
||||
Some(Endpoint {
|
||||
method: method.to_string(),
|
||||
path: path.to_string(),
|
||||
operation_id: operation.operation_id.clone(),
|
||||
summary: operation.summary.clone(),
|
||||
description: operation.description.clone(),
|
||||
path_params,
|
||||
query_params,
|
||||
request_body,
|
||||
response_kind,
|
||||
deprecated: operation.deprecated.unwrap_or(false),
|
||||
supports_csv,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract the request body shape, if any.
|
||||
/// Prefers `text/plain` (string) over `application/json` (typed).
|
||||
fn extract_request_body(operation: &Operation) -> Option<RequestBody> {
|
||||
let req = operation.request_body.as_ref()?;
|
||||
let req = match req {
|
||||
ObjectOrReference::Object(rb) => rb,
|
||||
ObjectOrReference::Ref { .. } => return None,
|
||||
};
|
||||
|
||||
let body_type = if req.content.contains_key("text/plain; charset=utf-8")
|
||||
|| req.content.contains_key("text/plain")
|
||||
{
|
||||
"string".to_string()
|
||||
} else if let Some(content) = req.content.get("application/json") {
|
||||
schema_name_from_content(content).unwrap_or_else(|| "Object".to_string())
|
||||
} else {
|
||||
"string".to_string()
|
||||
};
|
||||
|
||||
Some(RequestBody {
|
||||
body_type,
|
||||
required: req.required.unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if the endpoint supports CSV format (has text/csv in 200 response content types).
|
||||
fn check_csv_support(operation: &Operation) -> bool {
|
||||
let Some(responses) = operation.responses.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let Some(response) = responses.get("200") else {
|
||||
return false;
|
||||
};
|
||||
match response {
|
||||
ObjectOrReference::Object(response) => response.content.contains_key("text/csv"),
|
||||
ObjectOrReference::Ref { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract path parameters in the order they appear in the path URL.
|
||||
fn extract_path_parameters(path: &str, operation: &Operation) -> Vec<Parameter> {
|
||||
// Extract parameter names from the path in order (e.g., "/api/series/{series}/{index}" -> ["series", "index"])
|
||||
let path_order: Vec<&str> = path
|
||||
.split('/')
|
||||
.filter_map(|segment| segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')))
|
||||
.collect();
|
||||
|
||||
// Get all path parameters from the operation
|
||||
let params = extract_parameters(operation, ParameterIn::Path);
|
||||
|
||||
// Sort by position in the path
|
||||
let mut sorted_params: Vec<Parameter> = params;
|
||||
sorted_params.sort_by_key(|p| {
|
||||
path_order
|
||||
.iter()
|
||||
.position(|&name| name == p.name)
|
||||
.unwrap_or(usize::MAX)
|
||||
});
|
||||
|
||||
sorted_params
|
||||
}
|
||||
|
||||
fn extract_parameters(operation: &Operation, location: ParameterIn) -> Vec<Parameter> {
|
||||
operation
|
||||
.parameters
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
ObjectOrReference::Object(param) if param.location == location => {
|
||||
let param_type = param
|
||||
.schema
|
||||
.as_ref()
|
||||
.and_then(schema_type_from_schema)
|
||||
.unwrap_or_else(|| "string".to_string());
|
||||
Some(Parameter {
|
||||
name: param.name.clone(),
|
||||
required: param.required.unwrap_or(false),
|
||||
param_type,
|
||||
description: param.description.clone(),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extract_response_kind(operation: &Operation, spec: &Spec) -> ResponseKind {
|
||||
let response = operation
|
||||
.responses
|
||||
.as_ref()
|
||||
.and_then(|r| r.get("200"))
|
||||
.and_then(|r| match r {
|
||||
ObjectOrReference::Object(o) => Some(o),
|
||||
ObjectOrReference::Ref { .. } => None,
|
||||
});
|
||||
let Some(response) = response else {
|
||||
return ResponseKind::Text(None);
|
||||
};
|
||||
|
||||
if response.content.contains_key("application/octet-stream") {
|
||||
return ResponseKind::Binary;
|
||||
}
|
||||
if let Some(content) = response.content.get("application/json") {
|
||||
return ResponseKind::Json(
|
||||
schema_name_from_content(content).unwrap_or_else(|| "*".to_string()),
|
||||
);
|
||||
}
|
||||
if let Some(content) = response.content.get("text/plain; charset=utf-8") {
|
||||
let schema = schema_name_from_content(content).map(|name| {
|
||||
let is_numeric = is_numeric_schema(spec, &name);
|
||||
TextSchema { name, is_numeric }
|
||||
});
|
||||
return ResponseKind::Text(schema);
|
||||
}
|
||||
ResponseKind::Text(None)
|
||||
}
|
||||
|
||||
fn schema_name_from_content(content: &oas3::spec::MediaType) -> Option<String> {
|
||||
schema_type_from_schema(content.schema.as_ref()?)
|
||||
}
|
||||
|
||||
/// Resolves `name` against `components.schemas` and reports whether the
|
||||
/// underlying primitive is `integer` or `number`.
|
||||
fn is_numeric_schema(spec: &Spec, name: &str) -> bool {
|
||||
let Some(components) = spec.components.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let Some(Schema::Object(obj_or_ref)) = components.schemas.get(name) else {
|
||||
return false;
|
||||
};
|
||||
let ObjectOrReference::Object(schema) = obj_or_ref.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
matches!(
|
||||
schema.schema_type.as_ref(),
|
||||
Some(SchemaTypeSet::Single(
|
||||
SchemaType::Integer | SchemaType::Number
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
fn schema_type_from_schema(schema: &Schema) -> Option<String> {
|
||||
match schema {
|
||||
Schema::Boolean(_) => Some("boolean".to_string()),
|
||||
Schema::Object(obj_or_ref) => match obj_or_ref.as_ref() {
|
||||
ObjectOrReference::Object(obj_schema) => schema_to_type_name(obj_schema),
|
||||
ObjectOrReference::Ref { ref_path, .. } => {
|
||||
// Return the type name as-is (e.g., "Height", "Address")
|
||||
// These should have definitions generated from schemas
|
||||
ref_to_type_name(ref_path).map(|s| s.to_string())
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn schema_to_type_name(schema: &ObjectSchema) -> Option<String> {
|
||||
if let Some(schema_type) = schema.schema_type.as_ref() {
|
||||
return match schema_type {
|
||||
SchemaTypeSet::Single(t) => single_type_to_name(t, schema),
|
||||
SchemaTypeSet::Multiple(types) => {
|
||||
// For nullable types like ["integer", "null"], return the non-null type
|
||||
types
|
||||
.iter()
|
||||
.find(|t| !matches!(t, SchemaType::Null))
|
||||
.and_then(|t| single_type_to_name(t, schema))
|
||||
.or(Some("*".to_string()))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Handle anyOf/oneOf unions (e.g., Option<RangeIndex> → anyOf: [$ref, null])
|
||||
let variants = if !schema.any_of.is_empty() {
|
||||
&schema.any_of
|
||||
} else if !schema.one_of.is_empty() {
|
||||
&schema.one_of
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let types: Vec<String> = variants
|
||||
.iter()
|
||||
.filter_map(|v| match v {
|
||||
Schema::Boolean(_) => None,
|
||||
Schema::Object(obj_or_ref) => match obj_or_ref.as_ref() {
|
||||
ObjectOrReference::Ref { ref_path, .. } => {
|
||||
ref_to_type_name(ref_path).map(|s| s.to_string())
|
||||
}
|
||||
ObjectOrReference::Object(obj) => {
|
||||
if matches!(
|
||||
obj.schema_type.as_ref(),
|
||||
Some(SchemaTypeSet::Single(SchemaType::Null))
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
schema_to_type_name(obj)
|
||||
}
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
match types.len() {
|
||||
0 => None,
|
||||
1 => Some(types.into_iter().next().unwrap()),
|
||||
_ => Some(types.join(" | ")),
|
||||
}
|
||||
}
|
||||
|
||||
fn single_type_to_name(t: &SchemaType, schema: &ObjectSchema) -> Option<String> {
|
||||
match t {
|
||||
SchemaType::String => Some("string".to_string()),
|
||||
SchemaType::Number => Some("number".to_string()),
|
||||
SchemaType::Integer => Some("integer".to_string()),
|
||||
SchemaType::Boolean => Some("boolean".to_string()),
|
||||
SchemaType::Array => {
|
||||
let inner = match &schema.items {
|
||||
Some(boxed_schema) => schema_type_from_schema(boxed_schema),
|
||||
None => Some("*".to_string()),
|
||||
};
|
||||
inner.map(|t| format!("{}[]", t))
|
||||
}
|
||||
SchemaType::Object => Some("Object".to_string()),
|
||||
SchemaType::Null => Some("null".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/// Parameter information.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Parameter {
|
||||
pub name: String,
|
||||
pub required: bool,
|
||||
pub param_type: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use crate::openapi::TextSchema;
|
||||
|
||||
/// 200-response body shape.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ResponseKind {
|
||||
/// JSON body, schema named (e.g. "Block").
|
||||
Json(String),
|
||||
/// `text/plain` body. `Some(schema)` carries a typed shape (e.g. "Height", "Hex");
|
||||
/// `None` is the escape hatch for opaque text.
|
||||
Text(Option<TextSchema>),
|
||||
/// `application/octet-stream`.
|
||||
Binary,
|
||||
}
|
||||
|
||||
impl ResponseKind {
|
||||
/// Schema name, if the body is named (Json or typed Text).
|
||||
pub fn schema_name(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Json(s) => Some(s.as_str()),
|
||||
Self::Text(Some(t)) => Some(t.name.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when a typed text body needs numeric parsing (`int(...)` etc.).
|
||||
pub fn text_is_numeric(&self) -> bool {
|
||||
matches!(self, Self::Text(Some(t)) if t.is_numeric)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/// Schema metadata for a typed `text/plain` response.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextSchema {
|
||||
/// Schema name, e.g. "Height", "Hex".
|
||||
pub name: String,
|
||||
/// True when the underlying primitive is `integer`/`number` (body needs numeric parsing).
|
||||
pub is_numeric: bool,
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Language-specific syntax traits for code generation.
|
||||
//!
|
||||
//! This module defines the `LanguageSyntax` trait that abstracts over
|
||||
//! language-specific code generation patterns, allowing shared generation
|
||||
//! logic to work across Python, JavaScript, and Rust backends.
|
||||
|
||||
use crate::GenericSyntax;
|
||||
|
||||
/// Language-specific syntax for code generation.
|
||||
///
|
||||
/// Implementations of this trait provide the language-specific formatting
|
||||
/// for generated client code. This allows the core generation logic to be
|
||||
/// written once and reused across all supported languages.
|
||||
pub trait LanguageSyntax {
|
||||
/// Convert a field name to the language's naming convention.
|
||||
///
|
||||
/// - Python/Rust: `snake_case`
|
||||
/// - JavaScript: `camelCase`
|
||||
fn field_name(&self, name: &str) -> String;
|
||||
|
||||
/// Format an interpolated path expression.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `base_var` - The variable name to interpolate (e.g., "acc", "base_path")
|
||||
/// * `suffix` - The suffix to append (e.g., "_field_name")
|
||||
///
|
||||
/// # Returns
|
||||
/// - Python: `f'{acc}_suffix'`
|
||||
/// - JavaScript: `` `${acc}_suffix` ``
|
||||
/// - Rust: `format!("{acc}_suffix")`
|
||||
fn path_expr(&self, base_var: &str, suffix: &str) -> String;
|
||||
|
||||
/// Format a suffix mode expression: `_m(acc, relative)`.
|
||||
///
|
||||
/// Suffix mode appends the relative name to the accumulator.
|
||||
/// - If relative is empty, returns just acc (identity)
|
||||
/// - Otherwise: `{acc}_{relative}` or `{relative}` if acc is empty
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `acc_var` - The accumulator variable name (e.g., "acc")
|
||||
/// * `relative` - The relative name to append (e.g., "max_cost_basis")
|
||||
fn suffix_expr(&self, acc_var: &str, relative: &str) -> String;
|
||||
|
||||
/// Format a prefix mode expression: `_p(prefix, acc)`.
|
||||
///
|
||||
/// Prefix mode prepends the prefix to the accumulator.
|
||||
/// - If prefix is empty, returns just acc (identity)
|
||||
/// - Otherwise: `{prefix}{acc}` (prefix includes trailing underscore)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `prefix` - The prefix to prepend (e.g., "cumulative_")
|
||||
/// * `acc_var` - The accumulator variable name (e.g., "acc")
|
||||
fn prefix_expr(&self, prefix: &str, acc_var: &str) -> String;
|
||||
|
||||
/// Generate a constructor call for patterns and accessors.
|
||||
///
|
||||
/// - Python: `TypeName(client, path)`
|
||||
/// - JavaScript: `createTypeName(client, path)`
|
||||
/// - Rust: `TypeName::new(client.clone(), path)`
|
||||
fn constructor(&self, type_name: &str, path_expr: &str) -> String;
|
||||
|
||||
/// Generate a field initialization line.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `indent` - The indentation string
|
||||
/// * `name` - The field name (already converted to language convention)
|
||||
/// * `type_ann` - The type annotation (may be ignored by some languages)
|
||||
/// * `value` - The initialization value/expression
|
||||
///
|
||||
/// # Returns
|
||||
/// - Python: `{indent}self.{name}: {type_ann} = {value}`
|
||||
/// - JavaScript: `{indent}{name}: {value},`
|
||||
/// - Rust: `{indent}{name}: {value},`
|
||||
fn field_init(&self, indent: &str, name: &str, type_ann: &str, value: &str) -> String;
|
||||
|
||||
/// Get the generic type syntax for this language.
|
||||
///
|
||||
/// - Python: `[T]` with default `Any`
|
||||
/// - JavaScript: `<T>` with default `unknown`
|
||||
/// - Rust: `<T>` with default `_`
|
||||
fn generic_syntax(&self) -> GenericSyntax;
|
||||
|
||||
/// Format a string literal.
|
||||
///
|
||||
/// - Python/JavaScript: `'value'` (single quotes)
|
||||
/// - Rust: `"value"` (double quotes)
|
||||
fn string_literal(&self, value: &str) -> String;
|
||||
|
||||
/// Get the constructor name/prefix for a type.
|
||||
///
|
||||
/// - Python: `TypeName`
|
||||
/// - JavaScript: `createTypeName`
|
||||
/// - Rust: `TypeName::new`
|
||||
fn constructor_name(&self, type_name: &str) -> String;
|
||||
|
||||
/// Return a variable as an owned value expression.
|
||||
///
|
||||
/// - Rust: `var.clone()` (String needs explicit cloning)
|
||||
/// - JavaScript/Python: `var` (no ownership)
|
||||
fn owned_expr(&self, var: &str) -> String {
|
||||
var.to_string()
|
||||
}
|
||||
|
||||
/// Format a discriminator argument for passing to a templated child.
|
||||
///
|
||||
/// Returns an expression computing the disc value from a template.
|
||||
/// - `"pct99"` (static) → `'pct99'` (JS) / `"pct99".to_string()` (Rust)
|
||||
/// - `""` (empty) → `disc` (pass parent's disc through)
|
||||
/// - `"p1sd{disc}"` (suffix) → `_m('p1sd', disc)` (composed)
|
||||
/// - `"ratio_{disc}_bps"` (embedded) → `` `ratio_${disc}_bps` `` (template literal)
|
||||
fn disc_arg_expr(&self, template: &str) -> String;
|
||||
|
||||
/// Format a templated mode expression: substitute `{disc}` at runtime.
|
||||
///
|
||||
/// The template contains `{disc}` placeholder. The generated code should
|
||||
/// construct `_m(acc, template_with_disc_substituted)` at runtime.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `acc_var` - The accumulator variable (e.g., "acc")
|
||||
/// * `template` - Template like `"ratio_{disc}_bps"` or `"{disc}"`
|
||||
fn template_expr(&self, acc_var: &str, template: &str) -> String;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use brk_types::Index;
|
||||
|
||||
/// Convert a string to PascalCase (e.g., "fee_rate" -> "FeeRate").
|
||||
pub fn to_pascal_case(s: &str) -> String {
|
||||
s.replace('-', "_")
|
||||
.split('_')
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Convert a string to snake_case (no keyword escaping — backends handle that).
|
||||
pub fn to_snake_case(s: &str) -> String {
|
||||
let sanitized = s.to_lowercase().replace('-', "_");
|
||||
|
||||
if sanitized.chars().next().is_some_and(|c| c.is_ascii_digit()) {
|
||||
format!("_{}", sanitized)
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape Rust reserved keywords with `_` suffix (consistent with Python).
|
||||
pub fn escape_rust_keyword(name: &str) -> String {
|
||||
match name {
|
||||
"type" | "const" | "static" | "match" | "if" | "else" | "loop" | "while" | "for"
|
||||
| "break" | "continue" | "return" | "fn" | "let" | "mut" | "ref" | "self" | "super"
|
||||
| "mod" | "use" | "pub" | "crate" | "extern" | "impl" | "trait" | "struct" | "enum"
|
||||
| "where" | "async" | "await" | "dyn" | "move" => format!("{}_", name),
|
||||
_ => name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a string to camelCase (e.g., "fee_rate" -> "feeRate").
|
||||
pub fn to_camel_case(s: &str) -> String {
|
||||
let pascal = to_pascal_case(s);
|
||||
let mut chars = pascal.chars();
|
||||
|
||||
let result = match chars.next() {
|
||||
None => String::new(),
|
||||
Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
|
||||
};
|
||||
|
||||
// Prefix with _ if starts with digit
|
||||
if result.chars().next().is_some_and(|c| c.is_ascii_digit()) {
|
||||
format!("_{}", result)
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an Index to a snake_case field name (e.g., Day1 -> day1).
|
||||
pub fn index_to_field_name(index: &Index) -> String {
|
||||
to_snake_case(index.name())
|
||||
}
|
||||
|
||||
/// Generate a child type/struct/class name (e.g., ParentName + child_name -> ParentName_ChildName).
|
||||
pub fn child_type_name(parent: &str, child: &str) -> String {
|
||||
format!("{}_{}", parent, to_pascal_case(child))
|
||||
}
|
||||
|
||||
/// Escape Python reserved keywords by appending an underscore.
|
||||
/// Also prefixes names starting with digits with an underscore.
|
||||
pub fn escape_python_keyword(name: &str) -> String {
|
||||
const PYTHON_KEYWORDS: &[&str] = &[
|
||||
"False", "None", "True", "and", "as", "assert", "async", "await", "break", "class",
|
||||
"continue", "def", "del", "elif", "else", "except", "finally", "for", "from", "global",
|
||||
"if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return",
|
||||
"try", "while", "with", "yield",
|
||||
];
|
||||
|
||||
// Strip characters invalid in identifiers (e.g. `[]` from `txId[]`)
|
||||
let name = name.replace(['[', ']'], "");
|
||||
|
||||
// Prefix with underscore if starts with digit
|
||||
let name = if name.starts_with(|c: char| c.is_ascii_digit()) {
|
||||
format!("_{}", name)
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
|
||||
// Append underscore if it's a keyword
|
||||
if PYTHON_KEYWORDS.contains(&name.as_str()) {
|
||||
format!("{}_", name)
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Client metadata extracted from brk_query.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use brk_query::Vecs;
|
||||
use brk_types::{Index, SeriesLeafWithSchema};
|
||||
|
||||
use super::{GenericSyntax, IndexSetPattern, PatternField, StructuralPattern, extract_inner_type};
|
||||
use crate::{PatternBaseResult, analysis};
|
||||
|
||||
/// Metadata extracted from brk_query for client generation.
|
||||
#[derive(Debug)]
|
||||
pub struct ClientMetadata {
|
||||
/// The catalog tree structure (with schemas in leaves)
|
||||
pub catalog: brk_types::TreeNode,
|
||||
/// Structural patterns - tree node shapes that repeat
|
||||
pub structural_patterns: Vec<StructuralPattern>,
|
||||
/// Index set patterns - sets of indexes that appear together on series
|
||||
pub index_set_patterns: Vec<IndexSetPattern>,
|
||||
/// Maps field signatures to pattern names (merged from concrete instances + pattern definitions)
|
||||
pattern_lookup: BTreeMap<Vec<PatternField>, String>,
|
||||
/// Maps concrete field signatures to their type parameter (for generic patterns)
|
||||
concrete_to_type_param: BTreeMap<Vec<PatternField>, String>,
|
||||
/// Maps tree paths to their computed PatternBaseResult
|
||||
node_bases: BTreeMap<String, PatternBaseResult>,
|
||||
}
|
||||
|
||||
impl ClientMetadata {
|
||||
/// Extract metadata from brk_query::Vecs.
|
||||
pub fn from_vecs(vecs: &Vecs) -> Self {
|
||||
Self::from_catalog(vecs.catalog().clone())
|
||||
}
|
||||
|
||||
/// Extract metadata from a catalog TreeNode directly.
|
||||
pub fn from_catalog(catalog: brk_types::TreeNode) -> Self {
|
||||
let (structural_patterns, concrete_to_pattern, concrete_to_type_param, node_bases) =
|
||||
analysis::detect_structural_patterns(&catalog);
|
||||
let index_set_patterns = analysis::detect_index_patterns(&catalog);
|
||||
|
||||
// Build merged pattern lookup: concrete instances + pattern definitions
|
||||
let mut pattern_lookup = concrete_to_pattern;
|
||||
for p in &structural_patterns {
|
||||
pattern_lookup.insert(p.fields.clone(), p.name.clone());
|
||||
}
|
||||
|
||||
ClientMetadata {
|
||||
catalog,
|
||||
structural_patterns,
|
||||
index_set_patterns,
|
||||
pattern_lookup,
|
||||
concrete_to_type_param,
|
||||
node_bases,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find an index set pattern that matches the given indexes.
|
||||
pub fn find_index_set_pattern(&self, indexes: &BTreeSet<Index>) -> Option<&IndexSetPattern> {
|
||||
self.index_set_patterns
|
||||
.iter()
|
||||
.find(|p| &p.indexes == indexes)
|
||||
}
|
||||
|
||||
/// Find a pattern by name.
|
||||
pub fn find_pattern(&self, name: &str) -> Option<&StructuralPattern> {
|
||||
self.structural_patterns.iter().find(|p| p.name == name)
|
||||
}
|
||||
|
||||
/// Check if a pattern is fully parameterizable (recursively).
|
||||
/// Returns false if the pattern or any nested branch pattern has no mode.
|
||||
pub fn is_parameterizable(&self, name: &str) -> bool {
|
||||
self.find_pattern(name).is_some_and(|p| {
|
||||
p.is_parameterizable()
|
||||
&& p.fields.iter().all(|f| {
|
||||
!f.is_branch()
|
||||
|| self.find_pattern(&f.rust_type).is_none()
|
||||
|| self.is_parameterizable(&f.rust_type)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Find a pattern by its concrete fields.
|
||||
pub fn find_pattern_by_fields(&self, fields: &[PatternField]) -> Option<&StructuralPattern> {
|
||||
self.pattern_lookup
|
||||
.get(fields)
|
||||
.and_then(|name| self.find_pattern(name))
|
||||
}
|
||||
|
||||
/// Get the type parameter for a generic pattern given its concrete fields.
|
||||
pub fn get_type_param(&self, fields: &[PatternField]) -> Option<&String> {
|
||||
self.concrete_to_type_param.get(fields)
|
||||
}
|
||||
|
||||
/// Get the pre-computed pattern lookup map.
|
||||
pub fn pattern_lookup(&self) -> &BTreeMap<Vec<PatternField>, String> {
|
||||
&self.pattern_lookup
|
||||
}
|
||||
|
||||
/// Get the pre-computed PatternBaseResult for a tree path.
|
||||
pub fn get_node_base(&self, path: &str) -> Option<&PatternBaseResult> {
|
||||
self.node_bases.get(path)
|
||||
}
|
||||
|
||||
/// Generate type annotation for a field with language-specific syntax.
|
||||
pub fn field_type_annotation(
|
||||
&self,
|
||||
field: &PatternField,
|
||||
is_generic: bool,
|
||||
generic_value_type: Option<&str>,
|
||||
syntax: GenericSyntax,
|
||||
) -> String {
|
||||
// Pattern type — single lookup instead of is_pattern_type + is_pattern_generic
|
||||
if let Some(pattern) = self.find_pattern(&field.rust_type) {
|
||||
if pattern.is_generic {
|
||||
let type_param = field
|
||||
.type_param
|
||||
.as_deref()
|
||||
.or(generic_value_type)
|
||||
.unwrap_or(if is_generic { "T" } else { syntax.default_type });
|
||||
return syntax.wrap(&field.rust_type, type_param);
|
||||
}
|
||||
return field.rust_type.clone();
|
||||
}
|
||||
|
||||
// Branch type (non-pattern)
|
||||
if field.is_branch() {
|
||||
return field.rust_type.clone();
|
||||
}
|
||||
|
||||
// Leaf type
|
||||
let value_type = if is_generic && field.rust_type == "T" {
|
||||
"T".to_string()
|
||||
} else {
|
||||
extract_inner_type(&field.rust_type)
|
||||
};
|
||||
if let Some(accessor) = self.find_index_set_pattern(&field.indexes) {
|
||||
syntax.wrap(&accessor.name, &value_type)
|
||||
} else {
|
||||
syntax.wrap("SeriesNode", &value_type)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate type annotation for a leaf node with language-specific syntax.
|
||||
///
|
||||
/// This is a simpler version of `field_type_annotation` that works directly
|
||||
/// with a `SeriesLeafWithSchema` node instead of a `PatternField`.
|
||||
pub fn field_type_annotation_from_leaf(
|
||||
&self,
|
||||
leaf: &SeriesLeafWithSchema,
|
||||
syntax: GenericSyntax,
|
||||
) -> String {
|
||||
let value_type = leaf.kind().to_string();
|
||||
if let Some(accessor) = self.find_index_set_pattern(leaf.indexes()) {
|
||||
syntax.wrap(&accessor.name, &value_type)
|
||||
} else {
|
||||
syntax.wrap("SeriesNode", &value_type)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Core types for client generation.
|
||||
|
||||
mod case;
|
||||
mod metadata;
|
||||
mod positions;
|
||||
mod schema;
|
||||
mod structs;
|
||||
|
||||
pub use case::*;
|
||||
pub use metadata::*;
|
||||
pub use positions::*;
|
||||
pub use schema::*;
|
||||
pub use structs::*;
|
||||
|
||||
/// Language-specific syntax for generic type annotations.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct GenericSyntax {
|
||||
pub open: char,
|
||||
pub close: char,
|
||||
pub default_type: &'static str,
|
||||
}
|
||||
|
||||
impl GenericSyntax {
|
||||
pub const PYTHON: Self = Self {
|
||||
open: '[',
|
||||
close: ']',
|
||||
default_type: "Any",
|
||||
};
|
||||
pub const JAVASCRIPT: Self = Self {
|
||||
open: '<',
|
||||
close: '>',
|
||||
default_type: "unknown",
|
||||
};
|
||||
pub const RUST: Self = Self {
|
||||
open: '<',
|
||||
close: '>',
|
||||
default_type: "_",
|
||||
};
|
||||
|
||||
pub fn wrap(&self, name: &str, type_param: &str) -> String {
|
||||
// Convert the type_param from Rust syntax to target syntax
|
||||
let converted = self.convert(type_param);
|
||||
format!("{}{}{}{}", name, self.open, converted, self.close)
|
||||
}
|
||||
|
||||
/// Convert a type string from Rust generic syntax to target language syntax.
|
||||
///
|
||||
/// For Python, wrapper newtypes like `Close<Cents>` are flattened to just `Cents`
|
||||
/// because Python type aliases can't be parameterized. This matches JS behavior.
|
||||
pub fn convert(&self, type_str: &str) -> String {
|
||||
// Flatten nested generics to innermost type (e.g., Close<Cents> -> Cents)
|
||||
// This is needed because wrapper types like Close, Open, High, Low are
|
||||
// just type aliases in generated code, not actual generic classes.
|
||||
extract_inner_type_recursive(type_str)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the innermost type from nested generics.
|
||||
/// E.g., `Close<Cents>` -> `Cents`, `Foo<Bar<Baz>>` -> `Baz`
|
||||
fn extract_inner_type_recursive(type_str: &str) -> String {
|
||||
if let Some(start) = type_str.find('<')
|
||||
&& let Some(end) = type_str.rfind('>')
|
||||
{
|
||||
let inner = &type_str[start + 1..end];
|
||||
return extract_inner_type_recursive(inner);
|
||||
}
|
||||
type_str.to_string()
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Pattern mode and field parts for series name reconstruction.
|
||||
//!
|
||||
//! Patterns are either suffix mode or prefix mode:
|
||||
//! - Suffix mode: `_m(acc, relative)` → `acc_relative` or just `relative` if acc empty
|
||||
//! - Prefix mode: `_p(prefix, acc)` → `prefix_acc` or just `acc` if prefix empty
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// How a pattern constructs series names from the accumulator.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PatternMode {
|
||||
/// Fields append their relative name to acc.
|
||||
/// Formula: `_m(acc, relative)` → `{acc}_{relative}` or `{relative}` if acc empty
|
||||
/// Example: `_m("lth", "max_cost_basis")` → `"lth_max_cost_basis"`
|
||||
Suffix {
|
||||
/// Maps field name to its relative name (full series name when acc = "")
|
||||
relatives: BTreeMap<String, String>,
|
||||
},
|
||||
/// Fields prepend their prefix to acc.
|
||||
/// Formula: `_p(prefix, acc)` → `{prefix}_{acc}` or `{acc}` if prefix empty
|
||||
/// Example: `_p("cumulative", "lth_realized_loss")` → `"cumulative_lth_realized_loss"`
|
||||
Prefix {
|
||||
/// Maps field name to its prefix (empty string for identity)
|
||||
prefixes: BTreeMap<String, String>,
|
||||
},
|
||||
/// Fields construct series names using a template with a discriminator placeholder.
|
||||
/// Factory takes two params: `acc` (base) and `disc` (discriminator).
|
||||
/// Formula: `_m(acc, template.replace("{disc}", disc))`
|
||||
/// Example: template `"ratio_{disc}_bps"` with disc `"pct99"` → `_m(acc, "ratio_pct99_bps")`
|
||||
Templated {
|
||||
/// Maps field name to its template string containing `{disc}` placeholder
|
||||
templates: BTreeMap<String, String>,
|
||||
},
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
//! JSON Schema utilities.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Unwrap allOf with a single element, returning the inner schema.
|
||||
@@ -13,13 +11,6 @@ pub fn unwrap_allof(schema: &Value) -> &Value {
|
||||
schema
|
||||
}
|
||||
|
||||
/// Check if a schema represents an enum type.
|
||||
/// Enums have either an "enum" array or "oneOf" without properties.
|
||||
pub fn is_enum_schema(schema: &Value) -> bool {
|
||||
schema.get("enum").is_some()
|
||||
|| (schema.get("oneOf").is_some() && schema.get("properties").is_none())
|
||||
}
|
||||
|
||||
/// Extract inner type from a wrapper generic like `Close<Dollars>` -> `Dollars`.
|
||||
/// Also handles malformed types like `Dollars>` (from vecdb's short_type_name).
|
||||
pub fn extract_inner_type(type_str: &str) -> String {
|
||||
@@ -37,11 +28,16 @@ pub fn extract_inner_type(type_str: &str) -> String {
|
||||
type_str.to_string()
|
||||
}
|
||||
|
||||
/// Extract JSON type from a schema ("integer", "number", "string", etc).
|
||||
pub fn schema_to_json_type(schema: &Value) -> String {
|
||||
schema
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("object")
|
||||
.to_string()
|
||||
/// Extract type name from a JSON Schema $ref path.
|
||||
/// E.g., "#/definitions/MyType" -> "MyType", "#/$defs/Foo" -> "Foo"
|
||||
pub fn ref_to_type_name(ref_path: &str) -> Option<&str> {
|
||||
ref_path.rsplit('/').next()
|
||||
}
|
||||
|
||||
/// Get union variants from anyOf or oneOf schema.
|
||||
pub fn get_union_variants(schema: &Value) -> Option<&Vec<Value>> {
|
||||
schema
|
||||
.get("anyOf")
|
||||
.or_else(|| schema.get("oneOf"))
|
||||
.and_then(|v| v.as_array())
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Structural pattern and field types.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use brk_types::Index;
|
||||
|
||||
use super::PatternMode;
|
||||
|
||||
/// A pattern of indexes that appear together on multiple series.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IndexSetPattern {
|
||||
/// Pattern name (e.g., "DateHeightIndexes")
|
||||
pub name: String,
|
||||
/// The set of indexes
|
||||
pub indexes: BTreeSet<Index>,
|
||||
}
|
||||
|
||||
/// A structural pattern - a branch structure that appears multiple times.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StructuralPattern {
|
||||
/// Pattern name
|
||||
pub name: String,
|
||||
/// Ordered list of child fields
|
||||
pub fields: Vec<PatternField>,
|
||||
/// How fields construct series names from acc (None = not parameterizable)
|
||||
pub mode: Option<PatternMode>,
|
||||
/// If true, all leaf fields use a type parameter T
|
||||
pub is_generic: bool,
|
||||
}
|
||||
|
||||
impl StructuralPattern {
|
||||
/// Returns true if this pattern can be parameterized with an accumulator.
|
||||
pub fn is_parameterizable(&self) -> bool {
|
||||
self.mode.is_some()
|
||||
}
|
||||
|
||||
/// Get the field part (relative name or prefix) for a given field.
|
||||
pub fn get_field_part(&self, field_name: &str) -> Option<&str> {
|
||||
match &self.mode {
|
||||
Some(PatternMode::Suffix { relatives }) => {
|
||||
relatives.get(field_name).map(|s| s.as_str())
|
||||
}
|
||||
Some(PatternMode::Prefix { prefixes }) => prefixes.get(field_name).map(|s| s.as_str()),
|
||||
Some(PatternMode::Templated { templates }) => {
|
||||
templates.get(field_name).map(|s| s.as_str())
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this pattern is in suffix mode.
|
||||
pub fn is_suffix_mode(&self) -> bool {
|
||||
matches!(
|
||||
&self.mode,
|
||||
Some(PatternMode::Suffix { .. } | PatternMode::Templated { .. })
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns true if this pattern uses templated mode with a discriminator.
|
||||
pub fn is_templated(&self) -> bool {
|
||||
matches!(&self.mode, Some(PatternMode::Templated { .. }))
|
||||
}
|
||||
|
||||
/// Extract the discriminator value from a concrete instance's field_parts.
|
||||
/// Uses the pattern's templates to reverse-match and find the disc.
|
||||
pub fn extract_disc_from_instance(
|
||||
&self,
|
||||
instance_field_parts: &BTreeMap<String, String>,
|
||||
) -> Option<String> {
|
||||
let templates = match &self.mode {
|
||||
Some(PatternMode::Templated { templates }) => templates,
|
||||
_ => return None,
|
||||
};
|
||||
// Find a template with {disc} and extract the disc from the instance value.
|
||||
// Strip leading underscore since _m() handles separators.
|
||||
for (field_name, template) in templates {
|
||||
if let Some(value) = instance_field_parts.get(field_name)
|
||||
&& let Some(disc) = extract_disc(template, value)
|
||||
{
|
||||
return Some(disc.trim_start_matches('_').to_string());
|
||||
}
|
||||
}
|
||||
// If no template matched (all empty templates), disc is empty
|
||||
Some(String::new())
|
||||
}
|
||||
|
||||
/// Check if the given instance field parts match this pattern's field parts.
|
||||
pub fn field_parts_match(&self, instance_field_parts: &BTreeMap<String, String>) -> bool {
|
||||
match &self.mode {
|
||||
Some(PatternMode::Suffix { relatives }) => {
|
||||
relatives.iter().all(|(field_name, pattern_suffix)| {
|
||||
instance_field_parts
|
||||
.get(field_name)
|
||||
.is_some_and(|instance_suffix| instance_suffix == pattern_suffix)
|
||||
})
|
||||
}
|
||||
Some(PatternMode::Prefix { prefixes }) => {
|
||||
prefixes.iter().all(|(field_name, pattern_prefix)| {
|
||||
instance_field_parts
|
||||
.get(field_name)
|
||||
.is_some_and(|instance_prefix| instance_prefix == pattern_prefix)
|
||||
})
|
||||
}
|
||||
Some(PatternMode::Templated { templates }) => {
|
||||
// For templated patterns, check if the instance's field_parts
|
||||
// can be produced by substituting some discriminator into the templates
|
||||
let first_template_field = templates.iter().next();
|
||||
let Some((ref_field, ref_template)) = first_template_field else {
|
||||
return false;
|
||||
};
|
||||
let Some(ref_value) = instance_field_parts.get(ref_field) else {
|
||||
return false;
|
||||
};
|
||||
// Extract discriminator from the reference field
|
||||
let Some(disc) = extract_disc(ref_template, ref_value) else {
|
||||
return false;
|
||||
};
|
||||
// Verify all fields match with this discriminator
|
||||
templates.iter().all(|(field_name, template)| {
|
||||
instance_field_parts
|
||||
.get(field_name)
|
||||
.is_some_and(|value| *value == template.replace("{disc}", &disc))
|
||||
})
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the discriminator value by matching a template against a concrete string.
|
||||
/// E.g., template `"ratio_{disc}_bps"` matched against `"ratio_pct99_bps"` yields `"pct99"`.
|
||||
fn extract_disc(template: &str, value: &str) -> Option<String> {
|
||||
let (prefix, suffix) = template.split_once("{disc}")?;
|
||||
if value.starts_with(prefix) && value.ends_with(suffix) {
|
||||
let disc = &value[prefix.len()..value.len() - suffix.len()];
|
||||
if !disc.is_empty() {
|
||||
return Some(disc.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A field in a structural pattern.
|
||||
#[derive(Debug, Clone, PartialOrd, Ord)]
|
||||
pub struct PatternField {
|
||||
/// Field name
|
||||
pub name: String,
|
||||
/// Rust type for leaves or pattern name for branches
|
||||
pub rust_type: String,
|
||||
/// JSON type from schema
|
||||
pub json_type: String,
|
||||
/// For leaves: the set of supported indexes. Empty for branches.
|
||||
pub indexes: BTreeSet<Index>,
|
||||
/// For branches referencing generic patterns: the concrete type parameter
|
||||
pub type_param: Option<String>,
|
||||
}
|
||||
|
||||
impl PatternField {
|
||||
/// Returns true if this is a leaf field (has indexes).
|
||||
pub fn is_leaf(&self) -> bool {
|
||||
!self.indexes.is_empty()
|
||||
}
|
||||
|
||||
/// Returns true if this is a branch field (no indexes).
|
||||
pub fn is_branch(&self) -> bool {
|
||||
self.indexes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::hash::Hash for PatternField {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.name.hash(state);
|
||||
self.rust_type.hash(state);
|
||||
self.json_type.hash(state);
|
||||
self.indexes.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for PatternField {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.name == other.name
|
||||
&& self.rust_type == other.rust_type
|
||||
&& self.json_type == other.json_type
|
||||
&& self.indexes == other.indexes
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for PatternField {}
|
||||
@@ -0,0 +1,917 @@
|
||||
// //! Tests that verify pattern analysis using the real catalog.
|
||||
|
||||
// use std::collections::{BTreeMap, BTreeSet};
|
||||
// use std::fmt::Write;
|
||||
|
||||
// use brk_bindgen::ClientMetadata;
|
||||
// use brk_types::TreeNode;
|
||||
|
||||
// /// Load the catalog from the JSON file.
|
||||
// fn load_catalog() -> TreeNode {
|
||||
// let path = concat!(env!("CARGO_MANIFEST_DIR"), "/catalog.json");
|
||||
// let catalog_json = std::fs::read_to_string(path).expect("Failed to read catalog.json");
|
||||
// serde_json::from_str(&catalog_json).expect("Failed to parse catalog.json")
|
||||
// }
|
||||
|
||||
// /// Load OpenAPI spec from openapi.json.
|
||||
// fn load_openapi_json() -> String {
|
||||
// let path = concat!(env!("CARGO_MANIFEST_DIR"), "/openapi.json");
|
||||
// std::fs::read_to_string(path).expect("Failed to read openapi.json")
|
||||
// }
|
||||
|
||||
// /// Load metadata from the catalog.
|
||||
// #[allow(unused)]
|
||||
// fn load_metadata() -> ClientMetadata {
|
||||
// ClientMetadata::from_catalog(load_catalog())
|
||||
// }
|
||||
|
||||
// /// Collect all leaf metric names from a tree.
|
||||
// fn collect_leaf_names(node: &TreeNode, names: &mut BTreeSet<String>) {
|
||||
// match node {
|
||||
// TreeNode::Leaf(leaf) => {
|
||||
// names.insert(leaf.name().to_string());
|
||||
// }
|
||||
// TreeNode::Branch(children) => {
|
||||
// for child in children.values() {
|
||||
// collect_leaf_names(child, names);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// #[test]
|
||||
// fn test_catalog_loads() {
|
||||
// let catalog = load_catalog();
|
||||
|
||||
// // Should be a branch with top-level categories
|
||||
// let TreeNode::Branch(categories) = &catalog else {
|
||||
// panic!("Expected catalog to be a branch");
|
||||
// };
|
||||
|
||||
// // Check some expected top-level categories exist
|
||||
// assert!(
|
||||
// categories.contains_key("addresses"),
|
||||
// "Missing addresses category"
|
||||
// );
|
||||
// assert!(categories.contains_key("blocks"), "Missing blocks category");
|
||||
// assert!(categories.contains_key("market"), "Missing market category");
|
||||
// assert!(categories.contains_key("supply"), "Missing supply category");
|
||||
|
||||
// println!("Catalog has {} top-level categories", categories.len());
|
||||
// }
|
||||
|
||||
// #[test]
|
||||
// fn test_all_leaves_have_names() {
|
||||
// let catalog = load_catalog();
|
||||
// let mut names = BTreeSet::new();
|
||||
// collect_leaf_names(&catalog, &mut names);
|
||||
|
||||
// println!("Catalog has {} unique metric names", names.len());
|
||||
// assert!(!names.is_empty(), "Should have at least some metrics");
|
||||
|
||||
// // All names should be non-empty
|
||||
// for name in &names {
|
||||
// assert!(!name.is_empty(), "Found empty metric name");
|
||||
// }
|
||||
// }
|
||||
|
||||
// #[test]
|
||||
// fn test_pattern_detection() {
|
||||
// let catalog = load_catalog();
|
||||
|
||||
// let (patterns, concrete_to_pattern, concrete_to_type_param, _node_bases) =
|
||||
// brk_bindgen::detect_structural_patterns(&catalog);
|
||||
|
||||
// println!("Detected {} structural patterns", patterns.len());
|
||||
// println!(
|
||||
// "Concrete to pattern mappings: {}",
|
||||
// concrete_to_pattern.len()
|
||||
// );
|
||||
// println!("Type parameter mappings: {}", concrete_to_type_param.len());
|
||||
|
||||
// // Print pattern details
|
||||
// for pattern in &patterns {
|
||||
// let mode_str = match &pattern.mode {
|
||||
// Some(brk_bindgen::PatternMode::Suffix { relatives }) => {
|
||||
// format!("Suffix({})", relatives.len())
|
||||
// }
|
||||
// Some(brk_bindgen::PatternMode::Prefix { prefixes }) => {
|
||||
// format!("Prefix({})", prefixes.len())
|
||||
// }
|
||||
// None => "None".to_string(),
|
||||
// };
|
||||
// println!(
|
||||
// " {} (fields: {}, generic: {}, mode: {})",
|
||||
// pattern.name,
|
||||
// pattern.fields.len(),
|
||||
// pattern.is_generic,
|
||||
// mode_str
|
||||
// );
|
||||
// }
|
||||
|
||||
// // Should have detected some patterns
|
||||
// assert!(!patterns.is_empty(), "Should detect at least some patterns");
|
||||
|
||||
// // Check that parameterizable patterns have valid modes
|
||||
// for pattern in &patterns {
|
||||
// if pattern.is_parameterizable() {
|
||||
// let mode = pattern.mode.as_ref().unwrap();
|
||||
// match mode {
|
||||
// brk_bindgen::PatternMode::Suffix { relatives } => {
|
||||
// assert_eq!(
|
||||
// relatives.len(),
|
||||
// pattern.fields.len(),
|
||||
// "Pattern {} should have relative for each field",
|
||||
// pattern.name
|
||||
// );
|
||||
// }
|
||||
// brk_bindgen::PatternMode::Prefix { prefixes } => {
|
||||
// assert_eq!(
|
||||
// prefixes.len(),
|
||||
// pattern.fields.len(),
|
||||
// "Pattern {} should have prefix for each field",
|
||||
// pattern.name
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// #[test]
|
||||
// fn test_cost_basis_pattern() {
|
||||
// let catalog = load_catalog();
|
||||
|
||||
// let (patterns, _, _, _) = brk_bindgen::detect_structural_patterns(&catalog);
|
||||
|
||||
// // Find CostBasisPattern2 and inspect it
|
||||
// let cost_basis = patterns
|
||||
// .iter()
|
||||
// .find(|p| p.name == "CostBasisPattern2")
|
||||
// .expect("CostBasisPattern2 should exist");
|
||||
|
||||
// println!("CostBasisPattern2:");
|
||||
// println!(
|
||||
// " Fields: {:?}",
|
||||
// cost_basis
|
||||
// .fields
|
||||
// .iter()
|
||||
// .map(|f| &f.name)
|
||||
// .collect::<Vec<_>>()
|
||||
// );
|
||||
// println!(" Mode: {:?}", cost_basis.mode);
|
||||
// println!(" Is generic: {}", cost_basis.is_generic);
|
||||
|
||||
// // With suffix naming convention (cost_basis_max, cost_basis_min, cost_basis):
|
||||
// //
|
||||
// // At root level: common prefix is "cost_basis_" -> suffix mode
|
||||
// // max -> "max"
|
||||
// // min -> "min"
|
||||
// // percentiles -> "" (identity)
|
||||
// //
|
||||
// // At lth_ level: common prefix is "lth_cost_basis_" -> suffix mode
|
||||
// // max -> "max"
|
||||
// // min -> "min"
|
||||
// // percentiles -> "" (identity)
|
||||
// //
|
||||
// // Both use suffix mode with same relatives, so pattern IS parameterizable!
|
||||
// assert!(
|
||||
// cost_basis.is_parameterizable(),
|
||||
// "CostBasisPattern2 should be parameterizable with consistent suffix mode"
|
||||
// );
|
||||
// }
|
||||
|
||||
// #[test]
|
||||
// fn test_realized_pattern3_fields() {
|
||||
// let catalog = load_catalog();
|
||||
// let metadata = ClientMetadata::from_catalog(catalog);
|
||||
|
||||
// let pattern = metadata
|
||||
// .find_pattern("RealizedPattern3")
|
||||
// .expect("RealizedPattern3 should exist");
|
||||
|
||||
// println!("RealizedPattern3 fields:");
|
||||
// for field in &pattern.fields {
|
||||
// let is_branch = field.is_branch();
|
||||
// let is_pattern = metadata.find_pattern(&field.rust_type).is_some();
|
||||
// let is_param = metadata.is_parameterizable(&field.rust_type);
|
||||
// println!(
|
||||
// " {} -> {} (branch={}, pattern={}, param={})",
|
||||
// field.name, field.rust_type, is_branch, is_pattern, is_param
|
||||
// );
|
||||
// }
|
||||
|
||||
// // Check if RealizedPattern3 is considered parameterizable
|
||||
// println!(
|
||||
// "\nRealizedPattern3 is_parameterizable (metadata): {}",
|
||||
// metadata.is_parameterizable("RealizedPattern3")
|
||||
// );
|
||||
// }
|
||||
|
||||
// #[test]
|
||||
// fn test_parameterizable_patterns_have_mode() {
|
||||
// let catalog = load_catalog();
|
||||
// let (patterns, _, _, _) = brk_bindgen::detect_structural_patterns(&catalog);
|
||||
|
||||
// // All patterns that appear 2+ times should either:
|
||||
// // 1. Be parameterizable (have a mode)
|
||||
// // 2. Or have inconsistent instances (mode = None)
|
||||
// //
|
||||
// // Patterns with mode = None should be inlined, not generate factories
|
||||
|
||||
// let parameterizable: Vec<_> = patterns.iter().filter(|p| p.is_parameterizable()).collect();
|
||||
// let non_parameterizable: Vec<_> = patterns
|
||||
// .iter()
|
||||
// .filter(|p| !p.is_parameterizable())
|
||||
// .collect();
|
||||
|
||||
// println!("\nParameterizable patterns ({}):", parameterizable.len());
|
||||
// for p in ¶meterizable {
|
||||
// let mode = p.mode.as_ref().unwrap();
|
||||
// let mode_type = match mode {
|
||||
// brk_bindgen::PatternMode::Suffix { .. } => "Suffix",
|
||||
// brk_bindgen::PatternMode::Prefix { .. } => "Prefix",
|
||||
// };
|
||||
// println!(" {} ({} fields, {})", p.name, p.fields.len(), mode_type);
|
||||
// }
|
||||
|
||||
// println!(
|
||||
// "\nNon-parameterizable patterns ({}):",
|
||||
// non_parameterizable.len()
|
||||
// );
|
||||
// for p in &non_parameterizable {
|
||||
// println!(" {} ({} fields)", p.name, p.fields.len());
|
||||
// }
|
||||
|
||||
// // Verify all parameterizable patterns have valid modes with all fields
|
||||
// for pattern in ¶meterizable {
|
||||
// let mode = pattern.mode.as_ref().unwrap();
|
||||
// let field_names: BTreeSet<_> = pattern.fields.iter().map(|f| f.name.clone()).collect();
|
||||
|
||||
// match mode {
|
||||
// brk_bindgen::PatternMode::Suffix { relatives } => {
|
||||
// let mode_fields: BTreeSet<_> = relatives.keys().cloned().collect();
|
||||
// assert_eq!(
|
||||
// field_names, mode_fields,
|
||||
// "Pattern {} suffix mode should have all fields",
|
||||
// pattern.name
|
||||
// );
|
||||
// }
|
||||
// brk_bindgen::PatternMode::Prefix { prefixes } => {
|
||||
// let mode_fields: BTreeSet<_> = prefixes.keys().cloned().collect();
|
||||
// assert_eq!(
|
||||
// field_names, mode_fields,
|
||||
// "Pattern {} prefix mode should have all fields",
|
||||
// pattern.name
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// #[test]
|
||||
// fn test_fee_rate_pattern_relatives() {
|
||||
// let catalog = load_catalog();
|
||||
// let (patterns, _, _, _) = brk_bindgen::detect_structural_patterns(&catalog);
|
||||
|
||||
// let fee_rate_pattern = patterns
|
||||
// .iter()
|
||||
// .find(|p| p.name == "FeeRatePattern")
|
||||
// .expect("FeeRatePattern should exist");
|
||||
|
||||
// println!("FeeRatePattern mode:");
|
||||
// if let Some(mode) = &fee_rate_pattern.mode {
|
||||
// match mode {
|
||||
// brk_bindgen::PatternMode::Suffix { relatives } => {
|
||||
// println!(" Suffix mode:");
|
||||
// for (field, relative) in relatives {
|
||||
// println!(" {} -> '{}'", field, relative);
|
||||
// }
|
||||
// }
|
||||
// brk_bindgen::PatternMode::Prefix { prefixes } => {
|
||||
// println!(" Prefix mode:");
|
||||
// for (field, prefix) in prefixes {
|
||||
// println!(" {} -> '{}'", field, prefix);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// println!(" No mode (not parameterizable)");
|
||||
// }
|
||||
|
||||
// // Check that relatives are correct - should be "average", "max", etc.
|
||||
// // NOT "tx_weight_average", "tx_weight_max", etc.
|
||||
// if let Some(brk_bindgen::PatternMode::Suffix { relatives }) = &fee_rate_pattern.mode {
|
||||
// assert_eq!(
|
||||
// relatives.get("average"),
|
||||
// Some(&"average".to_string()),
|
||||
// "average relative should be 'average', not 'tx_weight_average'"
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
// #[test]
|
||||
// fn test_index_patterns() {
|
||||
// let catalog = load_catalog();
|
||||
|
||||
// let index_patterns = brk_bindgen::detect_index_patterns(&catalog);
|
||||
|
||||
// // println!("Used indexes: {:?}", used_indexes);
|
||||
// println!("Index set patterns: {}", index_patterns.len());
|
||||
|
||||
// for pattern in &index_patterns {
|
||||
// println!(" {} -> {:?}", pattern.name, pattern.indexes);
|
||||
// }
|
||||
|
||||
// // Should have detected some index patterns
|
||||
// assert!(!index_patterns.is_empty(), "Should detect index patterns");
|
||||
// }
|
||||
|
||||
// #[test]
|
||||
// fn test_generated_rust_output() {
|
||||
// let catalog = load_catalog();
|
||||
// let metadata = ClientMetadata::from_catalog(catalog.clone());
|
||||
|
||||
// // Collect all metric names from the catalog
|
||||
// let mut all_metrics = BTreeSet::new();
|
||||
// collect_leaf_names(&catalog, &mut all_metrics);
|
||||
|
||||
// // Generate Rust client output
|
||||
// let mut rust_output = String::new();
|
||||
// brk_bindgen::rust::client::generate_imports(&mut rust_output);
|
||||
// brk_bindgen::rust::client::generate_base_client(&mut rust_output);
|
||||
// brk_bindgen::rust::client::generate_metric_pattern_trait(&mut rust_output);
|
||||
// brk_bindgen::rust::client::generate_endpoint(&mut rust_output);
|
||||
// brk_bindgen::rust::client::generate_index_accessors(
|
||||
// &mut rust_output,
|
||||
// &metadata.index_set_patterns,
|
||||
// );
|
||||
// brk_bindgen::rust::client::generate_pattern_structs(
|
||||
// &mut rust_output,
|
||||
// &metadata.structural_patterns,
|
||||
// &metadata,
|
||||
// );
|
||||
// brk_bindgen::rust::tree::generate_tree(&mut rust_output, &metadata.catalog, &metadata);
|
||||
// brk_bindgen::rust::api::generate_main_client(&mut rust_output, &[]);
|
||||
|
||||
// // Count metrics that appear as direct string literals
|
||||
// let mut direct_metrics = 0;
|
||||
// for metric in &all_metrics {
|
||||
// if rust_output.contains(&format!("\"{}\"", metric)) {
|
||||
// direct_metrics += 1;
|
||||
// }
|
||||
// }
|
||||
|
||||
// println!("\nGenerated Rust output stats:");
|
||||
// println!(" Total metrics in catalog: {}", all_metrics.len());
|
||||
// println!(" Direct string literals: {}", direct_metrics);
|
||||
// println!(
|
||||
// " Via pattern factories: {}",
|
||||
// all_metrics.len() - direct_metrics
|
||||
// );
|
||||
// println!(" Output size: {} bytes", rust_output.len());
|
||||
|
||||
// // Write output to test directory (not actual client)
|
||||
// let output_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/output");
|
||||
// std::fs::create_dir_all(output_dir).ok();
|
||||
// let output_path = format!("{}/rust_client.rs", output_dir);
|
||||
// std::fs::write(&output_path, &rust_output).expect("Failed to write client output");
|
||||
// println!(" Wrote output to: {}", output_path);
|
||||
|
||||
// // Verify the output contains the key components
|
||||
// assert!(rust_output.contains("fn _m("), "Should define _m helper");
|
||||
// assert!(
|
||||
// rust_output.contains("pub struct MetricsTree"),
|
||||
// "Should have MetricsTree"
|
||||
// );
|
||||
// assert!(
|
||||
// rust_output.contains("impl MetricsTree"),
|
||||
// "Should have MetricsTree impl"
|
||||
// );
|
||||
|
||||
// // Count parameterizable patterns (these use _m for dynamic metric names)
|
||||
// // Use metadata.is_parameterizable() for full recursive check
|
||||
// let parameterizable_count = metadata
|
||||
// .structural_patterns
|
||||
// .iter()
|
||||
// .filter(|p| metadata.is_parameterizable(&p.name))
|
||||
// .count();
|
||||
// println!(" Parameterizable patterns: {}", parameterizable_count);
|
||||
|
||||
// // Verify all pattern structs are generated (parameterizable and non)
|
||||
// for pattern in &metadata.structural_patterns {
|
||||
// assert!(
|
||||
// rust_output.contains(&format!("pub struct {}", pattern.name)),
|
||||
// "Missing pattern struct: {}",
|
||||
// pattern.name
|
||||
// );
|
||||
// }
|
||||
|
||||
// println!("\nGenerated Rust client is complete!");
|
||||
// }
|
||||
|
||||
// #[test]
|
||||
// fn test_generated_javascript_output() {
|
||||
// let catalog = load_catalog();
|
||||
// let metadata = ClientMetadata::from_catalog(catalog.clone());
|
||||
|
||||
// // Collect all metric names from the catalog
|
||||
// let mut all_metrics = BTreeSet::new();
|
||||
// collect_leaf_names(&catalog, &mut all_metrics);
|
||||
|
||||
// // Load schemas from OpenAPI spec only (catalog schemas require runtime data)
|
||||
// let openapi_json = load_openapi_json();
|
||||
// let schemas = brk_bindgen::extract_schemas(&openapi_json);
|
||||
|
||||
// // Generate JavaScript client output
|
||||
// let mut js_output = String::new();
|
||||
// writeln!(js_output, "// Auto-generated BRK JavaScript client").unwrap();
|
||||
// writeln!(js_output, "// Do not edit manually\n").unwrap();
|
||||
// brk_bindgen::javascript::types::generate_type_definitions(&mut js_output, &schemas);
|
||||
// brk_bindgen::javascript::client::generate_base_client(&mut js_output);
|
||||
// brk_bindgen::javascript::client::generate_index_accessors(
|
||||
// &mut js_output,
|
||||
// &metadata.index_set_patterns,
|
||||
// );
|
||||
// brk_bindgen::javascript::client::generate_structural_patterns(
|
||||
// &mut js_output,
|
||||
// &metadata.structural_patterns,
|
||||
// &metadata,
|
||||
// );
|
||||
// brk_bindgen::javascript::tree::generate_tree_typedefs(
|
||||
// &mut js_output,
|
||||
// &metadata.catalog,
|
||||
// &metadata,
|
||||
// );
|
||||
// brk_bindgen::javascript::tree::generate_main_client(
|
||||
// &mut js_output,
|
||||
// &metadata.catalog,
|
||||
// &metadata,
|
||||
// &[],
|
||||
// );
|
||||
|
||||
// // Count metrics that appear as direct string literals
|
||||
// let mut direct_metrics = 0;
|
||||
// for metric in &all_metrics {
|
||||
// if js_output.contains(&format!("'{}'", metric))
|
||||
// || js_output.contains(&format!("\"{}\"", metric))
|
||||
// {
|
||||
// direct_metrics += 1;
|
||||
// }
|
||||
// }
|
||||
|
||||
// println!("\nGenerated JavaScript output stats:");
|
||||
// println!(" Total metrics in catalog: {}", all_metrics.len());
|
||||
// println!(" Direct string literals: {}", direct_metrics);
|
||||
// println!(
|
||||
// " Via pattern factories: {}",
|
||||
// all_metrics.len() - direct_metrics
|
||||
// );
|
||||
// println!(" Output size: {} bytes", js_output.len());
|
||||
// println!(" Output lines: {}", js_output.lines().count());
|
||||
|
||||
// // Write output to test directory (not actual client)
|
||||
// let output_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/output");
|
||||
// std::fs::create_dir_all(output_dir).ok();
|
||||
// let output_path = format!("{}/js_client.js", output_dir);
|
||||
// std::fs::write(&output_path, &js_output).expect("Failed to write JS client output");
|
||||
// println!(" Wrote output to: {}", output_path);
|
||||
|
||||
// // Verify the output contains key components
|
||||
// assert!(js_output.contains("const _m ="), "Should define _m helper");
|
||||
// assert!(js_output.contains("const _p ="), "Should define _p helper");
|
||||
// assert!(
|
||||
// js_output.contains("@typedef {Object} MetricsTree"),
|
||||
// "Should have MetricsTree typedef"
|
||||
// );
|
||||
// assert!(
|
||||
// js_output.contains("class BrkClient"),
|
||||
// "Should have BrkClient class"
|
||||
// );
|
||||
|
||||
// // Verify all pattern factories are generated
|
||||
// for pattern in &metadata.structural_patterns {
|
||||
// assert!(
|
||||
// js_output.contains(&format!("function create{}(", pattern.name)),
|
||||
// "Missing pattern factory: {}",
|
||||
// pattern.name
|
||||
// );
|
||||
// }
|
||||
|
||||
// println!("\nGenerated JavaScript client is complete!");
|
||||
// }
|
||||
|
||||
// #[test]
|
||||
// fn test_generated_python_output() {
|
||||
// let catalog = load_catalog();
|
||||
// let metadata = ClientMetadata::from_catalog(catalog.clone());
|
||||
|
||||
// // Collect all metric names from the catalog
|
||||
// let mut all_metrics = BTreeSet::new();
|
||||
// collect_leaf_names(&catalog, &mut all_metrics);
|
||||
|
||||
// // Load schemas from OpenAPI spec only (catalog schemas require runtime data)
|
||||
// let openapi_json = load_openapi_json();
|
||||
// let schemas = brk_bindgen::extract_schemas(&openapi_json);
|
||||
|
||||
// // Generate Python client output
|
||||
// let mut py_output = String::new();
|
||||
// writeln!(py_output, "# Auto-generated BRK Python client").unwrap();
|
||||
// writeln!(py_output, "# Do not edit manually\n").unwrap();
|
||||
// writeln!(py_output, "from typing import TypeVar, Generic, Any, Optional, List, Literal, TypedDict, Union, Protocol, overload, Iterator, Tuple, TYPE_CHECKING").unwrap();
|
||||
// writeln!(py_output, "\nif TYPE_CHECKING:").unwrap();
|
||||
// writeln!(py_output, " import pandas as pd # type: ignore[import-not-found]").unwrap();
|
||||
// writeln!(py_output, " import polars as pl # type: ignore[import-not-found]").unwrap();
|
||||
// writeln!(
|
||||
// py_output,
|
||||
// "from http.client import HTTPSConnection, HTTPConnection"
|
||||
// )
|
||||
// .unwrap();
|
||||
// writeln!(py_output, "from urllib.parse import urlparse").unwrap();
|
||||
// writeln!(py_output, "from datetime import date, timedelta").unwrap();
|
||||
// writeln!(py_output, "from dataclasses import dataclass").unwrap();
|
||||
// writeln!(py_output, "import json\n").unwrap();
|
||||
// writeln!(py_output, "T = TypeVar('T')\n").unwrap();
|
||||
|
||||
// brk_bindgen::python::types::generate_type_definitions(&mut py_output, &schemas);
|
||||
// brk_bindgen::python::client::generate_base_client(&mut py_output);
|
||||
// brk_bindgen::python::client::generate_endpoint_class(&mut py_output);
|
||||
// brk_bindgen::python::client::generate_index_accessors(
|
||||
// &mut py_output,
|
||||
// &metadata.index_set_patterns,
|
||||
// );
|
||||
// brk_bindgen::python::client::generate_structural_patterns(
|
||||
// &mut py_output,
|
||||
// &metadata.structural_patterns,
|
||||
// &metadata,
|
||||
// );
|
||||
// brk_bindgen::python::tree::generate_tree_classes(&mut py_output, &metadata.catalog, &metadata);
|
||||
// brk_bindgen::python::api::generate_main_client(&mut py_output, &[]);
|
||||
|
||||
// // Count metrics that appear as direct string literals
|
||||
// let mut direct_metrics = 0;
|
||||
// for metric in &all_metrics {
|
||||
// if py_output.contains(&format!("'{}'", metric))
|
||||
// || py_output.contains(&format!("\"{}\"", metric))
|
||||
// {
|
||||
// direct_metrics += 1;
|
||||
// }
|
||||
// }
|
||||
|
||||
// println!("\nGenerated Python output stats:");
|
||||
// println!(" Total metrics in catalog: {}", all_metrics.len());
|
||||
// println!(" Direct string literals: {}", direct_metrics);
|
||||
// println!(
|
||||
// " Via pattern factories: {}",
|
||||
// all_metrics.len() - direct_metrics
|
||||
// );
|
||||
// println!(" Output size: {} bytes", py_output.len());
|
||||
// println!(" Output lines: {}", py_output.lines().count());
|
||||
|
||||
// // Write output to test directory (not actual client)
|
||||
// let output_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/output");
|
||||
// std::fs::create_dir_all(output_dir).ok();
|
||||
// let output_path = format!("{}/python_client.py", output_dir);
|
||||
// std::fs::write(&output_path, &py_output).expect("Failed to write Python client output");
|
||||
// println!(" Wrote output to: {}", output_path);
|
||||
|
||||
// // Verify the output contains key components
|
||||
// assert!(py_output.contains("def _m("), "Should define _m helper");
|
||||
// assert!(py_output.contains("def _p("), "Should define _p helper");
|
||||
// assert!(
|
||||
// py_output.contains("class MetricsTree:"),
|
||||
// "Should have MetricsTree class"
|
||||
// );
|
||||
// assert!(
|
||||
// py_output.contains("class BrkClient"),
|
||||
// "Should have BrkClient class"
|
||||
// );
|
||||
|
||||
// // Verify all pattern classes have constructors
|
||||
// for pattern in &metadata.structural_patterns {
|
||||
// assert!(
|
||||
// py_output.contains(&format!("class {}:", pattern.name))
|
||||
// || py_output.contains(&format!("class {}(", pattern.name)),
|
||||
// "Missing pattern class: {}",
|
||||
// pattern.name
|
||||
// );
|
||||
// }
|
||||
|
||||
// println!("\nGenerated Python client is complete!");
|
||||
// }
|
||||
|
||||
// #[test]
|
||||
// fn test_cost_basis_relatives() {
|
||||
// let catalog = load_catalog();
|
||||
|
||||
// // Find cost_basis branches that have 3 direct children (max, min, percentiles)
|
||||
// fn find_cost_basis_with_percentiles(
|
||||
// node: &TreeNode,
|
||||
// path: &str,
|
||||
// ) -> Vec<(String, Vec<(String, String)>)> {
|
||||
// let mut results = Vec::new();
|
||||
// if let TreeNode::Branch(children) = node {
|
||||
// for (name, child) in children {
|
||||
// let child_path = if path.is_empty() {
|
||||
// name.clone()
|
||||
// } else {
|
||||
// format!("{}.{}", path, name)
|
||||
// };
|
||||
|
||||
// if name == "cost_basis"
|
||||
// && let TreeNode::Branch(cb_children) = child
|
||||
// && cb_children.contains_key("percentiles")
|
||||
// {
|
||||
// // Found a cost_basis with percentiles
|
||||
// let mut metrics = Vec::new();
|
||||
// for (field_name, field_node) in cb_children {
|
||||
// match field_node {
|
||||
// TreeNode::Leaf(leaf) => {
|
||||
// metrics.push((field_name.clone(), leaf.name().to_string()));
|
||||
// }
|
||||
// TreeNode::Branch(pct_children) => {
|
||||
// // Get first percentile as example
|
||||
// if let Some((_, TreeNode::Leaf(first))) = pct_children.iter().next()
|
||||
// {
|
||||
// metrics.push((
|
||||
// format!("{}.first", field_name),
|
||||
// first.name().to_string(),
|
||||
// ));
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// results.push((child_path.clone(), metrics));
|
||||
// }
|
||||
// results.extend(find_cost_basis_with_percentiles(child, &child_path));
|
||||
// }
|
||||
// }
|
||||
// results
|
||||
// }
|
||||
|
||||
// let instances = find_cost_basis_with_percentiles(&catalog, "");
|
||||
|
||||
// println!("\nCostBasisPattern2 instances (with percentiles):");
|
||||
// for (path, metrics) in instances.iter().take(10) {
|
||||
// println!(" {}:", path);
|
||||
// for (field, metric) in metrics {
|
||||
// println!(" {} -> {}", field, metric);
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Now compute what relatives the pattern detection would see
|
||||
// // The key is: percentiles returns its BASE (common prefix of pct05, pct10, etc.)
|
||||
// // not the individual percentile metrics
|
||||
// use brk_bindgen::find_common_prefix;
|
||||
|
||||
// println!("\nComputing relatives (simulating branch base returns):");
|
||||
// for (path, metrics) in instances.iter().take(5) {
|
||||
// println!(" Instance: {}", path);
|
||||
|
||||
// // For leaves (max, min), the base is the metric name
|
||||
// // For branches (percentiles), the base is the common prefix of its children
|
||||
// let mut child_bases: std::collections::BTreeMap<String, String> =
|
||||
// std::collections::BTreeMap::new();
|
||||
// for (field, metric) in metrics {
|
||||
// if field.starts_with("percentiles.") {
|
||||
// // This is a percentile metric - compute what the percentiles branch would return
|
||||
// // The base is the metric name with the pct suffix stripped
|
||||
// let base = metric
|
||||
// .strip_suffix("_pct05")
|
||||
// .or_else(|| metric.strip_suffix("_pct10"))
|
||||
// .unwrap_or(metric)
|
||||
// .to_string();
|
||||
// child_bases.insert("percentiles".to_string(), base);
|
||||
// } else {
|
||||
// child_bases.insert(field.clone(), metric.clone());
|
||||
// }
|
||||
// }
|
||||
|
||||
// let bases: Vec<&str> = child_bases.values().map(|s| s.as_str()).collect();
|
||||
// println!(" Child bases:");
|
||||
// for (field, base) in &child_bases {
|
||||
// println!(" {} -> {}", field, base);
|
||||
// }
|
||||
|
||||
// if let Some(prefix) = find_common_prefix(&bases) {
|
||||
// println!(" Common prefix: '{}'", prefix);
|
||||
// for (field, base) in &child_bases {
|
||||
// let relative = base.strip_prefix(&prefix).unwrap_or(base);
|
||||
// println!(" {} -> relative '{}'", field, relative);
|
||||
// }
|
||||
// } else {
|
||||
// println!(" No common prefix found!");
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// #[test]
|
||||
// fn test_debug_cost_basis_pattern2_mode() {
|
||||
// // Debug why CostBasisPattern2 has mode=None
|
||||
// let catalog = load_catalog();
|
||||
// let metadata = brk_bindgen::ClientMetadata::from_catalog(catalog.clone());
|
||||
// let pattern_lookup = metadata.pattern_lookup();
|
||||
|
||||
// let pattern = metadata
|
||||
// .find_pattern("CostBasisPattern2")
|
||||
// .expect("CostBasisPattern2 should exist");
|
||||
|
||||
// println!("\nCostBasisPattern2 fields:");
|
||||
// for field in &pattern.fields {
|
||||
// println!(" {} (type: {})", field.name, field.rust_type);
|
||||
// }
|
||||
// println!("Mode: {:?}", pattern.mode);
|
||||
|
||||
// // Now debug the instance collection
|
||||
// #[derive(Debug, Clone)]
|
||||
// struct DebugInstanceAnalysis {
|
||||
// base: String,
|
||||
// field_parts: std::collections::BTreeMap<String, String>,
|
||||
// is_suffix_mode: bool,
|
||||
// }
|
||||
|
||||
// fn collect_debug(
|
||||
// node: &TreeNode,
|
||||
// pattern_lookup: &std::collections::BTreeMap<Vec<brk_bindgen::PatternField>, String>,
|
||||
// all_analyses: &mut std::collections::BTreeMap<String, Vec<DebugInstanceAnalysis>>,
|
||||
// ) -> Option<String> {
|
||||
// match node {
|
||||
// TreeNode::Leaf(leaf) => Some(leaf.name().to_string()),
|
||||
// TreeNode::Branch(children) => {
|
||||
// let mut child_bases: std::collections::BTreeMap<String, String> =
|
||||
// std::collections::BTreeMap::new();
|
||||
// for (field_name, child_node) in children {
|
||||
// if let Some(base) = collect_debug(child_node, pattern_lookup, all_analyses) {
|
||||
// child_bases.insert(field_name.clone(), base);
|
||||
// }
|
||||
// }
|
||||
|
||||
// if child_bases.is_empty() {
|
||||
// return None;
|
||||
// }
|
||||
|
||||
// // Analyze this instance
|
||||
// let bases: Vec<&str> = child_bases.values().map(|s| s.as_str()).collect();
|
||||
// let (base, field_parts, is_suffix_mode) =
|
||||
// if let Some(common_prefix) = brk_bindgen::find_common_prefix(&bases) {
|
||||
// let base = common_prefix.trim_end_matches('_').to_string();
|
||||
// let mut parts = std::collections::BTreeMap::new();
|
||||
// for (field_name, child_base) in &child_bases {
|
||||
// let relative = if *child_base == base {
|
||||
// String::new()
|
||||
// } else {
|
||||
// child_base
|
||||
// .strip_prefix(&common_prefix)
|
||||
// .unwrap_or(child_base)
|
||||
// .to_string()
|
||||
// };
|
||||
// parts.insert(field_name.clone(), relative);
|
||||
// }
|
||||
// (base, parts, true)
|
||||
// } else {
|
||||
// let base = child_bases.values().next().cloned().unwrap_or_default();
|
||||
// let parts = child_bases
|
||||
// .iter()
|
||||
// .map(|(k, v)| (k.clone(), v.clone()))
|
||||
// .collect();
|
||||
// (base, parts, true)
|
||||
// };
|
||||
|
||||
// let analysis = DebugInstanceAnalysis {
|
||||
// base: base.clone(),
|
||||
// field_parts,
|
||||
// is_suffix_mode,
|
||||
// };
|
||||
|
||||
// // Get the pattern name for this node
|
||||
// let fields = brk_bindgen::get_node_fields(children, pattern_lookup);
|
||||
// if let Some(pattern_name) = pattern_lookup.get(&fields) {
|
||||
// all_analyses
|
||||
// .entry(pattern_name.clone())
|
||||
// .or_default()
|
||||
// .push(analysis);
|
||||
// }
|
||||
|
||||
// Some(base)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// let mut all_analyses: BTreeMap<String, Vec<DebugInstanceAnalysis>> = BTreeMap::new();
|
||||
// collect_debug(&catalog, &pattern_lookup, &mut all_analyses);
|
||||
|
||||
// if let Some(analyses) = all_analyses.get("CostBasisPattern2") {
|
||||
// println!(
|
||||
// "\nCollected {} instances of CostBasisPattern2:",
|
||||
// analyses.len()
|
||||
// );
|
||||
// for (i, a) in analyses.iter().enumerate() {
|
||||
// println!(" Instance {}:", i);
|
||||
// println!(" base: {}", a.base);
|
||||
// println!(" is_suffix: {}", a.is_suffix_mode);
|
||||
// println!(" field_parts:");
|
||||
// for (f, p) in &a.field_parts {
|
||||
// println!(" {} -> '{}'", f, p);
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Check consistency
|
||||
// if analyses.len() >= 2 {
|
||||
// let first = &analyses[0];
|
||||
// for (i, a) in analyses.iter().enumerate().skip(1) {
|
||||
// if a.is_suffix_mode != first.is_suffix_mode {
|
||||
// println!(" INCONSISTENT: Instance {} has different mode", i);
|
||||
// }
|
||||
// for (field, part) in &a.field_parts {
|
||||
// if first.field_parts.get(field) != Some(part) {
|
||||
// println!(
|
||||
// " INCONSISTENT: Instance {} field '{}' has part '{}' vs '{}'",
|
||||
// i,
|
||||
// field,
|
||||
// part,
|
||||
// first
|
||||
// .field_parts
|
||||
// .get(field)
|
||||
// .unwrap_or(&"<missing>".to_string())
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// println!("\nNo instances collected for CostBasisPattern2!");
|
||||
// }
|
||||
// }
|
||||
|
||||
// #[test]
|
||||
// fn test_root_cost_basis_prefix() {
|
||||
// use brk_bindgen::find_common_prefix;
|
||||
|
||||
// // Root-level cost_basis has:
|
||||
// // max -> "max_cost_basis"
|
||||
// // min -> "min_cost_basis"
|
||||
// // percentiles -> "cost_basis" (base of pct05, pct10, etc.)
|
||||
|
||||
// let bases = vec!["max_cost_basis", "min_cost_basis", "cost_basis"];
|
||||
// let prefix = find_common_prefix(&bases);
|
||||
// println!("Root cost_basis prefix: {:?}", prefix);
|
||||
|
||||
// // Compare with nested cost_basis
|
||||
// let nested_bases = vec![
|
||||
// "utxos_at_least_15y_old_max_cost_basis",
|
||||
// "utxos_at_least_15y_old_min_cost_basis",
|
||||
// "utxos_at_least_15y_old_cost_basis",
|
||||
// ];
|
||||
// let nested_prefix = find_common_prefix(&nested_bases);
|
||||
// println!("Nested cost_basis prefix: {:?}", nested_prefix);
|
||||
// }
|
||||
|
||||
// #[test]
|
||||
// fn test_utxo_cohorts_all_activity_base() {
|
||||
// // Test that distribution.utxo_cohorts.all.activity uses empty base
|
||||
// // because its children (coinblocks_destroyed, coindays_destroyed, etc.)
|
||||
// // have no common prefix or suffix.
|
||||
// let catalog = load_catalog();
|
||||
// let metadata = ClientMetadata::from_catalog(catalog.clone());
|
||||
|
||||
// // Generate JavaScript output
|
||||
// let mut js_output = String::new();
|
||||
// writeln!(js_output, "// Test output").unwrap();
|
||||
// brk_bindgen::javascript::client::generate_base_client(&mut js_output);
|
||||
// brk_bindgen::javascript::client::generate_index_accessors(
|
||||
// &mut js_output,
|
||||
// &metadata.index_set_patterns,
|
||||
// );
|
||||
// brk_bindgen::javascript::client::generate_structural_patterns(
|
||||
// &mut js_output,
|
||||
// &metadata.structural_patterns,
|
||||
// &metadata,
|
||||
// );
|
||||
// brk_bindgen::javascript::tree::generate_tree_typedefs(
|
||||
// &mut js_output,
|
||||
// &metadata.catalog,
|
||||
// &metadata,
|
||||
// );
|
||||
// brk_bindgen::javascript::tree::generate_main_client(
|
||||
// &mut js_output,
|
||||
// &metadata.catalog,
|
||||
// &metadata,
|
||||
// &[],
|
||||
// );
|
||||
|
||||
// // The all.activity should use empty base, so metrics don't get duplicated
|
||||
// // Look for: activity: createActivityPattern2(this, '')
|
||||
// // NOT: activity: createActivityPattern2(this, 'coinblocks_destroyed')
|
||||
// assert!(
|
||||
// !js_output.contains("createActivityPattern2(this, 'coinblocks_destroyed')"),
|
||||
// "all.activity should NOT use 'coinblocks_destroyed' as base (causes duplication)"
|
||||
// );
|
||||
|
||||
// // Check that it uses empty string as base
|
||||
// assert!(
|
||||
// js_output.contains("activity: createActivityPattern2(this, '')"),
|
||||
// "all.activity should use empty base"
|
||||
// );
|
||||
|
||||
// println!("utxo_cohorts.all.activity base test passed!");
|
||||
// }
|
||||
@@ -1,17 +0,0 @@
|
||||
[package]
|
||||
name = "brk_bundler"
|
||||
description = "A thin wrapper around rolldown"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
notify = "8.2.0"
|
||||
# rolldown = { path = "../../../rolldown/crates/rolldown", package = "brk_rolldown" }
|
||||
rolldown = { version = "0.5.1", package = "brk_rolldown" }
|
||||
sugar_path = "1.2.1"
|
||||
tokio = { workspace = true }
|
||||
@@ -1,32 +0,0 @@
|
||||
# brk_bundler
|
||||
|
||||
JavaScript bundling with watch mode for BRK web interfaces.
|
||||
|
||||
## What It Enables
|
||||
|
||||
Bundle and minify JavaScript modules using Rolldown, with file watching for development. Handles module copying, source map generation, and cache-busting via hashed filenames.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Rolldown integration**: Fast Rust-based bundler with tree-shaking and minification
|
||||
- **Watch mode**: Rebuilds on file changes with live module syncing
|
||||
- **Source maps**: Full debugging support in production builds
|
||||
- **Cache busting**: Hashes main bundle filename, updates HTML references automatically
|
||||
- **Service worker versioning**: Injects package version into service worker files
|
||||
|
||||
## Core API
|
||||
|
||||
```rust,ignore
|
||||
// One-shot build
|
||||
let dist = bundle(modules_path, websites_path, "src", false).await?;
|
||||
|
||||
// Watch mode for development
|
||||
bundle(modules_path, websites_path, "src", true).await?;
|
||||
```
|
||||
|
||||
## Build Pipeline
|
||||
|
||||
1. Copy shared modules to source scripts directory
|
||||
2. Bundle with Rolldown (minified, with source maps)
|
||||
3. Update `index.html` with hashed script references
|
||||
4. Inject version into service worker
|
||||
@@ -1,8 +0,0 @@
|
||||
fn main() {
|
||||
let profile = std::env::var("PROFILE").unwrap_or_default();
|
||||
|
||||
if profile == "release" {
|
||||
println!("cargo:rustc-flag=-C");
|
||||
println!("cargo:rustc-flag=target-cpu=native");
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
use std::{
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use log::error;
|
||||
use notify::{EventKind, RecursiveMode, Watcher};
|
||||
use rolldown::{
|
||||
Bundler, BundlerOptions, InlineConstConfig, InlineConstMode, InlineConstOption,
|
||||
OptimizationOption, RawMinifyOptions, SourceMapType,
|
||||
};
|
||||
use sugar_path::SugarPath;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
pub async fn bundle(
|
||||
modules_path: &Path,
|
||||
websites_path: &Path,
|
||||
source_folder: &str,
|
||||
watch: bool,
|
||||
) -> io::Result<PathBuf> {
|
||||
let relative_modules_path = modules_path;
|
||||
let relative_source_path = websites_path.join(source_folder);
|
||||
let relative_dist_path = websites_path.join("dist");
|
||||
|
||||
let absolute_modules_path = relative_modules_path.absolutize();
|
||||
let absolute_modules_path_clone = absolute_modules_path.clone();
|
||||
let absolute_websites_path = websites_path.absolutize();
|
||||
let absolute_websites_path_clone = absolute_websites_path.clone();
|
||||
|
||||
let absolute_source_path = relative_source_path.absolutize();
|
||||
let absolute_source_index_path = absolute_source_path.join("index.html");
|
||||
let absolute_source_index_path_clone = absolute_source_index_path.clone();
|
||||
let absolute_source_scripts_path = absolute_source_path.join("scripts");
|
||||
let absolute_source_scripts_modules_path = absolute_source_scripts_path.join("modules");
|
||||
let absolute_source_sw_path = absolute_source_path.join("service-worker.js");
|
||||
let absolute_source_sw_path_clone = absolute_source_sw_path.clone();
|
||||
|
||||
let absolute_dist_path = relative_dist_path.absolutize();
|
||||
let absolute_dist_scripts_path = absolute_dist_path.join("scripts");
|
||||
let absolute_dist_scripts_entry_path = absolute_dist_scripts_path.join("entry.js");
|
||||
let absolute_dist_scripts_entry_path_clone = absolute_dist_scripts_entry_path.clone();
|
||||
let absolute_dist_index_path = absolute_dist_path.join("index.html");
|
||||
let absolute_dist_sw_path = absolute_dist_path.join("service-worker.js");
|
||||
|
||||
let _ = fs::remove_dir_all(&absolute_dist_path);
|
||||
let _ = fs::remove_dir_all(&absolute_source_scripts_modules_path);
|
||||
copy_dir_all(
|
||||
&absolute_modules_path,
|
||||
&absolute_source_scripts_modules_path,
|
||||
)?;
|
||||
copy_dir_all(&absolute_source_path, &absolute_dist_path)?;
|
||||
fs::remove_dir_all(&absolute_dist_scripts_path)?;
|
||||
fs::create_dir(&absolute_dist_scripts_path)?;
|
||||
|
||||
// dbg!(BundlerOptions::default());
|
||||
|
||||
let mut bundler = Bundler::new(BundlerOptions {
|
||||
input: Some(vec![format!("./{source_folder}/scripts/entry.js").into()]),
|
||||
dir: Some("./dist/scripts".to_string()),
|
||||
cwd: Some(absolute_websites_path),
|
||||
minify: Some(RawMinifyOptions::Bool(true)),
|
||||
sourcemap: Some(SourceMapType::File),
|
||||
// advanced_chunks: Some(AdvancedChunksOptions {
|
||||
// // min_size: Some(1000.0),
|
||||
// min_share_count: Some(20),
|
||||
// // min_module_size: S
|
||||
// // include_dependencies_recursively: Some(true),
|
||||
// ..Default::default()
|
||||
// }),
|
||||
//
|
||||
// inline_dynamic_imports
|
||||
// experimental: Some(ExperimentalOptions {
|
||||
// strict_execution_order: Some(true),
|
||||
// ..Default::default()
|
||||
// }),
|
||||
optimization: Some(OptimizationOption {
|
||||
inline_const: Some(InlineConstOption::Config(InlineConstConfig {
|
||||
mode: Some(InlineConstMode::All),
|
||||
..Default::default()
|
||||
})),
|
||||
// Needs benchmarks
|
||||
// pife_for_module_wrappers: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
if let Err(error) = bundler.write().await {
|
||||
error!("{error:?}");
|
||||
}
|
||||
|
||||
let update_dist_index = move || {
|
||||
let mut contents = fs::read_to_string(&absolute_source_index_path).unwrap();
|
||||
|
||||
if let Ok(entry) = fs::read_to_string(&absolute_dist_scripts_entry_path_clone)
|
||||
&& let Some(start) = entry.find("main")
|
||||
&& let Some(end) = entry.find(".js")
|
||||
{
|
||||
let main_hashed = &entry[start..end];
|
||||
contents = contents.replace("/scripts/main.js", &format!("/scripts/{main_hashed}.js"));
|
||||
}
|
||||
|
||||
let _ = fs::write(&absolute_dist_index_path, contents);
|
||||
};
|
||||
|
||||
let update_source_sw = move || {
|
||||
let contents = fs::read_to_string(&absolute_source_sw_path)
|
||||
.unwrap()
|
||||
.replace("__VERSION__", &format!("v{VERSION}"));
|
||||
let _ = fs::write(&absolute_dist_sw_path, contents);
|
||||
};
|
||||
|
||||
update_dist_index();
|
||||
update_source_sw();
|
||||
|
||||
if !watch {
|
||||
return Ok(relative_dist_path);
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut event_watcher = notify::recommended_watcher(
|
||||
move |res: Result<notify::Event, notify::Error>| match res {
|
||||
Ok(event) => match event.kind {
|
||||
EventKind::Create(_) => event.paths,
|
||||
EventKind::Modify(_) => event.paths,
|
||||
_ => vec![],
|
||||
}
|
||||
.into_iter()
|
||||
.for_each(|path| {
|
||||
let path = path.absolutize();
|
||||
|
||||
if path == absolute_dist_scripts_entry_path
|
||||
|| path == absolute_source_index_path_clone
|
||||
{
|
||||
update_dist_index();
|
||||
} else if path == absolute_source_sw_path_clone {
|
||||
update_source_sw();
|
||||
} else if let Ok(suffix) = path.strip_prefix(&absolute_modules_path) {
|
||||
let source_modules_path = absolute_source_scripts_modules_path.join(suffix);
|
||||
if path.is_file() {
|
||||
let _ = fs::create_dir_all(path.parent().unwrap());
|
||||
let _ = fs::copy(&path, &source_modules_path);
|
||||
}
|
||||
} else if let Ok(suffix) = path.strip_prefix(&absolute_source_path)
|
||||
// scripts are handled by rolldown
|
||||
&& !path.starts_with(&absolute_source_scripts_path)
|
||||
{
|
||||
let dist_path = absolute_dist_path.join(suffix);
|
||||
if path.is_file() {
|
||||
let _ = fs::create_dir_all(path.parent().unwrap());
|
||||
let _ = fs::copy(&path, &dist_path);
|
||||
}
|
||||
}
|
||||
}),
|
||||
Err(e) => error!("watch error: {e:?}"),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
event_watcher
|
||||
.watch(&absolute_websites_path_clone, RecursiveMode::Recursive)
|
||||
.unwrap();
|
||||
event_watcher
|
||||
.watch(&absolute_modules_path_clone, RecursiveMode::Recursive)
|
||||
.unwrap();
|
||||
|
||||
let watcher = rolldown::Watcher::new(vec![Arc::new(Mutex::new(bundler))], None).unwrap();
|
||||
|
||||
watcher.start().await;
|
||||
});
|
||||
|
||||
Ok(relative_dist_path)
|
||||
}
|
||||
|
||||
fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> io::Result<()> {
|
||||
fs::create_dir_all(&dst)?;
|
||||
for entry in fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let ty = entry.file_type()?;
|
||||
if ty.is_dir() {
|
||||
copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
|
||||
} else {
|
||||
fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -6,36 +6,28 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
brk_binder = { workspace = true }
|
||||
brk_bundler = { workspace = true }
|
||||
anyhow = "1.0"
|
||||
brk_alloc = { workspace = true }
|
||||
brk_computer = { workspace = true }
|
||||
brk_error = { workspace = true }
|
||||
brk_fetcher = { workspace = true }
|
||||
brk_error = { workspace = true, features = ["tokio", "vecdb"] }
|
||||
brk_indexer = { workspace = true }
|
||||
brk_iterator = { workspace = true }
|
||||
brk_logger = { workspace = true }
|
||||
brk_mempool = { workspace = true }
|
||||
brk_query = { workspace = true }
|
||||
brk_reader = { workspace = true }
|
||||
brk_rpc = { workspace = true }
|
||||
brk_server = { workspace = true }
|
||||
clap = { version = "4.5.53", features = ["derive", "string"] }
|
||||
color-eyre = { workspace = true }
|
||||
log = { workspace = true }
|
||||
mimalloc = { workspace = true }
|
||||
minreq = { workspace = true }
|
||||
brk_types = { workspace = true }
|
||||
lexopt = "0.3"
|
||||
owo-colors = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
toml = "0.9.10"
|
||||
toml = "1.1.2"
|
||||
vecdb = { workspace = true }
|
||||
zip = { version = "7.0.0", default-features = false, features = ["deflate"] }
|
||||
|
||||
[[bin]]
|
||||
name = "brk"
|
||||
path = "src/main.rs"
|
||||
|
||||
[package.metadata.dist]
|
||||
dist = true
|
||||
|
||||
+64
-38
@@ -1,54 +1,80 @@
|
||||
# brk_cli
|
||||
# BRK CLI
|
||||
|
||||
Command-line interface for running the Bitcoin Research Kit.
|
||||
Run your own Bitcoin Research Kit instance. One binary, one command. Full sync in ~4-7h depending on hardware. ~44% disk overhead vs 250% for mempool/electrs.
|
||||
|
||||
## What It Enables
|
||||
[bitview.space](https://bitview.space) is the official free hosted instance.
|
||||
|
||||
Run a full BRK instance: index the blockchain, compute metrics, serve the API, and optionally host a web interface. Continuously syncs with new blocks.
|
||||
## Requirements
|
||||
|
||||
## Key Features
|
||||
- Linux or macOS
|
||||
- Bitcoin Core with `server=1` in `bitcoin.conf`
|
||||
- Access to `blk*.dat` files
|
||||
- [~400 GB disk space](https://bitview.space/api/server/disk) (see [Disk usage](#disk-usage))
|
||||
- [12+ GB RAM](https://github.com/bitcoinresearchkit/benches#benchmarks)
|
||||
|
||||
- **All-in-one**: Single binary runs indexer, computer, mempool monitor, and server
|
||||
- **Auto-sync**: Waits for new blocks and processes them automatically
|
||||
- **Web interface**: Downloads and bundles frontend from GitHub releases
|
||||
- **Configurable**: TOML config for RPC, paths, and features
|
||||
- **Collision checking**: Optional TXID collision validation mode
|
||||
- **Memory optimized**: Uses mimalloc allocator, 512MB stack for deep recursion
|
||||
## Disk usage
|
||||
|
||||
## Usage
|
||||
BRK uses [sparse files](https://en.wikipedia.org/wiki/Sparse_file). Tools like `ls -l` or Finder report the logical file size (>1 TB), not actual disk usage (~350 GB). Use `du -sh` to see real usage.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# See all options
|
||||
brk --help
|
||||
|
||||
# The CLI will:
|
||||
# 1. Index new blocks
|
||||
# 2. Compute derived metrics
|
||||
# 3. Start mempool monitor
|
||||
# 4. Launch API server (port 3110)
|
||||
# 5. Wait for new blocks and repeat
|
||||
rustup update && RUSTFLAGS="-C target-cpu=native" cargo install --locked brk_cli --version $(cargo search brk_cli | head -1 | awk -F'"' '{print $2}')
|
||||
```
|
||||
|
||||
## Components
|
||||
Updates Rust, then builds `brk_cli` with optimizations tuned to your CPU. The `--version $(...)` subshell queries crates.io for the absolute latest published version, including pre-releases (rc/beta/alpha); without it, `cargo install` only picks the latest stable.
|
||||
|
||||
1. **Indexer**: Processes blocks into queryable indexes
|
||||
2. **Computer**: Derives 1000+ on-chain metrics
|
||||
3. **Mempool**: Real-time fee estimation
|
||||
4. **Server**: REST API + MCP endpoint
|
||||
5. **Bundler**: JS bundling for web interface (if enabled)
|
||||
Portable build (without native CPU optimizations):
|
||||
|
||||
## Performance
|
||||
```bash
|
||||
cargo install --locked brk_cli
|
||||
```
|
||||
|
||||
| Machine | Time | Disk | Peak Disk | Memory | Peak Memory |
|
||||
|---------|------|------|-----------|--------|-------------|
|
||||
| MBP M3 Pro (36GB, internal SSD) | 5.2h | 341 GB | 415 GB | 6.4 GB | 12 GB |
|
||||
## Update
|
||||
|
||||
Full benchmark data: [`https://github.com/bitcoinresearchkit/benches/tree/main/brk`](/benches/brk)
|
||||
Updating is the same as installing: re-run the install command above. `cargo install` overwrites the existing binary in place. Indexed data is reused when the on-disk format is unchanged, otherwise it is reset and re-synced automatically on the next run.
|
||||
|
||||
## Built On
|
||||
## Run
|
||||
|
||||
- `brk_indexer` for blockchain indexing
|
||||
- `brk_computer` for metric computation
|
||||
- `brk_mempool` for mempool monitoring
|
||||
- `brk_server` for HTTP API
|
||||
- `brk_bundler` for web interface bundling
|
||||
```bash
|
||||
brk
|
||||
```
|
||||
|
||||
Indexes the blockchain, computes datasets, starts the server on `localhost:3110`, and waits for new blocks.
|
||||
|
||||
## First sync
|
||||
|
||||
The initial sync processes the entire blockchain and can take several hours. During this time (more than 10,000 blocks behind), indexing completes before the server starts to free up memory. The web interface at `localhost:3110` won't be available until sync finishes.
|
||||
|
||||
## Options
|
||||
|
||||
```bash
|
||||
brk -h # Show all options
|
||||
brk -V # Show version
|
||||
```
|
||||
|
||||
Command-line options override `~/.brk/config.toml` for that run only. Edit the file directly to persist settings:
|
||||
|
||||
```toml
|
||||
brkdir = "/path/to/data"
|
||||
bitcoindir = "/path/to/.bitcoin"
|
||||
```
|
||||
|
||||
All fields are optional. See `brk -h` for the full list.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
LOG=debug brk # Enable debug logging (keeps noise filters)
|
||||
RUST_LOG=... brk # Full control over log filtering (overrides all defaults)
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
~/.brk/
|
||||
├── config.toml Configuration
|
||||
└── log Logs
|
||||
|
||||
<brkdir>/ Indexed data (default: ~/.brk)
|
||||
```
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user