Compare commits
336 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a2ba17d20 | |||
| ac30f0e512 | |||
| 2e1037ff36 | |||
| 626c52044d | |||
| f7ee4e487a | |||
| 7b3e172948 | |||
| 6bb1a2a311 | |||
| 3b00a92fa4 | |||
| f39681bb2b | |||
| 967d2c7f35 | |||
| b0d933a7ab | |||
| 96e0df110e | |||
| 91a6129e8d | |||
| d9c829c3c6 | |||
| 467dfcc4b8 | |||
| 8a938c00f6 | |||
| 5661735f3e | |||
| 1c7434ff83 | |||
| d75c2a881b | |||
| ddb1db7a8e | |||
| 407a365055 | |||
| 335cbce09e | |||
| 922a0abb60 | |||
| 25a0ebe51e | |||
| 3a836ab0f4 | |||
| 524ab3de05 | |||
| e77993fb76 | |||
| 0c442b4a71 | |||
| 670aa95494 | |||
| 5ffb66c0dc | |||
| b675b70067 | |||
| 1484eae53c | |||
| b12a72ea1a | |||
| 1b9e18f98b | |||
| 8fe0af349d | |||
| 5826d78e35 | |||
| 325811fee7 | |||
| 69f6d32d4a | |||
| ea70c381de | |||
| 6f45ec13f3 | |||
| 3bc0615000 | |||
| 69729842a4 | |||
| 5f4fc646f5 | |||
| 85570c73cb | |||
| 3a3f6b8593 | |||
| 426d7797a3 | |||
| cb0abc324e | |||
| e832ffbe23 | |||
| abffdec497 | |||
| 70e7e24b4f | |||
| 13ab7d39d7 | |||
| 3cae817915 | |||
| c33444a92e | |||
| 3e9b1cc2b2 | |||
| ac6175688d | |||
| a6f8108165 | |||
| 8cff55a405 | |||
| bd376f86ea | |||
| d9f28e85af | |||
| ed18fd55e1 | |||
| 5b06098368 | |||
| e89a67b9a7 | |||
| 445959f5b9 | |||
| 647f177f31 | |||
| 705dbdbd7e | |||
| 31d2f8ef37 | |||
| 236b4097c5 | |||
| f5790d5c8a | |||
| f08ac7f916 | |||
| e77d338357 | |||
| 5d6325ae30 | |||
| 9ba77dac0f | |||
| f9856cf0aa | |||
| de93f08e93 | |||
| d538280f4b | |||
| bbb74b76c8 | |||
| eadf93b804 | |||
| f29443fc15 | |||
| 75a023bdd8 | |||
| d30344ee3c | |||
| 02d635d48b | |||
| 40ec356cc3 | |||
| 5a5d4da57d | |||
| efb247d104 | |||
| 457b0e24c5 | |||
| 6e0ac138d8 | |||
| 26c6c92bb8 | |||
| e1ad45f44b | |||
| aebca14d78 | |||
| 42b0d7a174 | |||
| a37c2474fe | |||
| 5f308e9da7 | |||
| 3aadced85d | |||
| 9375d5aded | |||
| 2c8205146c | |||
| 8d5a2b911d | |||
| 7d5de7bf24 | |||
| 4b1410855a | |||
| 78a4d1af65 | |||
| 5e3519aad4 | |||
| 4386ef47fe | |||
| 135a18d56f | |||
| 71f45479b9 | |||
| bcb8d5bed6 | |||
| 8f19bf7350 | |||
| 25860636f0 | |||
| 8c2402cacb | |||
| 4b910ceaa7 | |||
| 4a0ce6337f | |||
| e134ed11a9 | |||
| 03b83846ef | |||
| 7c86c803fa | |||
| a31d9dc15e | |||
| 57749da919 | |||
| 9ad3acbdf9 | |||
| 6fa53aca9f | |||
| bd53168c4e | |||
| 08d17b4a09 | |||
| c5657b9c31 | |||
| 549e2da05b | |||
| c5e912593a | |||
| a86085c2db | |||
| edbec6fd5c | |||
| a76139c0ea | |||
| 59f1296d56 | |||
| 14ae41c7ba | |||
| df09b3aa28 | |||
| f9fad2d775 | |||
| fa609c73ba | |||
| 9b2f334130 | |||
| a006cefd71 | |||
| 4b2ada14a0 | |||
| 1ad8d8a631 | |||
| 3ca83a2289 | |||
| 2ccf0ef856 | |||
| f7f065c6e0 | |||
| 593af69230 | |||
| 032f3cb66b | |||
| 692a1889ab | |||
| 825a4a77c0 | |||
| 882a3525af | |||
| b491b1f41f | |||
| db5d784ff7 | |||
| db57db4bd9 | |||
| c5e9b75261 | |||
| c59ac62e45 | |||
| 9c8b9b1a3b | |||
| 158b0254ed | |||
| 3526a177fc | |||
| e755f2856a | |||
| 2ec3ca8308 | |||
| 1cf75b48b5 | |||
| abde9ed162 | |||
| 998db1beed | |||
| 79e352d06e | |||
| b8f77433b9 | |||
| 96b967f6fb | |||
| 68c71e62d6 | |||
| 60a38b4108 | |||
| f4a1384dc4 | |||
| b88f0bab56 | |||
| f23907768f | |||
| f280b03cab | |||
| 554c0e565d | |||
| cfc5f7633b | |||
| 82050c7c01 | |||
| f4edb695de | |||
| dc2fa233ab | |||
| a1f31a14be | |||
| d27cc02e8c | |||
| fcc74ba212 | |||
| f48ad577d3 | |||
| 60c73f5635 | |||
| 24248215e9 | |||
| b6ec133368 | |||
| 35e567cfb6 | |||
| 25c697cca1 | |||
| 30dc695741 | |||
| 9e41d51702 | |||
| dc86514329 | |||
| c644781d18 | |||
| eedc0dd075 | |||
| c8c62b504b | |||
| 8467e218ae | |||
| e8f77ab2e5 | |||
| 1d2c927d94 | |||
| 81da73bc53 | |||
| 2dcbd8df99 | |||
| 37f5f50867 | |||
| f6a2a0540b | |||
| dc2e847f58 | |||
| e77fe0253e | |||
| 3d3787a8d9 | |||
| 11b323ef00 | |||
| df577ca7f5 | |||
| a2ba4d89f3 | |||
| 2ad55bf558 | |||
| cf08e470ef | |||
| 82e59d409e | |||
| 7d01e9e91e | |||
| 1e4acfe124 | |||
| 4f1653b086 | |||
| 6cd60a064b | |||
| 8072c4670c | |||
| 4ffa2e3993 | |||
| 9b230d23dd | |||
| baa7c9cc22 | |||
| 33a92cfad4 | |||
| e9f6295014 | |||
| 71078b5bdd | |||
| 6cce92af22 | |||
| d3b8520c41 | |||
| 5425085953 | |||
| db0298ac1b | |||
| 7bfca87caf | |||
| 5f87594ead | |||
| bb46481d7f | |||
| 1821d5d57b | |||
| 6ad15221de | |||
| 83d74da556 | |||
| 114228e8eb | |||
| a53f89c849 | |||
| 7ff79c3164 | |||
| db344749b6 | |||
| 1c6ece48a8 | |||
| b622285999 | |||
| 5fde0101bf | |||
| a6062d4c39 | |||
| 66f1e92cb6 | |||
| d9c4653f82 | |||
| cfdf8fdbca | |||
| 138b2bd357 | |||
| 16b14b1fe1 | |||
| c4ce718bb2 | |||
| 62d4b35c93 | |||
| 7407c032e5 | |||
| 9d03fdf31d | |||
| dfe5148f17 | |||
| 0d5b792c57 | |||
| 2279aa8f18 | |||
| d45686128e | |||
| 5b6ce5d8ee | |||
| aad34c4d52 | |||
| 470082cc65 | |||
| 6554f35710 | |||
| 335fe24a54 | |||
| 3831ef7b25 | |||
| 8127337a09 | |||
| 9a59c2e541 | |||
| 27adca5653 | |||
| 2c5b502da9 | |||
| 23f6397a97 | |||
| 43117825d7 | |||
| cc5701ea62 | |||
| 9524eafea1 | |||
| c28a0f96f7 | |||
| 301dee96dc | |||
| 185fc7b6ed | |||
| 6d194dbb71 | |||
| d34f4bdd12 | |||
| 17dc4bde5e | |||
| ce50b14591 | |||
| f7bd319954 | |||
| e9c0121a18 | |||
| 01aa425f81 | |||
| 38d5c7dff6 | |||
| e3b4b9b618 | |||
| a5951c58f3 | |||
| 504d6eaa9f | |||
| 6253fa30ef | |||
| 47f7cef4f4 | |||
| 72bba06e71 | |||
| 9b92c5ce38 | |||
| dfa077a1c9 | |||
| 18fb2e7d4d | |||
| a610fd53e2 | |||
| 16abce1f2d | |||
| f3b42f34a6 | |||
| 6483d324de | |||
| 5ab97050dd | |||
| 17eed70903 | |||
| 88067c03b7 | |||
| 7c1e5b913f | |||
| 0014235e91 | |||
| a39b7be1d1 | |||
| de98c5f706 | |||
| 10b496e845 | |||
| bbe7bf390d | |||
| 4777b3400a | |||
| acaa70e944 | |||
| 4049d694f7 | |||
| e155a3dacf | |||
| a224e4c4d8 | |||
| edaeda5424 | |||
| 09d974913d | |||
| f82edb290a | |||
| 3d8b33ae94 | |||
| 565ecbd436 | |||
| 3359dfcc29 | |||
| 1c2afd14dd | |||
| fe5343c1d6 | |||
| 08cfefc02a | |||
| f6d9332c48 | |||
| cc6913c854 | |||
| 8c75fbd0a4 | |||
| 0de6d62409 | |||
| 5ba7ce5b7c | |||
| e106d30852 | |||
| 30affc884b | |||
| 745717ea49 | |||
| 4efd98b758 | |||
| 36640e3710 | |||
| 311c4fd29d | |||
| f50374f983 | |||
| 82ceb7f021 | |||
| 0aba3bc1d8 | |||
| f6c984ff3c | |||
| 4091ab6b6c | |||
| fb9fd5b51a | |||
| 9389700a01 | |||
| 016c1b2233 | |||
| 38b8a08297 | |||
| c9ffd3ad99 | |||
| 61f960de28 | |||
| da1ff2cacc | |||
| 05036c682f | |||
| 7d47bc8042 | |||
| 98cfd160ef | |||
| b5e3262b67 | |||
| 009fb35c4c | |||
| 8648d3131a | |||
| 00c316c35d | |||
| 5f8de8e756 | |||
| ee5dc8fc41 | |||
| a61926988a | |||
| bd8c4dfb6b |
@@ -0,0 +1,2 @@
|
||||
[build]
|
||||
rustflags = ["-C", "target-cpu=native"]
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
# 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.29.0/cargo-dist-installer.sh | sh"
|
||||
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:
|
||||
@@ -217,8 +217,8 @@ jobs:
|
||||
- plan
|
||||
- build-local-artifacts
|
||||
- build-global-artifacts
|
||||
# Only run if we're "publishing", and only if local and global didn't fail (skipped is fine)
|
||||
if: ${{ always() && 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') }}
|
||||
# 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"
|
||||
|
||||
@@ -3,21 +3,26 @@
|
||||
|
||||
# Builds
|
||||
target
|
||||
dist
|
||||
vecid-to-indexes.js
|
||||
websites/dist
|
||||
bridge/
|
||||
/ids.txt
|
||||
rust_out
|
||||
|
||||
# Copies
|
||||
*\ copy*
|
||||
|
||||
# Ignored
|
||||
_*
|
||||
|
||||
# Editors
|
||||
.vscode
|
||||
.zed
|
||||
!__*.py
|
||||
/*.md
|
||||
/*.py
|
||||
/*.json
|
||||
/*.html
|
||||
/research
|
||||
/filter_*
|
||||
|
||||
# Logs
|
||||
.log
|
||||
*.log*
|
||||
|
||||
# Environment variables/configs
|
||||
.env
|
||||
@@ -28,4 +33,14 @@ flamegraph.svg
|
||||
*.trace
|
||||
|
||||
# AI
|
||||
CLAUDE.md
|
||||
.claude/settings*
|
||||
|
||||
# Expand
|
||||
expand.rs
|
||||
|
||||
# Benchmarks
|
||||
[0-9]/
|
||||
/benches
|
||||
|
||||
# AI
|
||||
.claude
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
<!--
|
||||
# v0.X.Y | WIP
|
||||

|
||||
-->
|
||||
|
||||
# v0.X.0 | WIP | A new beginning
|
||||
|
||||

|
||||
|
||||
Full rewrite
|
||||
|
||||
# [kibo-v0.5.0](https://github.com/bitcoinresearchkit/brk/tree/eea56d394bf92c62c81da8b78b8c47ea730683f5) | [873199](https://mempool.space/block/0000000000000000000270925aa6a565be92e13164565a3f7994ca1966e48050) - 2024/12/04
|
||||
|
||||

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

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

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

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

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

|
||||
|
||||
# satonomics-v0.0.1 | [835444](https://mempool.space/block/000000000000000000009f93907a0dd83c080d5585cc7ec82c076d45f6d7c872) - 2024/03/20
|
||||
|
||||

|
||||
@@ -4,57 +4,82 @@ 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.0.87"
|
||||
package.version = "0.1.0-alpha.4"
|
||||
package.homepage = "https://bitcoinresearchkit.org"
|
||||
package.repository = "https://github.com/bitcoinresearchkit/brk"
|
||||
package.readme = "README.md"
|
||||
|
||||
[profile.dev]
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
opt-level = 2
|
||||
split-debuginfo = "unpacked"
|
||||
|
||||
[profile.release]
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
overflow-checks = false
|
||||
|
||||
[profile.bloaty]
|
||||
debug = true
|
||||
lto = false
|
||||
strip = false
|
||||
inherits = "release"
|
||||
|
||||
[profile.dist]
|
||||
inherits = "release"
|
||||
|
||||
[profile.profiling]
|
||||
inherits = "release"
|
||||
debug = true
|
||||
|
||||
[profile.dist]
|
||||
inherits = "release"
|
||||
|
||||
[workspace.dependencies]
|
||||
axum = "0.8.4"
|
||||
bitcoin = { version = "0.32.7", features = ["serde"] }
|
||||
aide = { version = "0.16.0-alpha.2", features = ["axum-json", "axum-query"] }
|
||||
axum = { version = "0.8.8", default-features = false, features = ["http1", "json", "query", "tokio", "tracing"] }
|
||||
bitcoin = { version = "0.32.8", features = ["serde"] }
|
||||
bitcoincore-rpc = "0.19.0"
|
||||
brk_bundler = { version = "0.0.87", path = "crates/brk_bundler" }
|
||||
brk_cli = { version = "0.0.87", path = "crates/brk_cli" }
|
||||
brk_computer = { version = "0.0.87", path = "crates/brk_computer" }
|
||||
brk_structs = { version = "0.0.87", path = "crates/brk_structs" }
|
||||
brk_error = { version = "0.0.87", path = "crates/brk_error" }
|
||||
brk_fetcher = { version = "0.0.87", path = "crates/brk_fetcher" }
|
||||
brk_indexer = { version = "0.0.87", path = "crates/brk_indexer" }
|
||||
brk_interface = { version = "0.0.87", path = "crates/brk_interface" }
|
||||
brk_logger = { version = "0.0.87", path = "crates/brk_logger" }
|
||||
brk_mcp = { version = "0.0.87", path = "crates/brk_mcp" }
|
||||
brk_parser = { version = "0.0.87", path = "crates/brk_parser" }
|
||||
brk_server = { version = "0.0.87", path = "crates/brk_server" }
|
||||
brk_store = { version = "0.0.87", path = "crates/brk_store" }
|
||||
vecdb = { version = "0.1.0", features = ["derive"]}
|
||||
# vecdb = { path = "../seqdb/crates/vecdb", features = ["derive"]}
|
||||
byteview = "=0.6.1"
|
||||
derive_deref = "1.1.1"
|
||||
fjall = "2.11.2"
|
||||
jiff = "0.2.15"
|
||||
log = "0.4.27"
|
||||
minreq = { version = "2.14.0", features = ["https", "serde_json"] }
|
||||
parking_lot = "0.12.4"
|
||||
rayon = "1.10.0"
|
||||
serde = "1.0.219"
|
||||
serde_bytes = "0.11.17"
|
||||
serde_derive = "1.0.219"
|
||||
serde_json = { version = "1.0.142", features = ["float_roundtrip"] }
|
||||
tokio = { version = "1.47.1", features = ["rt-multi-thread"] }
|
||||
zerocopy = "0.8.26"
|
||||
zerocopy-derive = "0.8.26"
|
||||
brk_alloc = { version = "0.1.0-alpha.4", path = "crates/brk_alloc" }
|
||||
brk_bencher = { version = "0.1.0-alpha.4", path = "crates/brk_bencher" }
|
||||
brk_bindgen = { version = "0.1.0-alpha.4", path = "crates/brk_bindgen" }
|
||||
brk_cli = { version = "0.1.0-alpha.4", path = "crates/brk_cli" }
|
||||
brk_client = { version = "0.1.0-alpha.4", path = "crates/brk_client" }
|
||||
brk_cohort = { version = "0.1.0-alpha.4", path = "crates/brk_cohort" }
|
||||
brk_computer = { version = "0.1.0-alpha.4", path = "crates/brk_computer" }
|
||||
brk_error = { version = "0.1.0-alpha.4", path = "crates/brk_error" }
|
||||
brk_fetcher = { version = "0.1.0-alpha.4", path = "crates/brk_fetcher" }
|
||||
brk_indexer = { version = "0.1.0-alpha.4", path = "crates/brk_indexer" }
|
||||
brk_iterator = { version = "0.1.0-alpha.4", path = "crates/brk_iterator" }
|
||||
brk_logger = { version = "0.1.0-alpha.4", path = "crates/brk_logger" }
|
||||
brk_mempool = { version = "0.1.0-alpha.4", path = "crates/brk_mempool" }
|
||||
brk_query = { version = "0.1.0-alpha.4", path = "crates/brk_query", features = ["tokio"] }
|
||||
brk_reader = { version = "0.1.0-alpha.4", path = "crates/brk_reader" }
|
||||
brk_rpc = { version = "0.1.0-alpha.4", path = "crates/brk_rpc" }
|
||||
brk_server = { version = "0.1.0-alpha.4", path = "crates/brk_server" }
|
||||
brk_store = { version = "0.1.0-alpha.4", path = "crates/brk_store" }
|
||||
brk_traversable = { version = "0.1.0-alpha.4", path = "crates/brk_traversable", features = ["pco", "derive"] }
|
||||
brk_traversable_derive = { version = "0.1.0-alpha.4", path = "crates/brk_traversable_derive" }
|
||||
brk_types = { version = "0.1.0-alpha.4", path = "crates/brk_types" }
|
||||
byteview = "0.10.0"
|
||||
color-eyre = "0.6.5"
|
||||
derive_more = { version = "2.1.1", features = ["deref", "deref_mut"] }
|
||||
fjall = "3.0.1"
|
||||
jiff = { version = "0.2.18", features = ["perf-inline", "tz-system"], default-features = false }
|
||||
minreq = { version = "2.14.1", features = ["https", "serde_json"] }
|
||||
parking_lot = "0.12.5"
|
||||
rayon = "1.11.0"
|
||||
rustc-hash = "2.1.1"
|
||||
schemars = "1.2.0"
|
||||
serde = "1.0.228"
|
||||
serde_bytes = "0.11.19"
|
||||
serde_derive = "1.0.228"
|
||||
serde_json = { version = "1.0.149", features = ["float_roundtrip", "preserve_order"] }
|
||||
smallvec = "1.15.1"
|
||||
tokio = { version = "1.49.0", features = ["rt-multi-thread"] }
|
||||
tracing = { version = "0.1", default-features = false, features = ["std"] }
|
||||
vecdb = { version = "0.5.11", features = ["derive", "serde_json", "pco", "schemars"] }
|
||||
# vecdb = { path = "../anydb/crates/vecdb", features = ["derive", "serde_json", "pco", "schemars"] }
|
||||
|
||||
[workspace.metadata.release]
|
||||
shared-version = true
|
||||
@@ -63,7 +88,8 @@ pre-release-commit-message = "release: v{{version}}"
|
||||
tag-message = "release: v{{version}}"
|
||||
|
||||
[workspace.metadata.dist]
|
||||
cargo-dist-version = "0.29.0"
|
||||
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"]
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
# Bitcoin Research Kit
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/bitcoinresearchkit/brk">
|
||||
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/bitcoinresearchkit/brk?style=social">
|
||||
</a>
|
||||
<a href="https://github.com/bitcoinresearchkit/brk/blob/main/LICENSE.md">
|
||||
<img src="https://img.shields.io/crates/l/brk" alt="License" />
|
||||
</a>
|
||||
<a href="https://crates.io/crates/brk">
|
||||
<img src="https://img.shields.io/crates/v/brk" alt="Version" />
|
||||
</a>
|
||||
<a href="https://docs.rs/brk">
|
||||
<img src="https://img.shields.io/docsrs/brk" alt="Documentation" />
|
||||
</a>
|
||||
<img src="https://img.shields.io/crates/size/brk" alt="Size" />
|
||||
<a href="https://deps.rs/crate/brk">
|
||||
<img src="https://deps.rs/crate/brk/latest/status.svg" alt="Dependency status">
|
||||
</a>
|
||||
<a href="https://discord.gg/HaR3wpH3nr">
|
||||
<img src="https://img.shields.io/discord/1350431684562124850?label=discord" alt="Discord" />
|
||||
</a>
|
||||
<a href="https://primal.net/p/nprofile1qqsfw5dacngjlahye34krvgz7u0yghhjgk7gxzl5ptm9v6n2y3sn03sqxu2e6">
|
||||
<img src="https://img.shields.io/badge/nostr-purple?link=https%3A%2F%2Fprimal.net%2Fp%2Fnprofile1qqsfw5dacngjlahye34krvgz7u0yghhjgk7gxzl5ptm9v6n2y3sn03sqxu2e6" alt="Nostr" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
The Bitcoin Research Kit is a high-performance toolchain designed to parse, index, compute, serve and visualize data from a Bitcoin node, enabling users to gain deeper insights into the Bitcoin network.
|
||||
|
||||
In other words it's an alternative to [Glassnode](https://glassnode.com), [mempool.space](https://mempool.space/) (soon) and [electrs](https://github.com/romanz/electrs) (soon) all in one package with a particular focus on simplicity and ease of use.
|
||||
|
||||
The toolkit can be used in various ways to accommodate as many needs as possible:
|
||||
|
||||
- **[Website](https://bitcoinresearchkit.org)** \
|
||||
Everyone is welcome to visit the official instance and showcase of the suite's capabilities. \
|
||||
It has a wide range of functionalities including charts, tables and simulations which you can visit for free and without the need for an account. \
|
||||
Also available at: [brekit.org](https://brekit.org) // [kibo.money](https://kibo.money) // [satonomics.xyz](https://satonomics.xyz)
|
||||
- **[API](https://github.com/bitcoinresearchkit/brk/tree/main/crates/brk_server#brk-server)** \
|
||||
Researchers and developers are free to use BRK's public API with  dataset variants at their disposal. \
|
||||
Just like the website, it's entirely free, with no authentication or rate-limiting.
|
||||
- **[AI](https://github.com/bitcoinresearchkit/brk/blob/main/crates/brk_mcp/README.md#brk-mcp)** \
|
||||
LLMs have to possibility to connect to BRK's backend through a [MCP](https://modelcontextprotocol.io/introduction). \
|
||||
It will give them access to the same tools as the API, with no restrictions, and allow you to have your very own data analysts.
|
||||
- **[CLI](https://crates.io/crates/brk_cli)** \
|
||||
Node runners are strongly encouraged to try out and self-host their own instance using BRK's command line interface. \
|
||||
The CLI has multiple cogs available for users to tweak to adapt to all situations with even the possibility for web developers to create their own custom website which could later on be added as an alternative front-end.
|
||||
- **[Crates](https://crates.io/crates/brk)** \
|
||||
Rust developers have access to a wide range crates, each built upon one another with its own specific purpose, enabling independent use and offering great flexibility.
|
||||
PRs are welcome, especially if their goal is to introduce additional datasets.
|
||||
|
||||
The primary goal of this project is to be fully-featured and accessible for everyone, regardless of their background or financial situation - whether that person is an enthusiast, researcher, miner, analyst, or simply curious.
|
||||
|
||||
In contrast, existing alternatives tend to be either [very costly](https://studio.glassnode.com/pricing) or missing essential features, with the vast majority being closed-source and unverifiable, which fundamentally undermines the principles of Bitcoin.
|
||||
|
||||
## Crates
|
||||
|
||||
- [`brk`](https://crates.io/crates/brk): A wrapper around all other `brk-*` crates
|
||||
- [`brk_bundler`](https://crates.io/crates/brk_bundler): A thin wrapper around [`rolldown`](https://rolldown.rs/)
|
||||
- [`brk_cli`](https://crates.io/crates/brk_cli): A command line interface to run a BRK instance
|
||||
- [`brk_computer`](https://crates.io/crates/brk_computer): A Bitcoin dataset computer built on top of [`brk_indexer`](https://crates.io/crates/brk_indexer)
|
||||
- [`brk_error`](https://crates.io/crates/brk_error): Errors used throughout BRK
|
||||
- [`brk_fetcher`](https://crates.io/crates/brk_fetcher): A Bitcoin price fetcher
|
||||
- [`brk_indexer`](https://crates.io/crates/brk_indexer): A Bitcoin indexer built on top of [`brk_parser`](https://crates.io/crates/brk_parser)
|
||||
- [`brk_interface`](https://crates.io/crates/brk_interface): An interface to find and format data from BRK
|
||||
- [`brk_logger`](https://crates.io/crates/brk_logger): A thin wrapper around [`env_logger`](https://crates.io/crates/env_logger)
|
||||
- [`brk_mcp`](https://crates.io/crates/brk_mcp): A bridge for LLMs to access BRK
|
||||
- [`brk_parser`](https://crates.io/crates/brk_parser): A very fast Bitcoin block parser and iterator built on top of [`bitcoin-rust`](https://crates.io/crates/bitcoin)
|
||||
- [`brk_server`](https://crates.io/crates/brk_server): A server with an API for anything from BRK
|
||||
- [`brk_store`](https://crates.io/crates/brk_store): A thin wrapper around [`fjall`](https://crates.io/crates/fjall)
|
||||
- [`brk_structs`](https://crates.io/crates/brk_structs): Structs used throughout BRK
|
||||
- [`vecdb`](https://crates.io/crates/vecdb): A KISS index-value storage engine
|
||||
- [`vecdb_macros`](https://crates.io/crates/vecdb_macros): Macros for [`vecdb`](https://crates.io/crates/vecdb)
|
||||
|
||||
## Hosting as a service
|
||||
|
||||
If you'd like to have your own instance hosted for you please contact [hosting@bitcoinresearchkit.org](mailto:hosting@bitcoinresearchkit.org).
|
||||
|
||||
- 2 separate dedicated servers (1 GB/s each) with different ISPs and Cloudflare integration for enhanced performance and optimal availability
|
||||
- 99.99% SLA
|
||||
- Configured for speed
|
||||
- Updates delivered at your convenience
|
||||
- Direct communication for feature requests and support
|
||||
- Bitcoin Core or Knots with desired version
|
||||
- Optional subdomains: `*.bitcoinresearchkit.org`, `*.kibo.money` and `*.satonomics.xyz`
|
||||
- Logo featured in the Readme if desired
|
||||
|
||||
Pricing: `0.01 BTC / month` *or* `0.1 BTC / year`
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Deepest gratitude to the [Open Sats](https://opensats.org/) public charity. Their grant — from December 2024 to the present — has been critical in sustaining this project.
|
||||
|
||||
Heartfelt thanks go out to every donor on [Nostr](https://primal.net/p/npub1jagmm3x39lmwfnrtvxcs9ac7g300y3dusv9lgzhk2e4x5frpxlrqa73v44) and [Geyser.fund](https://geyser.fund/project/brk) whose support has ensured the availability of the [bitcoinresearchkit.org](https://bitcoinresearchkit.org) public instance.
|
||||
|
||||
## Donate
|
||||
|
||||
[`bc1q09 8zsm89 m7kgyz e338vf ejhpdt 92ua9p 3peuve`](bitcoin:bc1q098zsm89m7kgyze338vfejhpdt92ua9p3peuve)
|
||||
|
||||
[`lnurl1dp68gurn8ghj7ampd3kx2ar0veekzar0wd5xjtnrdakj7tnhv4kxctttdehhwm30d3h82unvwqhkxmmww3jkuar8d35kgetj8yuq363hv4`](lightning:lnurl1dp68gurn8ghj7ampd3kx2ar0veekzar0wd5xjtnrdakj7tnhv4kxctttdehhwm30d3h82unvwqhkxmmww3jkuar8d35kgetj8yuq363hv4)
|
||||
|
||||
[Geyser Fund](https://geyser.fund/project/brk)
|
||||
@@ -1,99 +0,0 @@
|
||||
# TODO
|
||||
|
||||
- __crates__
|
||||
- _cli_
|
||||
- check disk space on first launch
|
||||
- add custom path support for config.toml
|
||||
- maybe add bitcoind download and launch support
|
||||
- via: https://github.com/rust-bitcoin/corepc/blob/master/node
|
||||
- test read/write speed, add warning if too low (<2gb/s)
|
||||
- pull latest version and notify is out of date
|
||||
- _computer_
|
||||
- **add rollback of states (in stateful)**
|
||||
- remove configurable format (raw/compressed) and chose sane ones instead
|
||||
- linear reads: compressed (height/date/... + txindex_to_height + txindex_to_version + ...)
|
||||
- random reads: raw (outputindex_to_value + ...)
|
||||
- add prices paid by percentile (percentile cost basis) back
|
||||
- add support for per index computation
|
||||
- fix min feerate which is always ZERO due to coinbase transaction
|
||||
- before computing multiple sources check their length, panic if not equal
|
||||
- add oracle price dataset (https://utxo.live/oracle/UTXOracle.py)
|
||||
- add address counts relative to all datasets
|
||||
- make decade, quarter, year datasets `computed` instead of `eager`
|
||||
- add 6 months (semester) interval datasets to builder
|
||||
- some datasets in `indexes` can probably be removed
|
||||
- add revived/sent supply datasets
|
||||
- add `in-sats` version of all price datasets (average and co)
|
||||
- add `p2pk` group (sum of `p2pk33` and `p2pk65`)
|
||||
- add chopiness datasets
|
||||
- add utxo count, address count, supply data for by reused addresses in groups by address type
|
||||
- add more date ranges (3-6 months and more)
|
||||
- add puell multiple dataset
|
||||
- add pi cycle dataset
|
||||
- add emas of price
|
||||
- add 7d and 30d ema to sell side risk ratio and sopr
|
||||
- don't compute everything for all cohorts as some datasets combinations are irrelevant
|
||||
- addresses/utxos by amount don't need mvrvz for example
|
||||
- add all possible charts from:
|
||||
- https://mainnet.observer
|
||||
- https://glassnode.com
|
||||
- https://checkonchain.com
|
||||
- https://researchbitcoin.net/exciting-update-coming-to-the-bitcoin-lab/
|
||||
- https://mempool.space/research
|
||||
- _indexer_
|
||||
- parse only the needed block number
|
||||
- maybe using https://developer.bitcoin.org/reference/rpc/getblockhash.html
|
||||
- _interface_
|
||||
- create pagination enum
|
||||
- from to
|
||||
- from option<count>
|
||||
- to option<count>
|
||||
- page + option<per page> default 1000 max 1000
|
||||
- from/to/count params don’t cap all combinations
|
||||
- example: from -10,000 count 10, won’t work if underlying vec isn’t 10k or more long
|
||||
- _parser_
|
||||
- save `vec` file instead of `json`
|
||||
- support lock file, process in read only if already opened in write mode
|
||||
- if less than X (10 maybe ?) get block using rpc instead of parsing the block files
|
||||
- _server_
|
||||
- api
|
||||
- add extensions support (.json .csv …)
|
||||
- if format instead of extension then don't download file
|
||||
- add support for https (rustls)
|
||||
- __docs__
|
||||
- _README_
|
||||
- add a comparison table with alternatives
|
||||
- add contribution section where help is needed
|
||||
- documentation/mcp/datasets/different front ends
|
||||
- add faq
|
||||
- __websites__
|
||||
- _default_
|
||||
- explorer
|
||||
- blocks
|
||||
- transactions
|
||||
- addresses
|
||||
- miners
|
||||
- maybe xpubs
|
||||
- charts
|
||||
- improve some names and colors
|
||||
- remove `sum` series when it's a duplicate of the `base` (in subsidy for example)
|
||||
- selected unit sometimes changes when going back end forth
|
||||
- add support for custom charts
|
||||
- separate z-score charts from "realized price" (with their own prices), have 4y, 2y and 1y
|
||||
- price scale format depends on unit, hide digits for sats for example (if/when possible)
|
||||
- table
|
||||
- pagination
|
||||
- exports (.json, .csv,…)
|
||||
- search
|
||||
- datasets add legend, and keywords ?
|
||||
- height/address/txid
|
||||
- api
|
||||
- add api page with interactivity
|
||||
- global
|
||||
- **fix navigation/history**
|
||||
- move share button to footer ?
|
||||
- Use `ichart.createPane()` in wrapper
|
||||
- improve behavior when local storage is unavailable
|
||||
- by having a global state
|
||||
- __global__
|
||||
- check `TODO`s in codebase
|
||||
|
Before Width: | Height: | Size: 340 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 263 KiB |
|
Before Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 386 KiB |
|
Before Width: | Height: | Size: 496 KiB |
|
Before Width: | Height: | Size: 564 KiB |
|
Before Width: | Height: | Size: 592 KiB |
|
Before Width: | Height: | Size: 453 KiB |
|
Before Width: | Height: | Size: 526 KiB |
@@ -2,58 +2,70 @@
|
||||
name = "brk"
|
||||
description.workspace = true
|
||||
license.workspace = true
|
||||
readme.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
build = "build.rs"
|
||||
|
||||
[features]
|
||||
full = [
|
||||
"bundler",
|
||||
"bencher",
|
||||
"bindgen",
|
||||
"client",
|
||||
"computer",
|
||||
"error",
|
||||
"fetcher",
|
||||
"cohort",
|
||||
"indexer",
|
||||
"interface",
|
||||
"iterator",
|
||||
"logger",
|
||||
"mcp",
|
||||
"parser",
|
||||
"mempool",
|
||||
"query",
|
||||
"reader",
|
||||
"rpc",
|
||||
"server",
|
||||
"store",
|
||||
"structs",
|
||||
"vecs",
|
||||
"traversable",
|
||||
"types",
|
||||
]
|
||||
bundler = ["brk_bundler"]
|
||||
bencher = ["brk_bencher"]
|
||||
bindgen = ["brk_bindgen"]
|
||||
client = ["brk_client"]
|
||||
computer = ["brk_computer"]
|
||||
error = ["brk_error"]
|
||||
fetcher = ["brk_fetcher"]
|
||||
cohort = ["brk_cohort"]
|
||||
indexer = ["brk_indexer"]
|
||||
interface = ["brk_interface"]
|
||||
iterator = ["brk_iterator"]
|
||||
logger = ["brk_logger"]
|
||||
mcp = ["brk_mcp"]
|
||||
parser = ["brk_parser"]
|
||||
mempool = ["brk_mempool"]
|
||||
query = ["brk_query"]
|
||||
reader = ["brk_reader"]
|
||||
rpc = ["brk_rpc"]
|
||||
server = ["brk_server"]
|
||||
store = ["brk_store"]
|
||||
structs = ["brk_structs"]
|
||||
vecs = ["vecdb"]
|
||||
traversable = ["brk_traversable"]
|
||||
types = ["brk_types"]
|
||||
|
||||
[dependencies]
|
||||
brk_bundler = { workspace = true, optional = true }
|
||||
brk_cli = { workspace = true }
|
||||
brk_bencher = { workspace = true, optional = true }
|
||||
brk_bindgen = { workspace = true, optional = true }
|
||||
brk_client = { workspace = true, optional = true }
|
||||
brk_computer = { workspace = true, optional = true }
|
||||
brk_error = { workspace = true, optional = true }
|
||||
brk_fetcher = { workspace = true, optional = true }
|
||||
brk_cohort = { workspace = true, optional = true }
|
||||
brk_indexer = { workspace = true, optional = true }
|
||||
brk_interface = { workspace = true, optional = true }
|
||||
brk_iterator = { workspace = true, optional = true }
|
||||
brk_logger = { workspace = true, optional = true }
|
||||
brk_mcp = { workspace = true, optional = true }
|
||||
brk_parser = { workspace = true, optional = true }
|
||||
brk_mempool = { workspace = true, optional = true }
|
||||
brk_query = { workspace = true, optional = true }
|
||||
brk_reader = { workspace = true, optional = true }
|
||||
brk_rpc = { workspace = true, optional = true }
|
||||
brk_server = { workspace = true, optional = true }
|
||||
brk_store = { workspace = true, optional = true }
|
||||
brk_structs = { workspace = true, optional = true }
|
||||
vecdb = { workspace = true, optional = true }
|
||||
brk_traversable = { workspace = true, optional = true }
|
||||
brk_types = { workspace = true, optional = true }
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# brk
|
||||
|
||||
Umbrella crate for the Bitcoin Research Kit.
|
||||
|
||||
[crates.io](https://crates.io/crates/brk) | [docs.rs](https://docs.rs/brk)
|
||||
|
||||
## Usage
|
||||
|
||||
Single dependency to access any BRK component. Enable only what you need via feature flags.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
brk = { version = "0.1", features = ["query", "types"] }
|
||||
```
|
||||
|
||||
```rust,ignore
|
||||
use brk::query::Query;
|
||||
use brk::types::Height;
|
||||
```
|
||||
|
||||
Feature flags match crate names without the `brk_` prefix. Use `full` to enable all.
|
||||
|
||||
## 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_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");
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
fn main() {}
|
||||
@@ -1,15 +1,20 @@
|
||||
#![doc = include_str!(concat!("../", env!("CARGO_PKG_README")))]
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
#[cfg(feature = "bundler")]
|
||||
#[cfg(feature = "bencher")]
|
||||
#[doc(inline)]
|
||||
pub use brk_bundler as bundler;
|
||||
pub use brk_bencher as bencher;
|
||||
|
||||
#[cfg(feature = "bindgen")]
|
||||
#[doc(inline)]
|
||||
pub use brk_cli as cli;
|
||||
pub use brk_bindgen as bindgen;
|
||||
|
||||
#[cfg(feature = "structs")]
|
||||
#[cfg(feature = "client")]
|
||||
#[doc(inline)]
|
||||
pub use brk_structs as structs;
|
||||
pub use brk_client as client;
|
||||
|
||||
#[cfg(feature = "cohort")]
|
||||
#[doc(inline)]
|
||||
pub use brk_cohort as cohort;
|
||||
|
||||
#[cfg(feature = "computer")]
|
||||
#[doc(inline)]
|
||||
@@ -27,21 +32,29 @@ pub use brk_fetcher as fetcher;
|
||||
#[doc(inline)]
|
||||
pub use brk_indexer as indexer;
|
||||
|
||||
#[cfg(feature = "iterator")]
|
||||
#[doc(inline)]
|
||||
pub use brk_iterator as iterator;
|
||||
|
||||
#[cfg(feature = "logger")]
|
||||
#[doc(inline)]
|
||||
pub use brk_logger as logger;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
#[cfg(feature = "mempool")]
|
||||
#[doc(inline)]
|
||||
pub use brk_mcp as mcp;
|
||||
pub use brk_mempool as mempool;
|
||||
|
||||
#[cfg(feature = "parser")]
|
||||
#[cfg(feature = "query")]
|
||||
#[doc(inline)]
|
||||
pub use brk_parser as parser;
|
||||
pub use brk_query as query;
|
||||
|
||||
#[cfg(feature = "interface")]
|
||||
#[cfg(feature = "reader")]
|
||||
#[doc(inline)]
|
||||
pub use brk_interface as interface;
|
||||
pub use brk_reader as reader;
|
||||
|
||||
#[cfg(feature = "rpc")]
|
||||
#[doc(inline)]
|
||||
pub use brk_rpc as rpc;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[doc(inline)]
|
||||
@@ -51,6 +64,10 @@ pub use brk_server as server;
|
||||
#[doc(inline)]
|
||||
pub use brk_store as store;
|
||||
|
||||
#[cfg(feature = "vecs")]
|
||||
#[cfg(feature = "traversable")]
|
||||
#[doc(inline)]
|
||||
pub use vecdb as vecs;
|
||||
pub use brk_traversable as traversable;
|
||||
|
||||
#[cfg(feature = "types")]
|
||||
#[doc(inline)]
|
||||
pub use brk_types as types;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
use brk_cli::main;
|
||||
@@ -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.44", features = ["extended"] }
|
||||
mimalloc = { version = "0.1.48", features = ["v3"] }
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Global allocator and memory utilities for brk.
|
||||
//!
|
||||
//! This crate sets mimalloc as the global allocator and provides
|
||||
//! utilities for monitoring and managing memory.
|
||||
|
||||
use mimalloc::MiMalloc as Allocator;
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: Allocator = Allocator;
|
||||
|
||||
/// Mimalloc allocator utilities
|
||||
pub struct Mimalloc;
|
||||
|
||||
impl Mimalloc {
|
||||
/// Eagerly free memory back to OS.
|
||||
/// Only call at natural pause points.
|
||||
#[inline]
|
||||
pub fn collect() {
|
||||
unsafe { libmimalloc_sys::mi_collect(true) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "brk_bencher"
|
||||
description = "A simple benchmarker for testing other crates."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
brk_error = { workspace = true }
|
||||
brk_logger = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libproc = "0.14"
|
||||
@@ -0,0 +1,43 @@
|
||||
# brk_bencher
|
||||
|
||||
Resource monitoring for long-running Bitcoin indexing operations.
|
||||
|
||||
## What It Enables
|
||||
|
||||
Track disk usage, memory consumption (current + peak), and I/O throughput during indexing runs. Progress tracking hooks into brk_logger to record processing milestones automatically.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Multi-metric monitoring**: Disk, memory (RSS + peak), I/O read/write
|
||||
- **Progress tracking**: Integrates with logging to capture block heights as they're processed
|
||||
- **Run comparison**: Outputs timestamped CSVs for comparing multiple runs
|
||||
- **macOS optimized**: Uses libproc for accurate process metrics on macOS
|
||||
- **Non-blocking**: Monitors in background thread with 5-second sample interval
|
||||
|
||||
## Core API
|
||||
|
||||
```rust,ignore
|
||||
let mut bencher = Bencher::from_cargo_env("brk_indexer", &data_path)?;
|
||||
bencher.start()?;
|
||||
|
||||
// ... run indexing ...
|
||||
|
||||
bencher.stop()?;
|
||||
```
|
||||
|
||||
## Output Structure
|
||||
|
||||
```
|
||||
benches/
|
||||
└── brk_indexer/
|
||||
└── 1703001234/
|
||||
├── disk.csv # timestamp_ms, bytes
|
||||
├── memory.csv # timestamp_ms, current, peak
|
||||
├── io.csv # timestamp_ms, read, written
|
||||
└── progress.csv # timestamp_ms, height
|
||||
```
|
||||
|
||||
## Built On
|
||||
|
||||
- `brk_error` for error handling
|
||||
- `brk_logger` for progress hook integration
|
||||
@@ -0,0 +1,66 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::{self, File},
|
||||
io::{self, Write},
|
||||
os::unix::fs::MetadataExt,
|
||||
path::{Path, PathBuf},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
pub struct DiskMonitor {
|
||||
cache: HashMap<PathBuf, (u64, SystemTime)>, // path -> (bytes_used, mtime)
|
||||
monitored_path: PathBuf,
|
||||
writer: File,
|
||||
}
|
||||
|
||||
impl DiskMonitor {
|
||||
pub fn new(monitored_path: &Path, csv_path: &Path) -> io::Result<Self> {
|
||||
let mut writer = File::create(csv_path)?;
|
||||
writeln!(writer, "timestamp_ms,disk_usage")?;
|
||||
|
||||
Ok(Self {
|
||||
cache: HashMap::new(),
|
||||
monitored_path: monitored_path.to_path_buf(),
|
||||
writer,
|
||||
})
|
||||
}
|
||||
|
||||
/// Record disk usage at the given timestamp
|
||||
pub fn record(&mut self, elapsed_ms: u128) -> io::Result<()> {
|
||||
if let Ok(bytes) = self.scan_recursive(&self.monitored_path.clone()) {
|
||||
writeln!(self.writer, "{},{}", elapsed_ms, bytes)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scan_recursive(&mut self, path: &Path) -> io::Result<u64> {
|
||||
let mut total = 0;
|
||||
|
||||
for entry in fs::read_dir(path)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let metadata = entry.metadata()?;
|
||||
|
||||
if metadata.is_file() {
|
||||
let mtime = metadata.modified()?;
|
||||
|
||||
// Check cache: if mtime unchanged, use cached value
|
||||
if let Some((cached_bytes, cached_mtime)) = self.cache.get(&path)
|
||||
&& *cached_mtime == mtime
|
||||
{
|
||||
total += cached_bytes;
|
||||
continue;
|
||||
}
|
||||
|
||||
// File is new or modified - get actual disk usage
|
||||
let bytes = metadata.blocks() * 512;
|
||||
self.cache.insert(path, (bytes, mtime));
|
||||
total += bytes;
|
||||
} else if metadata.is_dir() {
|
||||
total += self.scan_recursive(&path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(total)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{self, Write},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::fs;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use libproc::pid_rusage::{pidrusage, RUsageInfoV2};
|
||||
|
||||
pub struct IoMonitor {
|
||||
pid: u32,
|
||||
writer: File,
|
||||
}
|
||||
|
||||
impl IoMonitor {
|
||||
pub fn new(pid: u32, csv_path: &Path) -> io::Result<Self> {
|
||||
let mut writer = File::create(csv_path)?;
|
||||
writeln!(writer, "timestamp_ms,bytes_read,bytes_written")?;
|
||||
|
||||
Ok(Self { pid, writer })
|
||||
}
|
||||
|
||||
/// Record I/O usage at the given timestamp
|
||||
pub fn record(&mut self, elapsed_ms: u128) -> io::Result<()> {
|
||||
if let Ok((read, written)) = self.get_io_usage() {
|
||||
writeln!(self.writer, "{},{},{}", elapsed_ms, read, written)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get I/O usage in bytes
|
||||
/// Returns (bytes_read, bytes_written)
|
||||
fn get_io_usage(&self) -> io::Result<(u64, u64)> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
self.get_io_usage_linux()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.get_io_usage_macos()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn get_io_usage_linux(&self) -> io::Result<(u64, u64)> {
|
||||
let io_content = fs::read_to_string(format!("/proc/{}/io", self.pid))?;
|
||||
|
||||
let mut read_bytes = None;
|
||||
let mut write_bytes = None;
|
||||
|
||||
for line in io_content.lines() {
|
||||
if line.starts_with("read_bytes:") {
|
||||
if let Some(value_str) = line.split_whitespace().nth(1) {
|
||||
read_bytes = value_str.parse::<u64>().ok();
|
||||
}
|
||||
} else if line.starts_with("write_bytes:") {
|
||||
if let Some(value_str) = line.split_whitespace().nth(1) {
|
||||
write_bytes = value_str.parse::<u64>().ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (read_bytes, write_bytes) {
|
||||
(Some(r), Some(w)) => Ok((r, w)),
|
||||
_ => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Failed to parse I/O stats from /proc/[pid]/io",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn get_io_usage_macos(&self) -> io::Result<(u64, u64)> {
|
||||
match pidrusage::<RUsageInfoV2>(self.pid as i32) {
|
||||
Ok(info) => Ok((info.ri_diskio_bytesread, info.ri_diskio_byteswritten)),
|
||||
Err(_) => Err(io::Error::other("Failed to get process I/O stats")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use brk_error::{Error, Result};
|
||||
|
||||
mod disk;
|
||||
mod io;
|
||||
mod memory;
|
||||
mod progression;
|
||||
|
||||
use disk::*;
|
||||
use io::*;
|
||||
use memory::*;
|
||||
use parking_lot::Mutex;
|
||||
use progression::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bencher(Arc<BencherInner>);
|
||||
|
||||
struct BencherInner {
|
||||
bench_dir: PathBuf,
|
||||
monitored_path: PathBuf,
|
||||
stop_flag: Arc<AtomicBool>,
|
||||
monitor_thread: Mutex<Option<JoinHandle<Result<()>>>>,
|
||||
progression: Arc<ProgressionMonitor>,
|
||||
}
|
||||
|
||||
impl Bencher {
|
||||
/// Create a new bencher for the given crate name
|
||||
/// Creates directory structure: workspace_root/benches/{crate_name}/{timestamp}/
|
||||
pub fn new(crate_name: &str, workspace_root: &Path, monitored_path: &Path) -> Result<Self> {
|
||||
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||||
|
||||
let bench_dir = workspace_root
|
||||
.join("benches")
|
||||
.join(crate_name)
|
||||
.join(timestamp.to_string());
|
||||
|
||||
fs::create_dir_all(&bench_dir)?;
|
||||
|
||||
let progress_csv = bench_dir.join("progress.csv");
|
||||
let progression = Arc::new(ProgressionMonitor::new(&progress_csv)?);
|
||||
let progression_clone = progression.clone();
|
||||
|
||||
// Register hook with logger
|
||||
brk_logger::register_hook(move |message| {
|
||||
progression_clone.check_and_record(message);
|
||||
})
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::AlreadyExists, e))?;
|
||||
|
||||
Ok(Self(Arc::new(BencherInner {
|
||||
bench_dir,
|
||||
monitored_path: monitored_path.to_path_buf(),
|
||||
stop_flag: Arc::new(AtomicBool::new(false)),
|
||||
progression,
|
||||
monitor_thread: Mutex::new(None),
|
||||
})))
|
||||
}
|
||||
|
||||
/// Create a bencher using CARGO_MANIFEST_DIR to find workspace root
|
||||
pub fn from_cargo_env(crate_name: &str, monitored_path: &Path) -> Result<Self> {
|
||||
let mut current = std::env::current_dir()
|
||||
.map_err(|e| format!("Failed to get current directory: {}", e))
|
||||
.unwrap();
|
||||
|
||||
let workspace_root = loop {
|
||||
let cargo_toml = current.join("Cargo.toml");
|
||||
if cargo_toml.exists() {
|
||||
let contents = std::fs::read_to_string(&cargo_toml)
|
||||
.map_err(|e| format!("Failed to read Cargo.toml: {}", e))
|
||||
.unwrap();
|
||||
if contents.contains("[workspace]") {
|
||||
break current;
|
||||
}
|
||||
}
|
||||
|
||||
current = current
|
||||
.parent()
|
||||
.ok_or(Error::NotFound("Workspace root not found".into()))?
|
||||
.to_path_buf();
|
||||
};
|
||||
|
||||
Self::new(crate_name, &workspace_root, monitored_path)
|
||||
}
|
||||
|
||||
/// Start monitoring disk usage and memory footprint
|
||||
pub fn start(&mut self) -> Result<()> {
|
||||
if self.0.monitor_thread.lock().is_some() {
|
||||
return Err(Error::Internal("Bencher already started"));
|
||||
}
|
||||
|
||||
let stop_flag = self.0.stop_flag.clone();
|
||||
let bench_dir = self.0.bench_dir.clone();
|
||||
let monitored_path = self.0.monitored_path.clone();
|
||||
|
||||
let handle =
|
||||
thread::spawn(move || monitor_resources(&monitored_path, &bench_dir, stop_flag));
|
||||
|
||||
*self.0.monitor_thread.lock() = Some(handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop monitoring and wait for the thread to finish
|
||||
pub fn stop(&self) -> Result<()> {
|
||||
self.0.stop_flag.store(true, Ordering::Relaxed);
|
||||
|
||||
if let Some(handle) = self.0.monitor_thread.lock().take() {
|
||||
handle.join().map_err(|_| Error::Internal("Monitor thread panicked"))??;
|
||||
}
|
||||
|
||||
self.0.progression.flush()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Bencher {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
fn monitor_resources(
|
||||
monitored_path: &Path,
|
||||
bench_dir: &Path,
|
||||
stop_flag: Arc<AtomicBool>,
|
||||
) -> Result<()> {
|
||||
let pid = std::process::id();
|
||||
let start = Instant::now();
|
||||
|
||||
let mut disk_monitor = DiskMonitor::new(monitored_path, &bench_dir.join("disk.csv"))?;
|
||||
let mut memory_monitor = MemoryMonitor::new(pid, &bench_dir.join("memory.csv"))?;
|
||||
let mut io_monitor = IoMonitor::new(pid, &bench_dir.join("io.csv"))?;
|
||||
|
||||
'l: loop {
|
||||
let elapsed_ms = start.elapsed().as_millis();
|
||||
|
||||
disk_monitor.record(elapsed_ms)?;
|
||||
memory_monitor.record(elapsed_ms)?;
|
||||
io_monitor.record(elapsed_ms)?;
|
||||
|
||||
for _ in 0..50 {
|
||||
// 50 * 100ms = 5 seconds
|
||||
if stop_flag.load(Ordering::Relaxed) {
|
||||
break 'l;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{self, Write},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::fs;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::process::Command;
|
||||
|
||||
pub struct MemoryMonitor {
|
||||
pid: u32,
|
||||
writer: File,
|
||||
}
|
||||
|
||||
impl MemoryMonitor {
|
||||
pub fn new(pid: u32, csv_path: &Path) -> io::Result<Self> {
|
||||
let mut writer = File::create(csv_path)?;
|
||||
writeln!(writer, "timestamp_ms,phys_footprint,phys_footprint_peak")?;
|
||||
|
||||
Ok(Self { pid, writer })
|
||||
}
|
||||
|
||||
/// Record memory usage at the given timestamp
|
||||
pub fn record(&mut self, elapsed_ms: u128) -> io::Result<()> {
|
||||
if let Ok((footprint, peak)) = self.get_memory_usage() {
|
||||
writeln!(self.writer, "{},{},{}", elapsed_ms, footprint, peak)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get memory usage in bytes
|
||||
/// Returns (current_bytes, peak_bytes)
|
||||
fn get_memory_usage(&self) -> io::Result<(u64, u64)> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
self.get_memory_usage_linux()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.get_memory_usage_macos()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn get_memory_usage_linux(&self) -> io::Result<(u64, u64)> {
|
||||
let status_content = fs::read_to_string(format!("/proc/{}/status", self.pid))?;
|
||||
|
||||
let mut vm_rss = None;
|
||||
let mut vm_hwm = None;
|
||||
|
||||
for line in status_content.lines() {
|
||||
if line.starts_with("VmRSS:") {
|
||||
if let Some(value_str) = line.split_whitespace().nth(1) {
|
||||
if let Ok(kb) = value_str.parse::<u64>() {
|
||||
vm_rss = Some(kb * 1024); // KiB to bytes
|
||||
}
|
||||
}
|
||||
} else if line.starts_with("VmHWM:") {
|
||||
if let Some(value_str) = line.split_whitespace().nth(1) {
|
||||
if let Ok(kb) = value_str.parse::<u64>() {
|
||||
vm_hwm = Some(kb * 1024); // KiB to bytes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (vm_rss, vm_hwm) {
|
||||
(Some(rss), Some(hwm)) => Ok((rss, hwm)),
|
||||
_ => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Failed to parse memory info from /proc/[pid]/status",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn get_memory_usage_macos(&self) -> io::Result<(u64, u64)> {
|
||||
let output = Command::new("footprint")
|
||||
.args(["-p", &self.pid.to_string()])
|
||||
.output()?;
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).map_err(|_| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-8 from footprint")
|
||||
})?;
|
||||
|
||||
parse_footprint_output(&stdout).ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Failed to parse footprint output",
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn parse_footprint_output(output: &str) -> Option<(u64, u64)> {
|
||||
let mut phys_footprint = None;
|
||||
let mut phys_footprint_peak = None;
|
||||
|
||||
for line in output.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
if line.starts_with("phys_footprint:") {
|
||||
// Format: "phys_footprint: 7072 KB"
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 3 {
|
||||
// parts[0] = "phys_footprint:"
|
||||
// parts[1] = "7072"
|
||||
// parts[2] = "KB"
|
||||
phys_footprint = parse_size_to_bytes(parts[1], parts[2]);
|
||||
}
|
||||
} else if line.starts_with("phys_footprint_peak:") {
|
||||
// Format: "phys_footprint_peak: 15 MB"
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 3 {
|
||||
phys_footprint_peak = parse_size_to_bytes(parts[1], parts[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (phys_footprint, phys_footprint_peak) {
|
||||
(Some(f), Some(p)) => Some((f, p)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn parse_size_to_bytes(value: &str, unit: &str) -> Option<u64> {
|
||||
let value: f64 = value.parse().ok()?;
|
||||
|
||||
let multiplier = match unit.to_uppercase().as_str() {
|
||||
"KB" => 1024.0, // KiB to bytes
|
||||
"MB" => 1024.0 * 1024.0, // MiB to bytes
|
||||
"GB" => 1024.0 * 1024.0 * 1024.0, // GiB to bytes
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some((value * multiplier) as u64)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, BufWriter, Write},
|
||||
path::Path,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
/// Patterns to match for progress tracking.
|
||||
const PROGRESS_PATTERNS: &[&str] = &[
|
||||
"block ", // "Indexing block 123..."
|
||||
"chain at ", // "Processing chain at 456..."
|
||||
];
|
||||
|
||||
pub struct ProgressionMonitor {
|
||||
csv_file: Mutex<BufWriter<fs::File>>,
|
||||
start_time: Instant,
|
||||
}
|
||||
|
||||
impl ProgressionMonitor {
|
||||
pub fn new(csv_path: &Path) -> io::Result<Self> {
|
||||
let mut csv_file = BufWriter::new(fs::File::create(csv_path)?);
|
||||
writeln!(csv_file, "timestamp_ms,value")?;
|
||||
|
||||
Ok(Self {
|
||||
csv_file: Mutex::new(csv_file),
|
||||
start_time: Instant::now(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check message for progress patterns and record if found
|
||||
#[inline]
|
||||
pub fn check_and_record(&self, message: &str) {
|
||||
let Some(value) = parse_progress(message) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if value % 10 != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let elapsed_ms = self.start_time.elapsed().as_millis();
|
||||
let _ = writeln!(self.csv_file.lock(), "{},{}", elapsed_ms, value);
|
||||
}
|
||||
|
||||
pub fn flush(&self) -> io::Result<()> {
|
||||
self.csv_file.lock().flush()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse progress value from message
|
||||
#[inline]
|
||||
fn parse_progress(message: &str) -> Option<u64> {
|
||||
PROGRESS_PATTERNS
|
||||
.iter()
|
||||
.find_map(|pattern| parse_number_after(message, pattern))
|
||||
}
|
||||
|
||||
/// Extract number immediately following the pattern
|
||||
#[inline]
|
||||
fn parse_number_after(message: &str, pattern: &str) -> Option<u64> {
|
||||
let start = message.find(pattern)?;
|
||||
let after = &message[start + pattern.len()..];
|
||||
|
||||
let end = after
|
||||
.find(|c: char| !c.is_ascii_digit())
|
||||
.unwrap_or(after.len());
|
||||
|
||||
if end == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
after[..end].parse().ok()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "brk_bencher_visualizer"
|
||||
description = "A generator of charts for brk_bencher"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
plotters = "0.3.7"
|
||||
@@ -0,0 +1,34 @@
|
||||
# brk_bencher_visualizer
|
||||
|
||||
SVG chart generation for benchmark visualization.
|
||||
|
||||
## What It Enables
|
||||
|
||||
Turn benchmark CSV data into publication-ready SVG charts showing disk usage, memory (current/peak), progress, and I/O over time. Compare multiple runs side-by-side with automatic color coding.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Multi-run comparison**: Overlay multiple benchmark runs with distinct colors
|
||||
- **Dual-axis charts**: Memory charts show both current and peak usage (solid vs dashed lines)
|
||||
- **Smart scaling**: Automatic unit conversion for bytes (KB/MB/GB) and time (seconds/minutes/hours)
|
||||
- **Per-run trimming**: Aligns data by progress cutoffs for fair comparison
|
||||
- **Dark theme**: Clean, readable charts with monospace fonts
|
||||
|
||||
## Core API
|
||||
|
||||
```rust,ignore
|
||||
let viz = Visualizer::from_cargo_env()?;
|
||||
viz.generate_all_charts()?; // Process all crates in benches/
|
||||
```
|
||||
|
||||
## Chart Types
|
||||
|
||||
- `disk.svg` - Storage consumption over time
|
||||
- `memory.svg` - Current + peak memory usage
|
||||
- `progress.svg` - Processing progress (e.g., blocks indexed)
|
||||
- `io_read.svg` / `io_write.svg` - I/O throughput
|
||||
|
||||
## Input Format
|
||||
|
||||
Reads CSV files from `benches/<crate>/<run_id>/`:
|
||||
- `disk.csv`, `memory.csv`, `progress.csv`, `io.csv`
|
||||
@@ -0,0 +1,251 @@
|
||||
use crate::data::{DataPoint, DualRun, Result, Run};
|
||||
use crate::format;
|
||||
use plotters::prelude::*;
|
||||
use std::path::Path;
|
||||
|
||||
const FONT: &str = "monospace";
|
||||
const FONT_SIZE: i32 = 20;
|
||||
const FONT_SIZE_BIG: i32 = 30;
|
||||
const SIZE: (u32, u32) = (2000, 1000);
|
||||
const TIME_BUFFER_MS: u64 = 10_000;
|
||||
|
||||
const BG_COLOR: RGBColor = RGBColor(18, 18, 24);
|
||||
const TEXT_COLOR: RGBColor = RGBColor(230, 230, 240);
|
||||
const COLORS: [RGBColor; 6] = [
|
||||
RGBColor(255, 99, 132), // Pink/Red
|
||||
RGBColor(54, 162, 235), // Blue
|
||||
RGBColor(75, 192, 192), // Teal
|
||||
RGBColor(255, 206, 86), // Yellow
|
||||
RGBColor(153, 102, 255), // Purple
|
||||
RGBColor(255, 159, 64), // Orange
|
||||
];
|
||||
|
||||
pub enum YAxisFormat {
|
||||
Bytes,
|
||||
Number,
|
||||
}
|
||||
|
||||
pub struct ChartConfig<'a> {
|
||||
pub output_path: &'a Path,
|
||||
pub title: String,
|
||||
pub y_label: String,
|
||||
pub y_format: YAxisFormat,
|
||||
}
|
||||
|
||||
/// Generate a simple line chart from runs
|
||||
pub fn generate(config: ChartConfig, runs: &[Run]) -> Result<()> {
|
||||
if runs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let max_time_ms = runs.iter().map(|r| r.max_timestamp()).max().unwrap_or(1000) + TIME_BUFFER_MS;
|
||||
let max_time_s = max_time_ms as f64 / 1000.0;
|
||||
let max_value = runs.iter().map(|r| r.max_value()).fold(0.0, f64::max);
|
||||
|
||||
let (time_scaled, time_divisor, time_label) = format::time(max_time_s);
|
||||
let (value_scaled, scale_factor, y_label) = scale_y_axis(max_value, &config.y_label, &config.y_format);
|
||||
let x_labels = label_count(time_scaled);
|
||||
|
||||
let root = SVGBackend::new(config.output_path, SIZE).into_drawing_area();
|
||||
root.fill(&BG_COLOR)?;
|
||||
|
||||
let mut chart = ChartBuilder::on(&root)
|
||||
.caption(&config.title, (FONT, FONT_SIZE_BIG).into_font().color(&TEXT_COLOR))
|
||||
.margin(20)
|
||||
.margin_right(40)
|
||||
.x_label_area_size(50)
|
||||
.margin_left(50)
|
||||
.right_y_label_area_size(75)
|
||||
.build_cartesian_2d(0.0..time_scaled * 1.025, 0.0..value_scaled * 1.1)?;
|
||||
|
||||
configure_mesh(&mut chart, time_label, &y_label, &config.y_format, x_labels)?;
|
||||
|
||||
for (idx, run) in runs.iter().enumerate() {
|
||||
let color = COLORS[idx % COLORS.len()];
|
||||
draw_series(&mut chart, &run.data, &run.id, color, time_divisor, scale_factor)?;
|
||||
}
|
||||
|
||||
configure_legend(&mut chart)?;
|
||||
root.present()?;
|
||||
println!("Generated: {}", config.output_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a chart with dual series per run (e.g., current + peak memory)
|
||||
pub fn generate_dual(
|
||||
config: ChartConfig,
|
||||
runs: &[DualRun],
|
||||
primary_suffix: &str,
|
||||
secondary_suffix: &str,
|
||||
) -> Result<()> {
|
||||
if runs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let max_time_ms = runs
|
||||
.iter()
|
||||
.flat_map(|r| r.primary.iter().chain(r.secondary.iter()))
|
||||
.map(|d| d.timestamp_ms)
|
||||
.max()
|
||||
.unwrap_or(1000)
|
||||
+ TIME_BUFFER_MS;
|
||||
let max_time_s = max_time_ms as f64 / 1000.0;
|
||||
let max_value = runs.iter().map(|r| r.max_value()).fold(0.0, f64::max);
|
||||
|
||||
let (time_scaled, time_divisor, time_label) = format::time(max_time_s);
|
||||
let (value_scaled, scale_factor, y_label) = scale_y_axis(max_value, &config.y_label, &config.y_format);
|
||||
let x_labels = label_count(time_scaled);
|
||||
|
||||
let root = SVGBackend::new(config.output_path, SIZE).into_drawing_area();
|
||||
root.fill(&BG_COLOR)?;
|
||||
|
||||
let mut chart = ChartBuilder::on(&root)
|
||||
.caption(&config.title, (FONT, FONT_SIZE_BIG).into_font().color(&TEXT_COLOR))
|
||||
.margin(20)
|
||||
.margin_right(40)
|
||||
.x_label_area_size(50)
|
||||
.margin_left(50)
|
||||
.right_y_label_area_size(75)
|
||||
.build_cartesian_2d(0.0..time_scaled * 1.025, 0.0..value_scaled * 1.1)?;
|
||||
|
||||
configure_mesh(&mut chart, time_label, &y_label, &config.y_format, x_labels)?;
|
||||
|
||||
for (idx, run) in runs.iter().enumerate() {
|
||||
let color = COLORS[idx % COLORS.len()];
|
||||
|
||||
// Primary series (solid)
|
||||
draw_series(
|
||||
&mut chart,
|
||||
&run.primary,
|
||||
&format!("{} {}", run.id, primary_suffix),
|
||||
color,
|
||||
time_divisor,
|
||||
scale_factor,
|
||||
)?;
|
||||
|
||||
// Secondary series (dashed)
|
||||
draw_dashed_series(
|
||||
&mut chart,
|
||||
&run.secondary,
|
||||
&format!("{} {}", run.id, secondary_suffix),
|
||||
color.mix(0.5),
|
||||
time_divisor,
|
||||
scale_factor,
|
||||
)?;
|
||||
}
|
||||
|
||||
configure_legend(&mut chart)?;
|
||||
root.present()?;
|
||||
println!("Generated: {}", config.output_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scale_y_axis(max_value: f64, base_label: &str, y_format: &YAxisFormat) -> (f64, f64, String) {
|
||||
match y_format {
|
||||
YAxisFormat::Bytes => {
|
||||
let (scaled, unit) = format::bytes(max_value);
|
||||
let factor = max_value / scaled;
|
||||
(scaled, factor, format!("{} ({})", base_label, unit))
|
||||
}
|
||||
YAxisFormat::Number => (max_value, 1.0, base_label.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate appropriate label count to avoid duplicates when rounding to integers
|
||||
fn label_count(max_value: f64) -> usize {
|
||||
let max_int = max_value.ceil() as usize;
|
||||
// Don't exceed the range, cap at 12 for readability
|
||||
max_int.clamp(2, 12)
|
||||
}
|
||||
|
||||
type Chart<'a, 'b> = ChartContext<
|
||||
'a,
|
||||
SVGBackend<'b>,
|
||||
Cartesian2d<plotters::coord::types::RangedCoordf64, plotters::coord::types::RangedCoordf64>,
|
||||
>;
|
||||
|
||||
fn configure_mesh(chart: &mut Chart, x_label: &str, y_label: &str, y_format: &YAxisFormat, x_labels: usize) -> Result<()> {
|
||||
let y_formatter: Box<dyn Fn(&f64) -> String> = match y_format {
|
||||
YAxisFormat::Bytes => Box::new(|y: &f64| {
|
||||
if y.fract() == 0.0 {
|
||||
format!("{:.0}", y)
|
||||
} else {
|
||||
format!("{:.1}", y)
|
||||
}
|
||||
}),
|
||||
YAxisFormat::Number => Box::new(|y: &f64| format::axis_number(*y)),
|
||||
};
|
||||
|
||||
chart
|
||||
.configure_mesh()
|
||||
.disable_mesh()
|
||||
.x_desc(x_label)
|
||||
.y_desc(y_label)
|
||||
.x_label_formatter(&|x| format!("{:.0}", x))
|
||||
.y_label_formatter(&y_formatter)
|
||||
.x_labels(x_labels)
|
||||
.y_labels(10)
|
||||
.x_label_style((FONT, FONT_SIZE).into_font().color(&TEXT_COLOR.mix(0.7)))
|
||||
.y_label_style((FONT, FONT_SIZE).into_font().color(&TEXT_COLOR.mix(0.7)))
|
||||
.axis_style(TEXT_COLOR.mix(0.3))
|
||||
.draw()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw_series(
|
||||
chart: &mut Chart,
|
||||
data: &[DataPoint],
|
||||
label: &str,
|
||||
color: RGBColor,
|
||||
time_divisor: f64,
|
||||
scale_factor: f64,
|
||||
) -> Result<()> {
|
||||
let points = data
|
||||
.iter()
|
||||
.map(|d| (d.timestamp_ms as f64 / 1000.0 / time_divisor, d.value / scale_factor));
|
||||
|
||||
chart
|
||||
.draw_series(LineSeries::new(points, color.stroke_width(1)))?
|
||||
.label(label)
|
||||
.legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], color.stroke_width(1)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw_dashed_series(
|
||||
chart: &mut Chart,
|
||||
data: &[DataPoint],
|
||||
label: &str,
|
||||
color: RGBAColor,
|
||||
time_divisor: f64,
|
||||
scale_factor: f64,
|
||||
) -> Result<()> {
|
||||
let points: Vec<_> = data
|
||||
.iter()
|
||||
.map(|d| (d.timestamp_ms as f64 / 1000.0 / time_divisor, d.value / scale_factor))
|
||||
.collect();
|
||||
|
||||
// Draw dashed line by skipping every other segment
|
||||
chart
|
||||
.draw_series(
|
||||
points
|
||||
.windows(2)
|
||||
.enumerate()
|
||||
.filter(|(i, _)| i % 2 == 0)
|
||||
.map(|(_, w)| PathElement::new(vec![w[0], w[1]], color.stroke_width(2))),
|
||||
)?
|
||||
.label(label)
|
||||
.legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 10, y), (x + 20, y)], color.stroke_width(2)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn configure_legend<'a>(chart: &mut Chart<'a, 'a>) -> Result<()> {
|
||||
chart
|
||||
.configure_series_labels()
|
||||
.position(SeriesLabelPosition::UpperLeft)
|
||||
.label_font((FONT, FONT_SIZE).into_font().color(&TEXT_COLOR.mix(0.9)))
|
||||
.background_style(BG_COLOR.mix(0.98))
|
||||
.border_style(BG_COLOR)
|
||||
.margin(10)
|
||||
.draw()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DataPoint {
|
||||
pub timestamp_ms: u64,
|
||||
pub value: f64,
|
||||
}
|
||||
|
||||
/// Per-run cutoff timestamps for fair comparison
|
||||
pub struct Cutoffs {
|
||||
by_id: HashMap<String, u64>,
|
||||
default: u64,
|
||||
}
|
||||
|
||||
impl Cutoffs {
|
||||
/// Calculate cutoffs from progress runs.
|
||||
/// Finds the common max progress, then returns when each run reached it.
|
||||
pub fn from_progress(progress_runs: &[Run]) -> Self {
|
||||
const TIME_BUFFER_MS: u64 = 10_000;
|
||||
|
||||
if progress_runs.is_empty() {
|
||||
return Self {
|
||||
by_id: HashMap::new(),
|
||||
default: u64::MAX,
|
||||
};
|
||||
}
|
||||
|
||||
// Find the minimum of max progress values (the common point all runs reached)
|
||||
let common_progress = progress_runs
|
||||
.iter()
|
||||
.map(|r| r.max_value())
|
||||
.fold(f64::MAX, f64::min);
|
||||
|
||||
let by_id: HashMap<_, _> = progress_runs
|
||||
.iter()
|
||||
.map(|run| {
|
||||
let cutoff = run
|
||||
.data
|
||||
.iter()
|
||||
.find(|d| d.value >= common_progress)
|
||||
.map(|d| d.timestamp_ms)
|
||||
.unwrap_or_else(|| run.max_timestamp())
|
||||
.saturating_add(TIME_BUFFER_MS);
|
||||
(run.id.clone(), cutoff)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let default = by_id.values().copied().max().unwrap_or(u64::MAX);
|
||||
|
||||
Self { by_id, default }
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> u64 {
|
||||
self.by_id.get(id).copied().unwrap_or(self.default)
|
||||
}
|
||||
|
||||
pub fn trim_runs(&self, runs: &[Run]) -> Vec<Run> {
|
||||
runs.iter().map(|r| r.trimmed(self.get(&r.id))).collect()
|
||||
}
|
||||
|
||||
pub fn trim_dual_runs(&self, runs: &[DualRun]) -> Vec<DualRun> {
|
||||
runs.iter().map(|r| r.trimmed(self.get(&r.id))).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Run {
|
||||
pub id: String,
|
||||
pub data: Vec<DataPoint>,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
pub fn max_timestamp(&self) -> u64 {
|
||||
self.data.iter().map(|d| d.timestamp_ms).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn max_value(&self) -> f64 {
|
||||
self.data.iter().map(|d| d.value).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
pub fn trimmed(&self, max_timestamp_ms: u64) -> Self {
|
||||
Self {
|
||||
id: self.id.clone(),
|
||||
data: self
|
||||
.data
|
||||
.iter()
|
||||
.filter(|d| d.timestamp_ms <= max_timestamp_ms)
|
||||
.cloned()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Two data series from a single run (e.g., memory footprint + peak, or io read + write)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DualRun {
|
||||
pub id: String,
|
||||
pub primary: Vec<DataPoint>,
|
||||
pub secondary: Vec<DataPoint>,
|
||||
}
|
||||
|
||||
impl DualRun {
|
||||
pub fn trimmed(&self, max_timestamp_ms: u64) -> Self {
|
||||
Self {
|
||||
id: self.id.clone(),
|
||||
primary: self
|
||||
.primary
|
||||
.iter()
|
||||
.filter(|d| d.timestamp_ms <= max_timestamp_ms)
|
||||
.cloned()
|
||||
.collect(),
|
||||
secondary: self
|
||||
.secondary
|
||||
.iter()
|
||||
.filter(|d| d.timestamp_ms <= max_timestamp_ms)
|
||||
.cloned()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max_value(&self) -> f64 {
|
||||
self.primary
|
||||
.iter()
|
||||
.chain(self.secondary.iter())
|
||||
.map(|d| d.value)
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_runs(crate_path: &Path, filename: &str) -> Result<Vec<Run>> {
|
||||
let mut runs = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(crate_path)? {
|
||||
let run_path = entry?.path();
|
||||
if !run_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let run_id = run_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or("Invalid run ID")?
|
||||
.to_string();
|
||||
|
||||
// Skip underscore-prefixed or numeric-only directories
|
||||
if run_id.starts_with('_') || run_id.chars().all(|c| c.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let csv_path = run_path.join(filename);
|
||||
if csv_path.exists()
|
||||
&& let Ok(data) = read_csv(&csv_path)
|
||||
{
|
||||
runs.push(Run { id: run_id, data });
|
||||
}
|
||||
}
|
||||
|
||||
Ok(runs)
|
||||
}
|
||||
|
||||
pub fn read_dual_runs(crate_path: &Path, filename: &str) -> Result<Vec<DualRun>> {
|
||||
let mut runs = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(crate_path)? {
|
||||
let run_path = entry?.path();
|
||||
if !run_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let run_id = run_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or("Invalid run ID")?
|
||||
.to_string();
|
||||
|
||||
if run_id.starts_with('_') || run_id.chars().all(|c| c.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let csv_path = run_path.join(filename);
|
||||
if csv_path.exists()
|
||||
&& let Ok((primary, secondary)) = read_dual_csv(&csv_path)
|
||||
{
|
||||
runs.push(DualRun {
|
||||
id: run_id,
|
||||
primary,
|
||||
secondary,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(runs)
|
||||
}
|
||||
|
||||
fn read_csv(path: &Path) -> Result<Vec<DataPoint>> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let data = content
|
||||
.lines()
|
||||
.skip(1) // header
|
||||
.filter_map(|line| {
|
||||
let mut parts = line.split(',');
|
||||
let timestamp_ms = parts.next()?.parse().ok()?;
|
||||
let value = parts.next()?.parse().ok()?;
|
||||
Some(DataPoint {
|
||||
timestamp_ms,
|
||||
value,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
fn read_dual_csv(path: &Path) -> Result<(Vec<DataPoint>, Vec<DataPoint>)> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let mut primary = Vec::new();
|
||||
let mut secondary = Vec::new();
|
||||
|
||||
for line in content.lines().skip(1) {
|
||||
let mut parts = line.split(',');
|
||||
if let (Some(ts), Some(v1), Some(v2)) = (parts.next(), parts.next(), parts.next())
|
||||
&& let (Ok(timestamp_ms), Ok(val1), Ok(val2)) =
|
||||
(ts.parse(), v1.parse::<f64>(), v2.parse::<f64>())
|
||||
{
|
||||
primary.push(DataPoint {
|
||||
timestamp_ms,
|
||||
value: val1,
|
||||
});
|
||||
secondary.push(DataPoint {
|
||||
timestamp_ms,
|
||||
value: val2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok((primary, secondary))
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
const KIB: f64 = 1024.0;
|
||||
const MIB: f64 = KIB * 1024.0;
|
||||
const GIB: f64 = MIB * 1024.0;
|
||||
|
||||
const MINUTE: f64 = 60.0;
|
||||
const HOUR: f64 = 3600.0;
|
||||
|
||||
/// Returns (scaled_value, unit_suffix)
|
||||
pub fn bytes(bytes: f64) -> (f64, &'static str) {
|
||||
if bytes >= GIB {
|
||||
(bytes / GIB, "GiB")
|
||||
} else if bytes >= MIB {
|
||||
(bytes / MIB, "MiB")
|
||||
} else if bytes >= KIB {
|
||||
(bytes / KIB, "KiB")
|
||||
} else {
|
||||
(bytes, "bytes")
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns (scaled_value, divisor, axis_label)
|
||||
pub fn time(seconds: f64) -> (f64, f64, &'static str) {
|
||||
if seconds >= HOUR * 2.0 {
|
||||
(seconds / HOUR, HOUR, "Time (h)")
|
||||
} else if seconds >= MINUTE * 2.0 {
|
||||
(seconds / MINUTE, MINUTE, "Time (min)")
|
||||
} else {
|
||||
(seconds, 1.0, "Time (s)")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn axis_number(value: f64) -> String {
|
||||
if value >= 1000.0 {
|
||||
let k = value / 1000.0;
|
||||
if k.fract() == 0.0 || k >= 100.0 {
|
||||
format!("{:.0}k", k)
|
||||
} else if k >= 10.0 {
|
||||
format!("{:.1}k", k)
|
||||
} else {
|
||||
format!("{:.2}k", k)
|
||||
}
|
||||
} else {
|
||||
format!("{:.0}", value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
mod chart;
|
||||
mod data;
|
||||
mod format;
|
||||
|
||||
use data::{read_dual_runs, read_runs, Cutoffs, DualRun, Result, Run};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
pub struct Visualizer {
|
||||
workspace_root: PathBuf,
|
||||
}
|
||||
|
||||
impl Visualizer {
|
||||
pub fn new(workspace_root: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
workspace_root: workspace_root.as_ref().to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_cargo_env() -> Result<Self> {
|
||||
let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.ok_or("Failed to find workspace root")?
|
||||
.to_path_buf();
|
||||
Ok(Self { workspace_root })
|
||||
}
|
||||
|
||||
pub fn generate_all_charts(&self) -> Result<()> {
|
||||
let benches_dir = self.workspace_root.join("benches");
|
||||
if !benches_dir.exists() {
|
||||
return Err("Benches directory does not exist".into());
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(&benches_dir)? {
|
||||
let path = entry?.path();
|
||||
if path.is_dir() {
|
||||
let crate_name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or("Invalid crate name")?;
|
||||
|
||||
println!("Generating charts for crate: {}", crate_name);
|
||||
self.generate_crate_charts(&path, crate_name)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_crate_charts(&self, crate_path: &Path, crate_name: &str) -> Result<()> {
|
||||
let disk_runs = read_runs(crate_path, "disk.csv")?;
|
||||
let memory_runs = read_dual_runs(crate_path, "memory.csv")?;
|
||||
let progress_runs = read_runs(crate_path, "progress.csv")?;
|
||||
let io_runs = read_dual_runs(crate_path, "io.csv")?;
|
||||
|
||||
// Combined charts (all runs)
|
||||
self.generate_combined_charts(crate_path, crate_name, &disk_runs, &memory_runs, &progress_runs, &io_runs)?;
|
||||
|
||||
// Individual charts (one per run)
|
||||
self.generate_individual_charts(crate_path, crate_name, &disk_runs, &memory_runs, &progress_runs, &io_runs)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_combined_charts(
|
||||
&self,
|
||||
crate_path: &Path,
|
||||
crate_name: &str,
|
||||
disk_runs: &[Run],
|
||||
memory_runs: &[DualRun],
|
||||
progress_runs: &[Run],
|
||||
io_runs: &[DualRun],
|
||||
) -> Result<()> {
|
||||
let cutoffs = Cutoffs::from_progress(progress_runs);
|
||||
|
||||
// Trim data to per-run cutoffs for fair comparison
|
||||
let disk_trimmed = cutoffs.trim_runs(disk_runs);
|
||||
let memory_trimmed = cutoffs.trim_dual_runs(memory_runs);
|
||||
let io_trimmed = cutoffs.trim_dual_runs(io_runs);
|
||||
|
||||
if !disk_trimmed.is_empty() {
|
||||
chart::generate(
|
||||
chart::ChartConfig {
|
||||
output_path: &crate_path.join("disk.svg"),
|
||||
title: format!("{} — Disk Usage", crate_name),
|
||||
y_label: "Disk Usage".to_string(),
|
||||
y_format: chart::YAxisFormat::Bytes,
|
||||
},
|
||||
&disk_trimmed,
|
||||
)?;
|
||||
}
|
||||
|
||||
if !memory_trimmed.is_empty() {
|
||||
chart::generate_dual(
|
||||
chart::ChartConfig {
|
||||
output_path: &crate_path.join("memory.svg"),
|
||||
title: format!("{} — Memory", crate_name),
|
||||
y_label: "Memory".to_string(),
|
||||
y_format: chart::YAxisFormat::Bytes,
|
||||
},
|
||||
&memory_trimmed,
|
||||
"(current)",
|
||||
"(peak)",
|
||||
)?;
|
||||
}
|
||||
|
||||
if !progress_runs.is_empty() {
|
||||
let progress_trimmed = cutoffs.trim_runs(progress_runs);
|
||||
chart::generate(
|
||||
chart::ChartConfig {
|
||||
output_path: &crate_path.join("progress.svg"),
|
||||
title: format!("{} — Progress", crate_name),
|
||||
y_label: "Progress".to_string(),
|
||||
y_format: chart::YAxisFormat::Number,
|
||||
},
|
||||
&progress_trimmed,
|
||||
)?;
|
||||
}
|
||||
|
||||
if !io_trimmed.is_empty() {
|
||||
// I/O Read (primary column)
|
||||
let io_read: Vec<_> = io_trimmed
|
||||
.iter()
|
||||
.map(|r| Run {
|
||||
id: r.id.clone(),
|
||||
data: r.primary.clone(),
|
||||
})
|
||||
.collect();
|
||||
chart::generate(
|
||||
chart::ChartConfig {
|
||||
output_path: &crate_path.join("io_read.svg"),
|
||||
title: format!("{} — I/O Read", crate_name),
|
||||
y_label: "Bytes Read".to_string(),
|
||||
y_format: chart::YAxisFormat::Bytes,
|
||||
},
|
||||
&io_read,
|
||||
)?;
|
||||
|
||||
// I/O Write (secondary column)
|
||||
let io_write: Vec<_> = io_trimmed
|
||||
.iter()
|
||||
.map(|r| Run {
|
||||
id: r.id.clone(),
|
||||
data: r.secondary.clone(),
|
||||
})
|
||||
.collect();
|
||||
chart::generate(
|
||||
chart::ChartConfig {
|
||||
output_path: &crate_path.join("io_write.svg"),
|
||||
title: format!("{} — I/O Write", crate_name),
|
||||
y_label: "Bytes Written".to_string(),
|
||||
y_format: chart::YAxisFormat::Bytes,
|
||||
},
|
||||
&io_write,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_individual_charts(
|
||||
&self,
|
||||
crate_path: &Path,
|
||||
crate_name: &str,
|
||||
disk_runs: &[Run],
|
||||
memory_runs: &[DualRun],
|
||||
progress_runs: &[Run],
|
||||
io_runs: &[DualRun],
|
||||
) -> Result<()> {
|
||||
for run in disk_runs {
|
||||
let run_path = crate_path.join(&run.id);
|
||||
chart::generate(
|
||||
chart::ChartConfig {
|
||||
output_path: &run_path.join("disk.svg"),
|
||||
title: format!("{} — Disk Usage", crate_name),
|
||||
y_label: "Disk Usage".to_string(),
|
||||
y_format: chart::YAxisFormat::Bytes,
|
||||
},
|
||||
std::slice::from_ref(run),
|
||||
)?;
|
||||
}
|
||||
|
||||
for run in memory_runs {
|
||||
let run_path = crate_path.join(&run.id);
|
||||
chart::generate_dual(
|
||||
chart::ChartConfig {
|
||||
output_path: &run_path.join("memory.svg"),
|
||||
title: format!("{} — Memory", crate_name),
|
||||
y_label: "Memory".to_string(),
|
||||
y_format: chart::YAxisFormat::Bytes,
|
||||
},
|
||||
std::slice::from_ref(run),
|
||||
"(current)",
|
||||
"(peak)",
|
||||
)?;
|
||||
}
|
||||
|
||||
for run in progress_runs {
|
||||
let run_path = crate_path.join(&run.id);
|
||||
chart::generate(
|
||||
chart::ChartConfig {
|
||||
output_path: &run_path.join("progress.svg"),
|
||||
title: format!("{} — Progress", crate_name),
|
||||
y_label: "Progress".to_string(),
|
||||
y_format: chart::YAxisFormat::Number,
|
||||
},
|
||||
std::slice::from_ref(run),
|
||||
)?;
|
||||
}
|
||||
|
||||
for run in io_runs {
|
||||
let run_path = crate_path.join(&run.id);
|
||||
|
||||
let read_run = Run {
|
||||
id: run.id.clone(),
|
||||
data: run.primary.clone(),
|
||||
};
|
||||
chart::generate(
|
||||
chart::ChartConfig {
|
||||
output_path: &run_path.join("io_read.svg"),
|
||||
title: format!("{} — I/O Read", crate_name),
|
||||
y_label: "Bytes Read".to_string(),
|
||||
y_format: chart::YAxisFormat::Bytes,
|
||||
},
|
||||
std::slice::from_ref(&read_run),
|
||||
)?;
|
||||
|
||||
let write_run = Run {
|
||||
id: run.id.clone(),
|
||||
data: run.secondary.clone(),
|
||||
};
|
||||
chart::generate(
|
||||
chart::ChartConfig {
|
||||
output_path: &run_path.join("io_write.svg"),
|
||||
title: format!("{} — I/O Write", crate_name),
|
||||
y_label: "Bytes Written".to_string(),
|
||||
y_format: chart::YAxisFormat::Bytes,
|
||||
},
|
||||
std::slice::from_ref(&write_run),
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
use brk_bencher_visualizer::Visualizer;
|
||||
|
||||
fn main() {
|
||||
let v = Visualizer::from_cargo_env().unwrap();
|
||||
v.generate_all_charts().unwrap();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
clients/
|
||||
/*.json
|
||||
/*.js
|
||||
/*.rs
|
||||
/*.py
|
||||
tests/output/
|
||||
@@ -0,0 +1,16 @@
|
||||
[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 }
|
||||
oas3 = "0.20"
|
||||
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,195 @@
|
||||
//! Common prefix/suffix detection for metric names.
|
||||
//!
|
||||
//! This module provides utilities to find common prefixes and suffixes
|
||||
//! among metric 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,349 @@
|
||||
//! 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::collections::{BTreeSet, HashMap};
|
||||
|
||||
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: HashMap<Vec<PatternField>, String>,
|
||||
/// Counts how many times each signature appears
|
||||
signature_counts: HashMap<Vec<PatternField>, usize>,
|
||||
/// Maps normalized signatures to pattern names (for naming consistency)
|
||||
normalized_to_name: HashMap<Vec<PatternField>, String>,
|
||||
/// Counts pattern name usage (for unique naming)
|
||||
name_counts: HashMap<String, usize>,
|
||||
/// Maps signatures to their child field lists
|
||||
signature_to_child_fields: HashMap<Vec<PatternField>, Vec<Vec<PatternField>>>,
|
||||
}
|
||||
|
||||
impl PatternContext {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
signature_to_pattern: HashMap::new(),
|
||||
signature_counts: HashMap::new(),
|
||||
normalized_to_name: HashMap::new(),
|
||||
name_counts: HashMap::new(),
|
||||
signature_to_child_fields: HashMap::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>,
|
||||
HashMap<Vec<PatternField>, String>,
|
||||
HashMap<Vec<PatternField>, String>,
|
||||
HashMap<String, PatternBaseResult>,
|
||||
) {
|
||||
let mut ctx = PatternContext::new();
|
||||
resolve_branch_patterns(tree, "root", &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();
|
||||
|
||||
patterns.extend(generic_patterns);
|
||||
|
||||
// Build pattern lookup for mode analysis (patterns appearing 2+ times)
|
||||
let mut pattern_lookup: HashMap<Vec<PatternField>, String> = HashMap::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(|a, b| b.fields.len().cmp(&a.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: &HashMap<Vec<PatternField>, String>,
|
||||
) -> (
|
||||
Vec<StructuralPattern>,
|
||||
HashMap<Vec<PatternField>, String>,
|
||||
HashMap<Vec<PatternField>, String>,
|
||||
) {
|
||||
let mut normalized_groups: HashMap<
|
||||
Vec<PatternField>,
|
||||
Vec<(Vec<PatternField>, String, String)>,
|
||||
> = HashMap::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: HashMap<Vec<PatternField>, String> = HashMap::new();
|
||||
let mut type_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, _, 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,
|
||||
field_name: &str,
|
||||
ctx: &mut PatternContext,
|
||||
) -> Option<(String, Vec<PatternField>)> {
|
||||
let TreeNode::Branch(children) = node else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut fields: Vec<PatternField> = Vec::new();
|
||||
let mut child_fields_vec: Vec<Vec<PatternField>> = Vec::new();
|
||||
|
||||
for (child_name, child_node) in 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, child_name, 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.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
*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);
|
||||
let name = ctx
|
||||
.normalized_to_name
|
||||
.entry(normalized)
|
||||
.or_insert_with(|| generate_pattern_name(field_name, &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).
|
||||
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(),
|
||||
type_param: None,
|
||||
}
|
||||
}
|
||||
})
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
//! Pattern mode detection and field part extraction.
|
||||
//!
|
||||
//! This module analyzes pattern instances to detect whether they use
|
||||
//! suffix mode (fields append to acc) or prefix mode (fields prepend to acc),
|
||||
//! and extracts the field parts (relatives or prefixes) for code generation.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use brk_types::TreeNode;
|
||||
|
||||
use super::{find_common_prefix, find_common_suffix, get_node_fields, normalize_prefix};
|
||||
use crate::{PatternBaseResult, PatternField, PatternMode, StructuralPattern, build_child_path};
|
||||
|
||||
/// Result of analyzing a single pattern instance.
|
||||
#[derive(Debug, Clone)]
|
||||
struct InstanceAnalysis {
|
||||
/// The base to return to parent (used for nesting)
|
||||
base: String,
|
||||
/// For suffix mode: field -> relative name
|
||||
/// For prefix mode: field -> prefix
|
||||
field_parts: HashMap<String, String>,
|
||||
/// Whether this instance appears to be suffix mode
|
||||
is_suffix_mode: bool,
|
||||
}
|
||||
|
||||
/// Analyze all pattern instances and determine their modes.
|
||||
///
|
||||
/// This is the main entry point for mode detection. It processes
|
||||
/// the tree bottom-up, collecting analysis for each pattern instance,
|
||||
/// then determines the consistent mode for each pattern.
|
||||
///
|
||||
/// Returns a map from tree paths to their computed PatternBaseResult.
|
||||
/// This map is used during generation to check pattern compatibility.
|
||||
pub fn analyze_pattern_modes(
|
||||
tree: &TreeNode,
|
||||
patterns: &mut [StructuralPattern],
|
||||
pattern_lookup: &HashMap<Vec<PatternField>, String>,
|
||||
) -> HashMap<String, PatternBaseResult> {
|
||||
// Collect analyses from all instances, keyed by pattern name
|
||||
let mut all_analyses: HashMap<String, Vec<InstanceAnalysis>> = HashMap::new();
|
||||
// Also collect base results for each node, keyed by tree path
|
||||
let mut node_bases: HashMap<String, PatternBaseResult> = HashMap::new();
|
||||
|
||||
// Bottom-up traversal
|
||||
collect_instance_analyses(tree, "", pattern_lookup, &mut all_analyses, &mut node_bases);
|
||||
|
||||
// For each pattern, determine mode from collected instances
|
||||
for pattern in patterns.iter_mut() {
|
||||
if let Some(analyses) = all_analyses.get(&pattern.name) {
|
||||
pattern.mode = determine_pattern_mode(analyses, &pattern.fields);
|
||||
}
|
||||
}
|
||||
|
||||
node_bases
|
||||
}
|
||||
|
||||
/// Recursively collect instance analyses bottom-up.
|
||||
/// Returns the "base" for this node (used by parent for its analysis).
|
||||
///
|
||||
/// Also stores the PatternBaseResult for each node in `node_bases`, keyed by path.
|
||||
fn collect_instance_analyses(
|
||||
node: &TreeNode,
|
||||
path: &str,
|
||||
pattern_lookup: &HashMap<Vec<PatternField>, String>,
|
||||
all_analyses: &mut HashMap<String, Vec<InstanceAnalysis>>,
|
||||
node_bases: &mut HashMap<String, PatternBaseResult>,
|
||||
) -> Option<String> {
|
||||
match node {
|
||||
TreeNode::Leaf(leaf) => {
|
||||
// Leaves return their metric name as the base
|
||||
Some(leaf.name().to_string())
|
||||
}
|
||||
TreeNode::Branch(children) => {
|
||||
// First, process all children recursively (bottom-up)
|
||||
let mut child_bases: HashMap<String, String> = HashMap::new();
|
||||
for (field_name, child_node) in children {
|
||||
let child_path = build_child_path(path, field_name);
|
||||
if let Some(base) = collect_instance_analyses(
|
||||
child_node,
|
||||
&child_path,
|
||||
pattern_lookup,
|
||||
all_analyses,
|
||||
node_bases,
|
||||
) {
|
||||
child_bases.insert(field_name.clone(), base);
|
||||
}
|
||||
}
|
||||
|
||||
if child_bases.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Analyze this instance
|
||||
let analysis = analyze_instance(&child_bases);
|
||||
|
||||
// Store the base result for this node
|
||||
// Note: has_outlier is false because we use recursive base computation
|
||||
// which gives correct bases without needing outlier detection
|
||||
node_bases.insert(
|
||||
path.to_string(),
|
||||
PatternBaseResult {
|
||||
base: analysis.base.clone(),
|
||||
has_outlier: false,
|
||||
is_suffix_mode: analysis.is_suffix_mode,
|
||||
field_parts: analysis.field_parts.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
// Get the pattern name for this node (if any)
|
||||
let fields = 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.clone());
|
||||
}
|
||||
|
||||
// Return the base for parent
|
||||
Some(analysis.base)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Analyze a single pattern instance from its child bases.
|
||||
fn analyze_instance(child_bases: &HashMap<String, String>) -> InstanceAnalysis {
|
||||
let bases: Vec<&str> = child_bases.values().map(|s| s.as_str()).collect();
|
||||
|
||||
// Try suffix mode first: look for common prefix among children
|
||||
if let Some(common_prefix) = find_common_prefix(&bases) {
|
||||
let base = common_prefix.trim_end_matches('_').to_string();
|
||||
let mut field_parts = HashMap::new();
|
||||
|
||||
for (field_name, child_base) in child_bases {
|
||||
// Relative = child_base with common prefix stripped
|
||||
// If child_base equals base, relative is empty (identity field)
|
||||
let relative = if child_base == &base {
|
||||
String::new()
|
||||
} else {
|
||||
child_base
|
||||
.strip_prefix(&common_prefix)
|
||||
.unwrap_or(child_base)
|
||||
.to_string()
|
||||
};
|
||||
field_parts.insert(field_name.clone(), relative);
|
||||
}
|
||||
|
||||
return InstanceAnalysis {
|
||||
base,
|
||||
field_parts,
|
||||
is_suffix_mode: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Try prefix mode: look for common suffix among children
|
||||
if let Some(common_suffix) = find_common_suffix(&bases) {
|
||||
let base = common_suffix.trim_start_matches('_').to_string();
|
||||
let mut field_parts = HashMap::new();
|
||||
|
||||
for (field_name, child_base) in child_bases {
|
||||
// Prefix = child_base with common suffix stripped, normalized to end with _
|
||||
let prefix = child_base
|
||||
.strip_suffix(&common_suffix)
|
||||
.map(normalize_prefix)
|
||||
.unwrap_or_default();
|
||||
field_parts.insert(field_name.clone(), prefix);
|
||||
}
|
||||
|
||||
return InstanceAnalysis {
|
||||
base,
|
||||
field_parts,
|
||||
is_suffix_mode: false,
|
||||
};
|
||||
}
|
||||
|
||||
// No common prefix or suffix - use empty base so _m(base, relative) returns just the relative.
|
||||
// This handles cases like utxo_cohorts.all.activity where children have completely
|
||||
// different bases (coinblocks_destroyed, coindays_destroyed, etc.)
|
||||
let field_parts = child_bases
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
|
||||
InstanceAnalysis {
|
||||
base: String::new(),
|
||||
field_parts,
|
||||
is_suffix_mode: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine the consistent mode for a pattern from all its instances.
|
||||
/// Uses majority voting: if most instances agree on mode and field_parts,
|
||||
/// use those. Minority instances will be inlined at usage sites.
|
||||
fn determine_pattern_mode(
|
||||
analyses: &[InstanceAnalysis],
|
||||
fields: &[PatternField],
|
||||
) -> Option<PatternMode> {
|
||||
if analyses.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Group instances by (mode, field_parts) signature
|
||||
let suffix_instances: Vec<_> = analyses.iter().filter(|a| a.is_suffix_mode).collect();
|
||||
let prefix_instances: Vec<_> = analyses.iter().filter(|a| !a.is_suffix_mode).collect();
|
||||
|
||||
// Pick the majority mode group
|
||||
let (majority_instances, is_suffix) = if suffix_instances.len() >= prefix_instances.len() {
|
||||
(suffix_instances, true)
|
||||
} else {
|
||||
(prefix_instances, false)
|
||||
};
|
||||
|
||||
if majority_instances.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find the most common field_parts within the majority group
|
||||
// Convert to sorted Vec for comparison since HashMap isn't hashable
|
||||
let mut parts_counts: HashMap<Vec<(String, String)>, usize> = HashMap::new();
|
||||
for analysis in &majority_instances {
|
||||
let mut sorted: Vec<_> = analysis
|
||||
.field_parts
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
sorted.sort();
|
||||
*parts_counts.entry(sorted).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let (best_parts_vec, _count) = parts_counts.into_iter().max_by_key(|(_, count)| *count)?;
|
||||
let best_parts: HashMap<String, String> = best_parts_vec.into_iter().collect();
|
||||
|
||||
// Verify all required fields have parts
|
||||
for field in fields {
|
||||
if !best_parts.contains_key(&field.name) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let field_parts = best_parts;
|
||||
|
||||
if is_suffix {
|
||||
Some(PatternMode::Suffix {
|
||||
relatives: field_parts,
|
||||
})
|
||||
} else {
|
||||
Some(PatternMode::Prefix {
|
||||
prefixes: field_parts,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_analyze_instance_suffix_mode() {
|
||||
let mut child_bases = HashMap::new();
|
||||
child_bases.insert("max".to_string(), "lth_cost_basis_max".to_string());
|
||||
child_bases.insert("min".to_string(), "lth_cost_basis_min".to_string());
|
||||
child_bases.insert("percentiles".to_string(), "lth_cost_basis".to_string());
|
||||
|
||||
let analysis = analyze_instance(&child_bases);
|
||||
|
||||
assert!(analysis.is_suffix_mode);
|
||||
assert_eq!(analysis.base, "lth_cost_basis");
|
||||
assert_eq!(analysis.field_parts.get("max"), Some(&"max".to_string()));
|
||||
assert_eq!(analysis.field_parts.get("min"), Some(&"min".to_string()));
|
||||
assert_eq!(
|
||||
analysis.field_parts.get("percentiles"),
|
||||
Some(&"".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_analyze_instance_prefix_mode() {
|
||||
// Period-prefixed metrics like "1y_lump_sum_stack", "1m_lump_sum_stack"
|
||||
// share a common suffix "_lump_sum_stack" with different period prefixes
|
||||
let mut child_bases = HashMap::new();
|
||||
child_bases.insert("_1y".to_string(), "1y_lump_sum_stack".to_string());
|
||||
child_bases.insert("_1m".to_string(), "1m_lump_sum_stack".to_string());
|
||||
child_bases.insert("_1w".to_string(), "1w_lump_sum_stack".to_string());
|
||||
|
||||
let analysis = analyze_instance(&child_bases);
|
||||
|
||||
assert!(!analysis.is_suffix_mode);
|
||||
assert_eq!(analysis.base, "lump_sum_stack");
|
||||
assert_eq!(analysis.field_parts.get("_1y"), Some(&"1y_".to_string()));
|
||||
assert_eq!(analysis.field_parts.get("_1m"), Some(&"1m_".to_string()));
|
||||
assert_eq!(analysis.field_parts.get("_1w"), Some(&"1w_".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_analyze_instance_root_suffix() {
|
||||
// At root level with suffix naming convention
|
||||
let mut child_bases = HashMap::new();
|
||||
child_bases.insert("max".to_string(), "cost_basis_max".to_string());
|
||||
child_bases.insert("min".to_string(), "cost_basis_min".to_string());
|
||||
child_bases.insert("percentiles".to_string(), "cost_basis".to_string());
|
||||
|
||||
let analysis = analyze_instance(&child_bases);
|
||||
|
||||
// With suffix naming, common prefix is "cost_basis_" (since cost_basis is one of the names)
|
||||
assert!(analysis.is_suffix_mode);
|
||||
assert_eq!(analysis.base, "cost_basis");
|
||||
assert_eq!(analysis.field_parts.get("max"), Some(&"max".to_string()));
|
||||
assert_eq!(analysis.field_parts.get("min"), Some(&"min".to_string()));
|
||||
assert_eq!(
|
||||
analysis.field_parts.get("percentiles"),
|
||||
Some(&"".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_determine_pattern_mode_majority_voting() {
|
||||
// Test that majority voting works when instances have mixed modes.
|
||||
// This simulates CostBasisPattern2: most instances use suffix mode,
|
||||
// but root-level uses prefix mode (max_cost_basis, min_cost_basis, cost_basis).
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
let fields = vec![
|
||||
PatternField {
|
||||
name: "max".to_string(),
|
||||
rust_type: "TestType".to_string(),
|
||||
json_type: "number".to_string(),
|
||||
indexes: BTreeSet::new(),
|
||||
type_param: None,
|
||||
},
|
||||
PatternField {
|
||||
name: "min".to_string(),
|
||||
rust_type: "TestType".to_string(),
|
||||
json_type: "number".to_string(),
|
||||
indexes: BTreeSet::new(),
|
||||
type_param: None,
|
||||
},
|
||||
PatternField {
|
||||
name: "percentiles".to_string(),
|
||||
rust_type: "TestType".to_string(),
|
||||
json_type: "number".to_string(),
|
||||
indexes: BTreeSet::new(),
|
||||
type_param: None,
|
||||
},
|
||||
];
|
||||
|
||||
// 3 suffix mode instances (majority)
|
||||
let suffix1 = InstanceAnalysis {
|
||||
base: "lth_cost_basis".to_string(),
|
||||
field_parts: [
|
||||
("max".to_string(), "max".to_string()),
|
||||
("min".to_string(), "min".to_string()),
|
||||
("percentiles".to_string(), "".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
is_suffix_mode: true,
|
||||
};
|
||||
let suffix2 = InstanceAnalysis {
|
||||
base: "sth_cost_basis".to_string(),
|
||||
field_parts: [
|
||||
("max".to_string(), "max".to_string()),
|
||||
("min".to_string(), "min".to_string()),
|
||||
("percentiles".to_string(), "".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
is_suffix_mode: true,
|
||||
};
|
||||
let suffix3 = InstanceAnalysis {
|
||||
base: "utxo_cost_basis".to_string(),
|
||||
field_parts: [
|
||||
("max".to_string(), "max".to_string()),
|
||||
("min".to_string(), "min".to_string()),
|
||||
("percentiles".to_string(), "".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
is_suffix_mode: true,
|
||||
};
|
||||
|
||||
// 1 prefix mode instance (minority - root level)
|
||||
let prefix1 = InstanceAnalysis {
|
||||
base: "cost_basis".to_string(),
|
||||
field_parts: [
|
||||
("max".to_string(), "max_".to_string()),
|
||||
("min".to_string(), "min_".to_string()),
|
||||
("percentiles".to_string(), "".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
is_suffix_mode: false,
|
||||
};
|
||||
|
||||
let analyses = vec![suffix1, suffix2, suffix3, prefix1];
|
||||
|
||||
let mode = determine_pattern_mode(&analyses, &fields);
|
||||
|
||||
// Should pick suffix mode (majority) with the common field_parts
|
||||
assert!(mode.is_some());
|
||||
match mode.unwrap() {
|
||||
PatternMode::Suffix { relatives } => {
|
||||
assert_eq!(relatives.get("max"), Some(&"max".to_string()));
|
||||
assert_eq!(relatives.get("min"), Some(&"min".to_string()));
|
||||
assert_eq!(relatives.get("percentiles"), Some(&"".to_string()));
|
||||
}
|
||||
PatternMode::Prefix { .. } => {
|
||||
panic!("Expected suffix mode, got prefix mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_determine_pattern_mode_all_same() {
|
||||
// Test when all instances agree on mode and field_parts
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
let fields = vec![
|
||||
PatternField {
|
||||
name: "max".to_string(),
|
||||
rust_type: "TestType".to_string(),
|
||||
json_type: "number".to_string(),
|
||||
indexes: BTreeSet::new(),
|
||||
type_param: None,
|
||||
},
|
||||
PatternField {
|
||||
name: "min".to_string(),
|
||||
rust_type: "TestType".to_string(),
|
||||
json_type: "number".to_string(),
|
||||
indexes: BTreeSet::new(),
|
||||
type_param: None,
|
||||
},
|
||||
];
|
||||
|
||||
let instance1 = InstanceAnalysis {
|
||||
base: "metric_a".to_string(),
|
||||
field_parts: [
|
||||
("max".to_string(), "max".to_string()),
|
||||
("min".to_string(), "min".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
is_suffix_mode: true,
|
||||
};
|
||||
let instance2 = InstanceAnalysis {
|
||||
base: "metric_b".to_string(),
|
||||
field_parts: [
|
||||
("max".to_string(), "max".to_string()),
|
||||
("min".to_string(), "min".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
is_suffix_mode: true,
|
||||
};
|
||||
|
||||
let analyses = vec![instance1, instance2];
|
||||
let mode = determine_pattern_mode(&analyses, &fields);
|
||||
|
||||
assert!(mode.is_some());
|
||||
match mode.unwrap() {
|
||||
PatternMode::Suffix { relatives } => {
|
||||
assert_eq!(relatives.get("max"), Some(&"max".to_string()));
|
||||
assert_eq!(relatives.get("min"), Some(&"min".to_string()));
|
||||
}
|
||||
PatternMode::Prefix { .. } => {
|
||||
panic!("Expected suffix mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
//! 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, HashMap};
|
||||
|
||||
use brk_types::{Index, TreeNode, extract_json_type};
|
||||
|
||||
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`).
|
||||
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.
|
||||
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.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();
|
||||
fields.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
fields
|
||||
}
|
||||
|
||||
/// Detect index patterns (sets of indexes that appear together on metrics).
|
||||
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!("MetricPattern{}", 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: HashMap<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: HashMap::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: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the metric 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 metric 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: HashMap<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 = HashMap::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 = HashMap::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: &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.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::{MetricLeaf, MetricLeafWithSchema, TreeNode};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn make_leaf(name: &str) -> TreeNode {
|
||||
let leaf = MetricLeaf {
|
||||
name: name.to_string(),
|
||||
kind: "TestType".to_string(),
|
||||
indexes: BTreeSet::new(),
|
||||
};
|
||||
TreeNode::Leaf(MetricLeafWithSchema::new(leaf, serde_json::json!({})))
|
||||
}
|
||||
|
||||
fn make_branch(children: Vec<(&str, TreeNode)>) -> TreeNode {
|
||||
let map: BTreeMap<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![("dateindex", make_leaf("block_vbytes"))]),
|
||||
),
|
||||
(
|
||||
"average",
|
||||
make_branch(vec![("dateindex", make_leaf("block_vbytes_average"))]),
|
||||
),
|
||||
(
|
||||
"sum",
|
||||
make_branch(vec![("dateindex", 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 metrics
|
||||
let tree = make_branch(vec![
|
||||
(
|
||||
"average",
|
||||
make_branch(vec![("dateindex", make_leaf("block_weight_average"))]),
|
||||
),
|
||||
(
|
||||
"sum",
|
||||
make_branch(vec![("dateindex", make_leaf("block_weight_sum"))]),
|
||||
),
|
||||
(
|
||||
"cumulative",
|
||||
make_branch(vec![("dateindex", make_leaf("block_weight_cumulative"))]),
|
||||
),
|
||||
(
|
||||
"max",
|
||||
make_branch(vec![("dateindex", make_leaf("block_weight_max"))]),
|
||||
),
|
||||
(
|
||||
"min",
|
||||
make_branch(vec![("dateindex", 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![("dateindex", make_leaf("block_weight_average"))]),
|
||||
),
|
||||
(
|
||||
"average",
|
||||
make_branch(vec![("dateindex", make_leaf("block_weight_average"))]),
|
||||
),
|
||||
(
|
||||
"sum",
|
||||
make_branch(vec![("dateindex", 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 metrics 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 metrics 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 metric names are used directly.
|
||||
let tree = make_branch(vec![
|
||||
("alpha", make_leaf("foo_metric")),
|
||||
("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_1d_ago, price_1w_ago, price_10y_ago
|
||||
// Common prefix is "price_", so this is suffix mode
|
||||
let tree = make_branch(vec![
|
||||
("_1d", make_leaf("price_1d_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, "1d_ago")
|
||||
assert!(!result.has_outlier);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_pattern_instance_base_prefix_mode_price_returns() {
|
||||
// Simulates price_returns pattern: 1d_price_returns, 1w_price_returns, 10y_price_returns
|
||||
// Common suffix is "_price_returns", so this is prefix mode
|
||||
let tree = make_branch(vec![
|
||||
("_1d", make_leaf("1d_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("1d_", 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,62 @@
|
||||
//! 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)
|
||||
}
|
||||
}
|
||||
@@ -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,57 @@
|
||||
//! 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Rust language syntax implementation.
|
||||
|
||||
use crate::{GenericSyntax, LanguageSyntax, to_snake_case};
|
||||
|
||||
/// Rust-specific code generation syntax.
|
||||
pub struct RustSyntax;
|
||||
|
||||
impl LanguageSyntax for RustSyntax {
|
||||
fn field_name(&self, name: &str) -> String {
|
||||
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() {
|
||||
// Identity: just return acc
|
||||
format!("{}.clone()", acc_var)
|
||||
} else {
|
||||
// _m(&acc, relative) -> if acc.is_empty() { relative } else { format!("{acc}_{relative}") }
|
||||
format!("_m(&{}, \"{}\")", acc_var, relative)
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix_expr(&self, prefix: &str, acc_var: &str) -> String {
|
||||
if prefix.is_empty() {
|
||||
// Identity: just return acc
|
||||
format!("{}.clone()", acc_var)
|
||||
} else {
|
||||
// _p(prefix, &acc) -> if acc.is_empty() { prefix_base } else { format!("{prefix}{acc}") }
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! 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, EPOCH_NAMES, GE_AMOUNT_NAMES, LT_AMOUNT_NAMES,
|
||||
MAX_AGE_NAMES, MIN_AGE_NAMES, SPENDABLE_TYPE_NAMES, TERM_NAMES, YEAR_NAMES,
|
||||
};
|
||||
use brk_types::{pools, Index, PoolSlug};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{to_camel_case, VERSION};
|
||||
|
||||
/// Collected constant data for client generation.
|
||||
pub struct ClientConstants {
|
||||
pub version: String,
|
||||
pub indexes: Vec<&'static str>,
|
||||
pub pool_map: BTreeMap<PoolSlug, &'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.serialize_long()).collect();
|
||||
|
||||
let pools = pools();
|
||||
let mut sorted_pools: Vec<_> = pools.iter().collect();
|
||||
sorted_pools.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
let pool_map: BTreeMap<PoolSlug, &'static str> =
|
||||
sorted_pools.iter().map(|p| (p.slug(), 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)),
|
||||
("YEAR_NAMES", to_value(&YEAR_NAMES)),
|
||||
("SPENDABLE_TYPE_NAMES", to_value(&SPENDABLE_TYPE_NAMES)),
|
||||
("AGE_RANGE_NAMES", to_value(&AGE_RANGE_NAMES)),
|
||||
("MAX_AGE_NAMES", to_value(&MAX_AGE_NAMES)),
|
||||
("MIN_AGE_NAMES", to_value(&MIN_AGE_NAMES)),
|
||||
("AMOUNT_RANGE_NAMES", to_value(&AMOUNT_RANGE_NAMES)),
|
||||
("GE_AMOUNT_NAMES", to_value(&GE_AMOUNT_NAMES)),
|
||||
("LT_AMOUNT_NAMES", to_value(<_AMOUNT_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,181 @@
|
||||
//! 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::MetricLeafWithSchema;
|
||||
|
||||
use crate::{ClientMetadata, LanguageSyntax, PatternField, StructuralPattern};
|
||||
|
||||
/// Create a path suffix from a name.
|
||||
/// Adds `_` prefix only if the name doesn't already start with `_`.
|
||||
fn path_suffix(name: &str) -> String {
|
||||
if name.starts_with('_') {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("_{}", name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute path expression from pattern mode and field part.
|
||||
fn compute_path_expr<S: LanguageSyntax>(
|
||||
syntax: &S,
|
||||
pattern: &StructuralPattern,
|
||||
field: &PatternField,
|
||||
base_var: &str,
|
||||
) -> String {
|
||||
match pattern.get_field_part(&field.name) {
|
||||
Some(part) => {
|
||||
if pattern.is_suffix_mode() {
|
||||
syntax.suffix_expr(base_var, part)
|
||||
} else {
|
||||
syntax.prefix_expr(part, base_var)
|
||||
}
|
||||
}
|
||||
None => syntax.path_expr(base_var, &path_suffix(&field.name)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute field value from path expression.
|
||||
fn compute_field_value<S: LanguageSyntax>(
|
||||
syntax: &S,
|
||||
field: &PatternField,
|
||||
metadata: &ClientMetadata,
|
||||
path_expr: &str,
|
||||
) -> String {
|
||||
if metadata.is_pattern_type(&field.rust_type) {
|
||||
syntax.constructor(&field.rust_type, path_expr)
|
||||
} else if let Some(accessor) = metadata.find_index_set_pattern(&field.indexes) {
|
||||
syntax.constructor(&accessor.name, path_expr)
|
||||
} else if field.is_branch() {
|
||||
syntax.constructor(&field.rust_type, path_expr)
|
||||
} else {
|
||||
panic!(
|
||||
"Field '{}' has no matching pattern or index accessor. All metrics must be indexed.",
|
||||
field.name
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a parameterized field using the language syntax.
|
||||
///
|
||||
/// This is used for pattern instances where fields use an accumulated
|
||||
/// metric name that's built up through the tree traversal.
|
||||
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 path_expr = compute_path_expr(syntax, pattern, field, "acc");
|
||||
let value = compute_field_value(syntax, field, metadata, &path_expr);
|
||||
|
||||
writeln!(output, "{}", syntax.field_init(indent, &field_name, &type_ann, &value)).unwrap();
|
||||
}
|
||||
|
||||
/// Generate a tree node field with a specific child node for pattern instance base detection.
|
||||
///
|
||||
/// This is used when generating tree nodes where we need to detect the pattern instance
|
||||
/// base from descendant leaf names.
|
||||
pub fn generate_tree_node_field<S: LanguageSyntax>(
|
||||
output: &mut String,
|
||||
syntax: &S,
|
||||
field: &PatternField,
|
||||
metadata: &ClientMetadata,
|
||||
indent: &str,
|
||||
child_name: &str,
|
||||
pattern_base: Option<&str>,
|
||||
) {
|
||||
let field_name = syntax.field_name(&field.name);
|
||||
let type_ann = metadata.field_type_annotation(field, false, None, syntax.generic_syntax());
|
||||
|
||||
let value = if metadata.is_pattern_type(&field.rust_type) {
|
||||
// Use metric base only for parameterizable patterns
|
||||
let use_base = metadata
|
||||
.find_pattern(&field.rust_type)
|
||||
.is_some_and(|p| p.is_parameterizable())
|
||||
&& pattern_base.is_some();
|
||||
|
||||
let path_arg = if use_base {
|
||||
syntax.string_literal(pattern_base.unwrap())
|
||||
} else {
|
||||
syntax.path_expr("base_path", &path_suffix(child_name))
|
||||
};
|
||||
syntax.constructor(&field.rust_type, &path_arg)
|
||||
} else if let Some(accessor) = metadata.find_index_set_pattern(&field.indexes) {
|
||||
// Leaf field - use metric name if provided, else tree path
|
||||
let path_arg = pattern_base
|
||||
.map(|name| syntax.string_literal(name))
|
||||
.unwrap_or_else(|| syntax.path_expr("base_path", &path_suffix(child_name)));
|
||||
syntax.constructor(&accessor.name, &path_arg)
|
||||
} else if field.is_branch() {
|
||||
// Non-pattern branch - instantiate the nested struct
|
||||
let path_expr = syntax.path_expr("base_path", &path_suffix(child_name));
|
||||
syntax.constructor(&field.rust_type, &path_expr)
|
||||
} else {
|
||||
// All metrics must be indexed
|
||||
panic!(
|
||||
"Field '{}' is a leaf with no index accessor. All metrics must be indexed.",
|
||||
field.name
|
||||
)
|
||||
};
|
||||
|
||||
writeln!(output, "{}", syntax.field_init(indent, &field_name, &type_ann, &value)).unwrap();
|
||||
}
|
||||
|
||||
/// Generate a leaf field using the actual metric name from the TreeNode::Leaf.
|
||||
///
|
||||
/// This is the shared implementation for all language backends. It uses
|
||||
/// `leaf.name()` directly to get the correct metric 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 metric 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: &MetricLeafWithSchema,
|
||||
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!(
|
||||
"Metric '{}' has no matching index pattern. All metrics must be indexed.",
|
||||
leaf.name()
|
||||
)
|
||||
});
|
||||
|
||||
let type_ann = metadata.field_type_annotation_from_leaf(leaf, syntax.generic_syntax());
|
||||
let metric_name = syntax.string_literal(leaf.name());
|
||||
let value = format!(
|
||||
"{}({}, {})",
|
||||
syntax.constructor_name(&accessor.name),
|
||||
client_expr,
|
||||
metric_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,154 @@
|
||||
//! Shared tree generation helpers.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
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.
|
||||
pub field: PatternField,
|
||||
/// Child fields if this is a branch (for pattern lookup).
|
||||
pub child_fields: Option<Vec<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: &HashMap<Vec<PatternField>, String>,
|
||||
metadata: &ClientMetadata,
|
||||
generated: &mut HashSet<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), (field, child_fields))| {
|
||||
let is_leaf = matches!(child_node, TreeNode::Leaf(_));
|
||||
|
||||
// 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);
|
||||
|
||||
// For type annotations: use pattern type if ANY pattern matches
|
||||
let matches_any_pattern = child_fields
|
||||
.as_ref()
|
||||
.is_some_and(|cf| metadata.matches_pattern(cf));
|
||||
|
||||
// Check if the pattern mode AND field parts match the instance
|
||||
// Uses is_none_or so that "no pattern" doesn't trigger inlining
|
||||
let pattern_compatible = child_fields
|
||||
.as_ref()
|
||||
.and_then(|cf| metadata.find_pattern_by_fields(cf))
|
||||
.is_none_or(|p| {
|
||||
p.is_suffix_mode() == base_result.is_suffix_mode
|
||||
&& p.field_parts_match(&base_result.field_parts)
|
||||
});
|
||||
|
||||
// should_inline determines if we generate an inline struct type
|
||||
// We inline if: it's a branch AND (doesn't match any pattern OR pattern incompatible OR has outlier)
|
||||
let should_inline =
|
||||
!is_leaf && (!matches_any_pattern || !pattern_compatible || base_result.has_outlier);
|
||||
|
||||
// Inline type name (only used when should_inline is true)
|
||||
let inline_type_name = if should_inline {
|
||||
child_type_name(name, child_name)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
ChildContext {
|
||||
name: child_name,
|
||||
node: child_node,
|
||||
field,
|
||||
child_fields,
|
||||
base_result,
|
||||
is_leaf,
|
||||
should_inline,
|
||||
inline_type_name,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(TreeNodeContext { children })
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! JavaScript API method generation.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::{Endpoint, Parameter, generators::{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;
|
||||
}
|
||||
|
||||
let method_name = endpoint_to_method_name(endpoint);
|
||||
let base_return_type =
|
||||
normalize_return_type(endpoint.response_type.as_deref().unwrap_or("*"));
|
||||
let return_type = if endpoint.supports_csv {
|
||||
format!("{} | string", base_return_type)
|
||||
} else {
|
||||
base_return_type
|
||||
};
|
||||
|
||||
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, " * ", " *");
|
||||
}
|
||||
|
||||
// Add endpoint path
|
||||
writeln!(output, " *").unwrap();
|
||||
writeln!(output, " * Endpoint: `{} {}`", endpoint.method.to_uppercase(), endpoint.path).unwrap();
|
||||
|
||||
if !endpoint.path_params.is_empty() || !endpoint.query_params.is_empty() {
|
||||
writeln!(output, " *").unwrap();
|
||||
}
|
||||
|
||||
for param in &endpoint.path_params {
|
||||
let desc = format_param_desc(param.description.as_deref());
|
||||
writeln!(
|
||||
output,
|
||||
" * @param {{{}}} {}{}",
|
||||
param.param_type, 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());
|
||||
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.getJson(`{}`);", 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, " const path = `{}${{query ? '?' + query : ''}}`;", path).unwrap();
|
||||
|
||||
if endpoint.supports_csv {
|
||||
writeln!(output, " if (format === 'csv') {{").unwrap();
|
||||
writeln!(output, " return this.getText(path);").unwrap();
|
||||
writeln!(output, " }}").unwrap();
|
||||
writeln!(output, " return this.getJson(path);").unwrap();
|
||||
} else {
|
||||
writeln!(output, " return this.getJson(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: &[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,481 @@
|
||||
//! 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}} [cache] - Enable browser cache with default name (true), custom name (string), or disable (false). No effect in Node.js. Default: true
|
||||
*/
|
||||
|
||||
const _isBrowser = typeof window !== 'undefined' && 'caches' in window;
|
||||
const _runIdle = (/** @type {{VoidFunction}} */ fn) => (globalThis.requestIdleCallback ?? setTimeout)(fn);
|
||||
const _defaultCacheName = '__BRK_CLIENT__';
|
||||
|
||||
/**
|
||||
* @param {{string|boolean|undefined}} cache
|
||||
* @returns {{Promise<Cache | null>}}
|
||||
*/
|
||||
const _openCache = (cache) => {{
|
||||
if (!_isBrowser || cache === false) return Promise.resolve(null);
|
||||
const name = typeof cache === 'string' ? cache : _defaultCacheName;
|
||||
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;
|
||||
}}
|
||||
}}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {{Object}} MetricData
|
||||
* @property {{number}} total - Total number of data points
|
||||
* @property {{number}} start - Start index (inclusive)
|
||||
* @property {{number}} end - End index (exclusive)
|
||||
* @property {{T[]}} data - The metric data
|
||||
*/
|
||||
/** @typedef {{MetricData<any>}} AnyMetricData */
|
||||
|
||||
/**
|
||||
* Thenable interface for await support.
|
||||
* @template T
|
||||
* @typedef {{(onfulfilled?: (value: MetricData<T>) => MetricData<T>, onrejected?: (reason: Error) => never) => Promise<MetricData<T>>}} Thenable
|
||||
*/
|
||||
|
||||
/**
|
||||
* Metric endpoint builder. Callable (returns itself) so both .by.dateindex and .by.dateindex() work.
|
||||
* @template T
|
||||
* @typedef {{Object}} MetricEndpointBuilder
|
||||
* @property {{(index: number) => SingleItemBuilder<T>}} get - Get single item at index
|
||||
* @property {{(start?: number, end?: number) => RangeBuilder<T>}} slice - Slice like Array.slice
|
||||
* @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 {{(onUpdate?: (value: MetricData<T>) => void) => Promise<MetricData<T>>}} fetch - Fetch all data
|
||||
* @property {{() => Promise<string>}} fetchCsv - Fetch all data as CSV
|
||||
* @property {{Thenable<T>}} then - Thenable (await endpoint)
|
||||
* @property {{string}} path - The endpoint path
|
||||
*/
|
||||
/** @typedef {{MetricEndpointBuilder<any>}} AnyMetricEndpointBuilder */
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {{Object}} SingleItemBuilder
|
||||
* @property {{(onUpdate?: (value: MetricData<T>) => void) => Promise<MetricData<T>>}} fetch - Fetch the item
|
||||
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
|
||||
* @property {{Thenable<T>}} then - Thenable
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {{Object}} SkippedBuilder
|
||||
* @property {{(n: number) => RangeBuilder<T>}} take - Take n items after skipped position
|
||||
* @property {{(onUpdate?: (value: MetricData<T>) => void) => Promise<MetricData<T>>}} fetch - Fetch from skipped position to end
|
||||
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
|
||||
* @property {{Thenable<T>}} then - Thenable
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {{Object}} RangeBuilder
|
||||
* @property {{(onUpdate?: (value: MetricData<T>) => void) => Promise<MetricData<T>>}} fetch - Fetch the range
|
||||
* @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
|
||||
* @property {{Thenable<T>}} then - Thenable
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {{Object}} MetricPattern
|
||||
* @property {{string}} name - The metric name
|
||||
* @property {{Readonly<Partial<Record<Index, MetricEndpointBuilder<T>>>>}} by - Index endpoints as lazy getters. Access via .by.dateindex or .by['dateindex']
|
||||
* @property {{() => readonly Index[]}} indexes - Get the list of available indexes
|
||||
* @property {{(index: Index) => MetricEndpointBuilder<T>|undefined}} get - Get an endpoint for a specific index
|
||||
*/
|
||||
|
||||
/** @typedef {{MetricPattern<any>}} AnyMetricPattern */
|
||||
|
||||
/**
|
||||
* Create a metric endpoint builder with typestate pattern.
|
||||
* @template T
|
||||
* @param {{BrkClientBase}} client
|
||||
* @param {{string}} name - The metric vec name
|
||||
* @param {{Index}} index - The index name
|
||||
* @returns {{MetricEndpointBuilder<T>}}
|
||||
*/
|
||||
function _endpoint(client, name, index) {{
|
||||
const p = `/api/metric/${{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 {{RangeBuilder<T>}}
|
||||
*/
|
||||
const rangeBuilder = (start, end) => ({{
|
||||
fetch(onUpdate) {{ return client.getJson(buildPath(start, end), onUpdate); }},
|
||||
fetchCsv() {{ return client.getText(buildPath(start, end, 'csv')); }},
|
||||
then(resolve, reject) {{ return this.fetch().then(resolve, reject); }},
|
||||
}});
|
||||
|
||||
/**
|
||||
* @param {{number}} index
|
||||
* @returns {{SingleItemBuilder<T>}}
|
||||
*/
|
||||
const singleItemBuilder = (index) => ({{
|
||||
fetch(onUpdate) {{ return client.getJson(buildPath(index, index + 1), onUpdate); }},
|
||||
fetchCsv() {{ return client.getText(buildPath(index, index + 1, 'csv')); }},
|
||||
then(resolve, reject) {{ return this.fetch().then(resolve, reject); }},
|
||||
}});
|
||||
|
||||
/**
|
||||
* @param {{number}} start
|
||||
* @returns {{SkippedBuilder<T>}}
|
||||
*/
|
||||
const skippedBuilder = (start) => ({{
|
||||
take(n) {{ return rangeBuilder(start, start + n); }},
|
||||
fetch(onUpdate) {{ return client.getJson(buildPath(start, undefined), onUpdate); }},
|
||||
fetchCsv() {{ return client.getText(buildPath(start, undefined, 'csv')); }},
|
||||
then(resolve, reject) {{ return this.fetch().then(resolve, reject); }},
|
||||
}});
|
||||
|
||||
/** @type {{MetricEndpointBuilder<T>}} */
|
||||
const endpoint = {{
|
||||
get(index) {{ return singleItemBuilder(index); }},
|
||||
slice(start, 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(onUpdate) {{ return client.getJson(buildPath(), onUpdate); }},
|
||||
fetchCsv() {{ return client.getText(buildPath(undefined, undefined, 'csv')); }},
|
||||
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';
|
||||
this.baseUrl = isString ? options : options.baseUrl;
|
||||
this.timeout = isString ? 5000 : (options.timeout ?? 5000);
|
||||
/** @type {{Promise<Cache | null>}} */
|
||||
this._cachePromise = _openCache(isString ? undefined : options.cache);
|
||||
}}
|
||||
|
||||
/**
|
||||
* @param {{string}} path
|
||||
* @returns {{Promise<Response>}}
|
||||
*/
|
||||
async get(path) {{
|
||||
const base = this.baseUrl.endsWith('/') ? this.baseUrl.slice(0, -1) : this.baseUrl;
|
||||
const url = `${{base}}${{path}}`;
|
||||
const res = await fetch(url, {{ signal: AbortSignal.timeout(this.timeout) }});
|
||||
if (!res.ok) throw new BrkError(`HTTP ${{res.status}}: ${{url}}`, res.status);
|
||||
return res;
|
||||
}}
|
||||
|
||||
/**
|
||||
* 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>}}
|
||||
*/
|
||||
async getJson(path, onUpdate) {{
|
||||
const base = this.baseUrl.endsWith('/') ? this.baseUrl.slice(0, -1) : this.baseUrl;
|
||||
const url = `${{base}}${{path}}`;
|
||||
const cache = await this._cachePromise;
|
||||
const cachedRes = await cache?.match(url);
|
||||
const cachedJson = cachedRes ? await cachedRes.json() : null;
|
||||
|
||||
if (cachedJson) onUpdate?.(cachedJson);
|
||||
if (globalThis.navigator?.onLine === false) {{
|
||||
if (cachedJson) return cachedJson;
|
||||
throw new BrkError('Offline and no cached data available');
|
||||
}}
|
||||
|
||||
try {{
|
||||
const res = await this.get(path);
|
||||
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;
|
||||
}}
|
||||
}}
|
||||
|
||||
/**
|
||||
* Make a GET request and return raw text (for CSV responses)
|
||||
* @param {{string}} path
|
||||
* @returns {{Promise<string>}}
|
||||
*/
|
||||
async getText(path) {{
|
||||
const res = await this.get(path);
|
||||
return res.text();
|
||||
}}
|
||||
}}
|
||||
|
||||
/**
|
||||
* Build metric name with suffix.
|
||||
* @param {{string}} acc - Accumulated prefix
|
||||
* @param {{string}} s - Metric suffix
|
||||
* @returns {{string}}
|
||||
*/
|
||||
const _m = (acc, s) => s ? (acc ? `${{acc}}_${{s}}` : s) : acc;
|
||||
|
||||
/**
|
||||
* Build metric 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)));
|
||||
}
|
||||
}
|
||||
|
||||
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 = ["dateindex", "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.serialize_long()).unwrap();
|
||||
}
|
||||
writeln!(output, "]);").unwrap();
|
||||
}
|
||||
writeln!(output).unwrap();
|
||||
|
||||
// Generate ONE generic metric pattern factory
|
||||
writeln!(
|
||||
output,
|
||||
r#"/**
|
||||
* Generic metric pattern factory.
|
||||
* @template T
|
||||
* @param {{BrkClientBase}} client
|
||||
* @param {{string}} name - The metric vec name
|
||||
* @param {{readonly Index[]}} indexes - The supported indexes
|
||||
*/
|
||||
function _mp(client, name, indexes) {{
|
||||
const by = /** @type {{any}} */ ({{}});
|
||||
for (const idx of indexes) {{
|
||||
Object.defineProperty(by, idx, {{
|
||||
get() {{ return _endpoint(client, name, idx); }},
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
}});
|
||||
}}
|
||||
return {{
|
||||
name,
|
||||
by,
|
||||
indexes() {{ return indexes; }},
|
||||
/** @param {{Index}} index */
|
||||
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| {
|
||||
format!(
|
||||
"readonly {}: MetricEndpointBuilder<T>",
|
||||
idx.serialize_long()
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let by_type = format!("{{ {} }}", by_fields.join(", "));
|
||||
|
||||
writeln!(
|
||||
output,
|
||||
"/** @template T @typedef {{{{ name: string, by: {}, indexes: () => readonly Index[], get: (index: Index) => MetricEndpointBuilder<T>|undefined }}}} {} */",
|
||||
by_type, pattern.name
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Generate thin wrapper that calls the generic factory
|
||||
writeln!(
|
||||
output,
|
||||
"/** @template T @param {{BrkClientBase}} client @param {{string}} name @returns {{{}<T>}} */",
|
||||
pattern.name
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
"function create{}(client, name) {{ return _mp(client, name, _i{}); }}",
|
||||
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();
|
||||
|
||||
// Generate factory function for ALL patterns
|
||||
writeln!(output, "/**").unwrap();
|
||||
writeln!(output, " * Create a {} pattern node", pattern.name).unwrap();
|
||||
if pattern.is_generic {
|
||||
writeln!(output, " * @template T").unwrap();
|
||||
}
|
||||
writeln!(output, " * @param {{BrkClientBase}} client").unwrap();
|
||||
writeln!(output, " * @param {{string}} acc - Accumulated metric name").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();
|
||||
|
||||
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,65 @@
|
||||
//! 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 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);
|
||||
|
||||
fs::write(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))?;
|
||||
|
||||
fs::write(package_json_path, updated + "\n")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! JavaScript tree structure generation.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Write;
|
||||
|
||||
use brk_types::TreeNode;
|
||||
|
||||
use crate::{
|
||||
ClientMetadata, Endpoint, GenericSyntax, JavaScriptSyntax, PatternField, build_child_path,
|
||||
generate_leaf_field, prepare_tree_node, to_camel_case,
|
||||
};
|
||||
|
||||
use super::api::generate_api_methods;
|
||||
use super::client::generate_static_constants;
|
||||
|
||||
/// Generate JSDoc typedefs for the metrics 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 = HashSet::new();
|
||||
generate_tree_typedef(
|
||||
output,
|
||||
"MetricsTree",
|
||||
"",
|
||||
catalog,
|
||||
&pattern_lookup,
|
||||
metadata,
|
||||
&mut generated,
|
||||
);
|
||||
}
|
||||
|
||||
fn generate_tree_typedef(
|
||||
output: &mut String,
|
||||
name: &str,
|
||||
path: &str,
|
||||
node: &TreeNode,
|
||||
pattern_lookup: &std::collections::HashMap<Vec<PatternField>, String>,
|
||||
metadata: &ClientMetadata,
|
||||
generated: &mut HashSet<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.resolve_tree_field_type(
|
||||
&child.field,
|
||||
child.child_fields.as_deref(),
|
||||
name,
|
||||
child.name,
|
||||
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 metrics 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 {{MetricsTree}} */").unwrap();
|
||||
writeln!(output, " this.metrics = this._buildTree('');").unwrap();
|
||||
writeln!(output, " }}\n").unwrap();
|
||||
|
||||
writeln!(output, " /**").unwrap();
|
||||
writeln!(output, " * @private").unwrap();
|
||||
writeln!(output, " * @param {{string}} basePath").unwrap();
|
||||
writeln!(output, " * @returns {{MetricsTree}}").unwrap();
|
||||
writeln!(output, " */").unwrap();
|
||||
writeln!(output, " _buildTree(basePath) {{").unwrap();
|
||||
writeln!(output, " return {{").unwrap();
|
||||
let mut generated = HashSet::new();
|
||||
generate_tree_initializer(
|
||||
output,
|
||||
catalog,
|
||||
"MetricsTree",
|
||||
"",
|
||||
3,
|
||||
&pattern_lookup,
|
||||
metadata,
|
||||
&mut generated,
|
||||
);
|
||||
writeln!(output, " }};").unwrap();
|
||||
writeln!(output, " }}\n").unwrap();
|
||||
|
||||
writeln!(output, " /**").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" * Create a dynamic metric endpoint builder for any metric/index combination."
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " *").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" * Use this for programmatic access when the metric name is determined at runtime."
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" * For type-safe access, use the `metrics` tree instead."
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, " *").unwrap();
|
||||
writeln!(output, " * @param {{string}} metric - The metric name").unwrap();
|
||||
writeln!(output, " * @param {{Index}} index - The index name").unwrap();
|
||||
writeln!(output, " * @returns {{MetricEndpointBuilder<unknown>}}").unwrap();
|
||||
writeln!(output, " */").unwrap();
|
||||
writeln!(output, " metric(metric, index) {{").unwrap();
|
||||
writeln!(output, " return _endpoint(this, metric, 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::HashMap<Vec<PatternField>, String>,
|
||||
metadata: &ClientMetadata,
|
||||
generated: &mut HashSet<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 {
|
||||
// Use pattern factory
|
||||
writeln!(
|
||||
output,
|
||||
"{}{}: create{}(this, '{}'),",
|
||||
indent_str, field_name, child.field.rust_type, child.base_result.base
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! 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);
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,43 @@
|
||||
//! 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;
|
||||
|
||||
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] = &["MetricData", "MetricEndpoint"];
|
||||
|
||||
/// 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
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! 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 metrics 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.metrics = MetricsTree(self)").unwrap();
|
||||
writeln!(output).unwrap();
|
||||
|
||||
// Generate metric() method for dynamic metric access
|
||||
writeln!(output, " def metric(self, metric: str, index: Index) -> MetricEndpointBuilder[Any]:").unwrap();
|
||||
writeln!(output, " \"\"\"Create a dynamic metric endpoint builder for any metric/index combination.").unwrap();
|
||||
writeln!(output).unwrap();
|
||||
writeln!(output, " Use this for programmatic access when the metric name is determined at runtime.").unwrap();
|
||||
writeln!(output, " For type-safe access, use the `metrics` tree instead.").unwrap();
|
||||
writeln!(output, " \"\"\"").unwrap();
|
||||
writeln!(output, " return MetricEndpointBuilder(self, metric, index)").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 = normalize_return_type(
|
||||
&endpoint
|
||||
.response_type
|
||||
.as_deref()
|
||||
.map(js_type_to_python)
|
||||
.unwrap_or_else(|| "Any".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);
|
||||
|
||||
if endpoint.query_params.is_empty() {
|
||||
if endpoint.path_params.is_empty() {
|
||||
writeln!(output, " return self.get_json('{}')", path).unwrap();
|
||||
} else {
|
||||
writeln!(output, " return self.get_json(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,
|
||||
" 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.get_json(path)").unwrap();
|
||||
} else {
|
||||
writeln!(output, " return self.get_json(path)").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));
|
||||
}
|
||||
}
|
||||
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,472 @@
|
||||
//! 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 close(self):
|
||||
"""Close the HTTP client."""
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
|
||||
def _m(acc: str, s: str) -> str:
|
||||
"""Build metric name with suffix."""
|
||||
if not s: return acc
|
||||
return f"{{acc}}_{{s}}" if acc else s
|
||||
|
||||
|
||||
def _p(prefix: str, acc: str) -> str:
|
||||
"""Build metric name with prefix."""
|
||||
return f"{{prefix}}_{{acc}}" if acc else prefix
|
||||
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Generate the MetricData and MetricEndpointBuilder classes
|
||||
pub fn generate_endpoint_class(output: &mut String) {
|
||||
writeln!(
|
||||
output,
|
||||
r#"class MetricData(TypedDict, Generic[T]):
|
||||
"""Metric data with range information."""
|
||||
total: int
|
||||
start: int
|
||||
end: int
|
||||
data: List[T]
|
||||
|
||||
|
||||
# Type alias for non-generic usage
|
||||
AnyMetricData = MetricData[Any]
|
||||
|
||||
|
||||
class _EndpointConfig:
|
||||
"""Shared endpoint configuration."""
|
||||
client: BrkClientBase
|
||||
name: str
|
||||
index: Index
|
||||
start: Optional[int]
|
||||
end: Optional[int]
|
||||
|
||||
def __init__(self, client: BrkClientBase, 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/metric/{{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 get_json(self) -> Any:
|
||||
return self.client.get_json(self._build_path())
|
||||
|
||||
def get_csv(self) -> str:
|
||||
return self.client.get_text(self._build_path(format='csv'))
|
||||
|
||||
|
||||
class RangeBuilder(Generic[T]):
|
||||
"""Builder with range specified."""
|
||||
|
||||
def __init__(self, config: _EndpointConfig):
|
||||
self._config = config
|
||||
|
||||
def fetch(self) -> MetricData[T]:
|
||||
"""Fetch the range as parsed JSON."""
|
||||
return self._config.get_json()
|
||||
|
||||
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) -> MetricData[T]:
|
||||
"""Fetch the single item."""
|
||||
return self._config.get_json()
|
||||
|
||||
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(_EndpointConfig(
|
||||
self._config.client, self._config.name, self._config.index,
|
||||
start, start + n
|
||||
))
|
||||
|
||||
def fetch(self) -> MetricData[T]:
|
||||
"""Fetch from skipped position to end."""
|
||||
return self._config.get_json()
|
||||
|
||||
def fetch_csv(self) -> str:
|
||||
"""Fetch as CSV."""
|
||||
return self._config.get_csv()
|
||||
|
||||
|
||||
class MetricEndpointBuilder(Generic[T]):
|
||||
"""Builder for metric endpoint queries.
|
||||
|
||||
Use method chaining to specify the data range, then call fetch() or fetch_csv() to execute.
|
||||
|
||||
Examples:
|
||||
# Fetch all data
|
||||
data = endpoint.fetch()
|
||||
|
||||
# Single item access
|
||||
data = endpoint[5].fetch()
|
||||
|
||||
# Slice syntax (Python-native)
|
||||
data = endpoint[:10].fetch() # First 10
|
||||
data = endpoint[-5:].fetch() # Last 5
|
||||
data = endpoint[100:110].fetch() # Range
|
||||
|
||||
# Convenience methods (pandas-style)
|
||||
data = endpoint.head().fetch() # First 10 (default)
|
||||
data = endpoint.head(20).fetch() # First 20
|
||||
data = endpoint.tail(5).fetch() # Last 5
|
||||
|
||||
# Iterator-style chaining
|
||||
data = endpoint.skip(100).take(10).fetch()
|
||||
"""
|
||||
|
||||
def __init__(self, client: BrkClientBase, 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.
|
||||
|
||||
Examples:
|
||||
endpoint[5] # Single item at index 5
|
||||
endpoint[:10] # First 10
|
||||
endpoint[-5:] # Last 5
|
||||
endpoint[100:110] # Range 100-109
|
||||
"""
|
||||
if isinstance(key, int):
|
||||
return SingleItemBuilder(_EndpointConfig(
|
||||
self._config.client, self._config.name, self._config.index,
|
||||
key, key + 1
|
||||
))
|
||||
return RangeBuilder(_EndpointConfig(
|
||||
self._config.client, self._config.name, self._config.index,
|
||||
key.start, key.stop
|
||||
))
|
||||
|
||||
def head(self, n: int = 10) -> RangeBuilder[T]:
|
||||
"""Get the first n items (pandas-style)."""
|
||||
return RangeBuilder(_EndpointConfig(
|
||||
self._config.client, self._config.name, self._config.index,
|
||||
None, n
|
||||
))
|
||||
|
||||
def tail(self, n: int = 10) -> RangeBuilder[T]:
|
||||
"""Get the last n items (pandas-style)."""
|
||||
start, end = (None, 0) if n == 0 else (-n, None)
|
||||
return RangeBuilder(_EndpointConfig(
|
||||
self._config.client, self._config.name, self._config.index,
|
||||
start, end
|
||||
))
|
||||
|
||||
def skip(self, n: int) -> SkippedBuilder[T]:
|
||||
"""Skip the first n items. Chain with take() to get a range."""
|
||||
return SkippedBuilder(_EndpointConfig(
|
||||
self._config.client, self._config.name, self._config.index,
|
||||
n, None
|
||||
))
|
||||
|
||||
def fetch(self) -> MetricData[T]:
|
||||
"""Fetch all data as parsed JSON."""
|
||||
return self._config.get_json()
|
||||
|
||||
def fetch_csv(self) -> str:
|
||||
"""Fetch all data as CSV string."""
|
||||
return self._config.get_csv()
|
||||
|
||||
def path(self) -> str:
|
||||
"""Get the base endpoint path."""
|
||||
return self._config.path()
|
||||
|
||||
|
||||
# Type alias for non-generic usage
|
||||
AnyMetricEndpointBuilder = MetricEndpointBuilder[Any]
|
||||
|
||||
|
||||
class MetricPattern(Protocol[T]):
|
||||
"""Protocol for metric patterns with different index sets."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get the metric name."""
|
||||
...
|
||||
|
||||
def indexes(self) -> List[str]:
|
||||
"""Get the list of available indexes for this metric."""
|
||||
...
|
||||
|
||||
def get(self, index: Index) -> Optional[MetricEndpointBuilder[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.serialize_long()).unwrap();
|
||||
}
|
||||
// Single-element tuple needs trailing comma
|
||||
if pattern.indexes.len() == 1 {
|
||||
write!(output, ",").unwrap();
|
||||
}
|
||||
writeln!(output, ")").unwrap();
|
||||
}
|
||||
writeln!(output).unwrap();
|
||||
|
||||
// Generate helper function
|
||||
writeln!(
|
||||
output,
|
||||
r#"def _ep(c: BrkClientBase, n: str, i: Index) -> MetricEndpointBuilder:
|
||||
return MetricEndpointBuilder(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: BrkClientBase, 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.serialize_long();
|
||||
writeln!(
|
||||
output,
|
||||
" def {}(self) -> MetricEndpointBuilder[T]: return _ep(self._c, self._n, '{}')",
|
||||
method_name, index_name
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(output).unwrap();
|
||||
|
||||
// Generate the main accessor class
|
||||
writeln!(output, "class {}(Generic[T]):", pattern.name).unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" def __init__(self, c: BrkClientBase, 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[MetricEndpointBuilder[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();
|
||||
writeln!(output, " ").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" def __init__(self, client: BrkClientBase, acc: str):"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" \"\"\"Create pattern node with accumulated metric name.\"\"\""
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let syntax = PythonSyntax;
|
||||
for field in &pattern.fields {
|
||||
generate_parameterized_field(output, &syntax, field, pattern, metadata, " ");
|
||||
}
|
||||
|
||||
writeln!(output).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! 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, fs, io, path::Path};
|
||||
|
||||
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 typing import TypeVar, Generic, Any, Optional, List, Literal, TypedDict, Union, Protocol, overload"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(output, "from http.client import HTTPSConnection, HTTPConnection").unwrap();
|
||||
writeln!(output, "from urllib.parse import urlparse").unwrap();
|
||||
writeln!(output, "import json\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);
|
||||
|
||||
fs::write(output_path, output)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Python tree structure generation.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Write;
|
||||
|
||||
use brk_types::TreeNode;
|
||||
|
||||
use crate::{
|
||||
ClientMetadata, GenericSyntax, PatternField, PythonSyntax, build_child_path,
|
||||
generate_leaf_field, prepare_tree_node, to_snake_case,
|
||||
};
|
||||
|
||||
/// Generate tree classes
|
||||
pub fn generate_tree_classes(output: &mut String, catalog: &TreeNode, metadata: &ClientMetadata) {
|
||||
writeln!(output, "# Metrics tree classes\n").unwrap();
|
||||
|
||||
let pattern_lookup = metadata.pattern_lookup();
|
||||
let mut generated = HashSet::new();
|
||||
generate_tree_class(
|
||||
output,
|
||||
"MetricsTree",
|
||||
"",
|
||||
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::HashMap<Vec<PatternField>, String>,
|
||||
metadata: &ClientMetadata,
|
||||
generated: &mut HashSet<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, " \"\"\"Metrics tree node.\"\"\"").unwrap();
|
||||
writeln!(output, " ").unwrap();
|
||||
writeln!(
|
||||
output,
|
||||
" def __init__(self, client: BrkClientBase, base_path: str = ''):"
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let syntax = PythonSyntax;
|
||||
for child in &ctx.children {
|
||||
let field_name_py = to_snake_case(child.name);
|
||||
|
||||
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 {
|
||||
// Inline class
|
||||
writeln!(
|
||||
output,
|
||||
" self.{}: {} = {}(client)",
|
||||
field_name_py, child.inline_type_name, child.inline_type_name
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
// Use pattern class with metric base
|
||||
let py_type = metadata.resolve_tree_field_type(
|
||||
&child.field,
|
||||
child.child_fields.as_deref(),
|
||||
name,
|
||||
child.name,
|
||||
GenericSyntax::PYTHON,
|
||||
);
|
||||
writeln!(
|
||||
output,
|
||||
" self.{}: {} = {}(client, '{}')",
|
||||
field_name_py, py_type, child.field.rust_type, child.base_result.base
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(output).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Python type definitions generation.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
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: HashSet<_> = 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: 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 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_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<&HashSet<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,194 @@
|
||||
//! 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 metrics tree and API methods.
|
||||
pub struct BrkClient {{
|
||||
base: Arc<BrkClientBase>,
|
||||
metrics: MetricsTree,
|
||||
}}
|
||||
|
||||
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 metrics = MetricsTree::new(base.clone(), String::new());
|
||||
Self {{ base, metrics }}
|
||||
}}
|
||||
|
||||
/// Create a new client with options.
|
||||
pub fn with_options(options: BrkClientOptions) -> Self {{
|
||||
let base = Arc::new(BrkClientBase::with_options(options));
|
||||
let metrics = MetricsTree::new(base.clone(), String::new());
|
||||
Self {{ base, metrics }}
|
||||
}}
|
||||
|
||||
/// Get the metrics tree for navigating metrics.
|
||||
pub fn metrics(&self) -> &MetricsTree {{
|
||||
&self.metrics
|
||||
}}
|
||||
|
||||
/// Create a dynamic metric endpoint builder for any metric/index combination.
|
||||
///
|
||||
/// Use this for programmatic access when the metric name is determined at runtime.
|
||||
/// For type-safe access, use the `metrics()` tree instead.
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// let data = client.metric("realized_price", Index::Height)
|
||||
/// .last(10)
|
||||
/// .json::<f64>()?;
|
||||
/// ```
|
||||
pub fn metric(&self, metric: impl Into<Metric>, index: Index) -> MetricEndpointBuilder<serde_json::Value> {{
|
||||
MetricEndpointBuilder::new(
|
||||
self.base.clone(),
|
||||
Arc::from(metric.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;
|
||||
}
|
||||
|
||||
let method_name = endpoint_to_method_name(endpoint);
|
||||
let base_return_type = endpoint
|
||||
.response_type
|
||||
.as_deref()
|
||||
.map(js_type_to_rust)
|
||||
.unwrap_or_else(|| "serde_json::Value".to_string());
|
||||
|
||||
let return_type = if endpoint.supports_csv {
|
||||
format!("FormatResponse<{}>", base_return_type)
|
||||
} else {
|
||||
base_return_type.clone()
|
||||
};
|
||||
|
||||
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, " /// ", " ///");
|
||||
}
|
||||
// Add endpoint path
|
||||
writeln!(output, " ///").unwrap();
|
||||
writeln!(output, " /// Endpoint: `{} {}`", endpoint.method.to_uppercase(), endpoint.path).unwrap();
|
||||
|
||||
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);
|
||||
|
||||
if endpoint.query_params.is_empty() {
|
||||
writeln!(output, " self.base.get_json(&format!(\"{}\"{}))", path, index_arg).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, " let path = format!(\"{}{{}}\"{}, query_str);", path, index_arg).unwrap();
|
||||
|
||||
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.get_json(&path).map(FormatResponse::Json)").unwrap();
|
||||
writeln!(output, " }}").unwrap();
|
||||
} else {
|
||||
writeln!(output, " self.base.get_json(&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 {
|
||||
let rust_type = param_type_to_rust(¶m.param_type);
|
||||
params.push(format!(", {}: {}", param.name, rust_type));
|
||||
}
|
||||
for param in &endpoint.query_params {
|
||||
let rust_type = param_type_to_rust(¶m.param_type);
|
||||
if param.required {
|
||||
params.push(format!(", {}: {}", param.name, rust_type));
|
||||
} else {
|
||||
params.push(format!(", {}: Option<{}>", param.name, rust_type));
|
||||
}
|
||||
}
|
||||
params.join("")
|
||||
}
|
||||
|
||||
/// Convert parameter type to Rust type for function signatures.
|
||||
fn param_type_to_rust(param_type: &str) -> String {
|
||||
match param_type {
|
||||
"string" | "*" => "&str".to_string(),
|
||||
"integer" | "number" => "i64".to_string(),
|
||||
"boolean" => "bool".to_string(),
|
||||
other => other.to_string(), // Domain types like Index, Metric, Format
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.serialize_long()")
|
||||
} else {
|
||||
(endpoint.path.clone(), "")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
//! Rust base client and pattern factory generation.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::{
|
||||
ClientMetadata, GenericSyntax, IndexSetPattern, RustSyntax, StructuralPattern,
|
||||
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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BrkClientBase {{
|
||||
base_url: String,
|
||||
timeout_secs: u64,
|
||||
}}
|
||||
|
||||
impl BrkClientBase {{
|
||||
/// Create a new client with the given base URL.
|
||||
pub fn new(base_url: impl Into<String>) -> Self {{
|
||||
Self {{
|
||||
base_url: base_url.into(),
|
||||
timeout_secs: 30,
|
||||
}}
|
||||
}}
|
||||
|
||||
/// Create a new client with options.
|
||||
pub fn with_options(options: BrkClientOptions) -> Self {{
|
||||
Self {{
|
||||
base_url: options.base_url,
|
||||
timeout_secs: options.timeout_secs,
|
||||
}}
|
||||
}}
|
||||
|
||||
fn get(&self, path: &str) -> Result<minreq::Response> {{
|
||||
let base = self.base_url.trim_end_matches('/');
|
||||
let url = format!("{{}}{{}}", base, path);
|
||||
let response = minreq::get(&url)
|
||||
.with_timeout(self.timeout_secs)
|
||||
.send()
|
||||
.map_err(|e| BrkError {{ message: e.to_string() }})?;
|
||||
|
||||
if response.status_code >= 400 {{
|
||||
return Err(BrkError {{
|
||||
message: format!("HTTP {{}}", response.status_code),
|
||||
}});
|
||||
}}
|
||||
|
||||
Ok(response)
|
||||
}}
|
||||
|
||||
/// Make a GET request and deserialize JSON response.
|
||||
pub fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {{
|
||||
self.get(path)?
|
||||
.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.get(path)?
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.map_err(|e| BrkError {{ message: e.to_string() }})
|
||||
}}
|
||||
}}
|
||||
|
||||
/// Build metric 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 metric 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 MetricPattern trait.
|
||||
pub fn generate_metric_pattern_trait(output: &mut String) {
|
||||
writeln!(
|
||||
output,
|
||||
r#"/// Non-generic trait for metric patterns (usable in collections).
|
||||
pub trait AnyMetricPattern {{
|
||||
/// Get the metric name.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Get the list of available indexes for this metric.
|
||||
fn indexes(&self) -> &'static [Index];
|
||||
}}
|
||||
|
||||
/// Generic trait for metric patterns with endpoint access.
|
||||
pub trait MetricPattern<T>: AnyMetricPattern {{
|
||||
/// Get an endpoint builder for a specific index, if supported.
|
||||
fn get(&self, index: Index) -> Option<MetricEndpointBuilder<T>>;
|
||||
}}
|
||||
|
||||
"#
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Generate the MetricEndpointBuilder 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/metric/{{}}/{{}}", self.name, self.index.serialize_long())
|
||||
}}
|
||||
|
||||
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))
|
||||
}}
|
||||
}}
|
||||
|
||||
/// Initial builder for metric endpoint queries.
|
||||
///
|
||||
/// Use method chaining to specify the data range, then call `fetch()` or `fetch_csv()` to execute.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```ignore
|
||||
/// // Fetch all data
|
||||
/// let data = endpoint.fetch()?;
|
||||
///
|
||||
/// // Get single item at index 5
|
||||
/// let data = endpoint.get(5).fetch()?;
|
||||
///
|
||||
/// // Get first 10 using range
|
||||
/// let data = endpoint.range(..10).fetch()?;
|
||||
///
|
||||
/// // Get range [100, 200)
|
||||
/// let data = endpoint.range(100..200).fetch()?;
|
||||
///
|
||||
/// // Get first 10 (convenience)
|
||||
/// let data = endpoint.take(10).fetch()?;
|
||||
///
|
||||
/// // Get last 10
|
||||
/// let data = endpoint.last(10).fetch()?;
|
||||
///
|
||||
/// // Iterator-style chaining
|
||||
/// let data = endpoint.skip(100).take(10).fetch()?;
|
||||
/// ```
|
||||
pub struct MetricEndpointBuilder<T> {{
|
||||
config: EndpointConfig,
|
||||
_marker: std::marker::PhantomData<T>,
|
||||
}}
|
||||
|
||||
impl<T: DeserializeOwned> MetricEndpointBuilder<T> {{
|
||||
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> {{
|
||||
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> {{
|
||||
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> {{
|
||||
self.range(..n)
|
||||
}}
|
||||
|
||||
/// Take the last n items.
|
||||
pub fn last(mut self, n: usize) -> RangeBuilder<T> {{
|
||||
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> {{
|
||||
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<MetricData<T>> {{
|
||||
self.config.get_json(None)
|
||||
}}
|
||||
|
||||
/// Fetch all data as CSV string.
|
||||
pub fn fetch_csv(self) -> Result<String> {{
|
||||
self.config.get_text(Some("csv"))
|
||||
}}
|
||||
|
||||
/// Get the base endpoint path.
|
||||
pub fn path(&self) -> String {{
|
||||
self.config.path()
|
||||
}}
|
||||
}}
|
||||
|
||||
/// Builder for single item access.
|
||||
pub struct SingleItemBuilder<T> {{
|
||||
config: EndpointConfig,
|
||||
_marker: std::marker::PhantomData<T>,
|
||||
}}
|
||||
|
||||
impl<T: DeserializeOwned> SingleItemBuilder<T> {{
|
||||
/// Fetch the single item.
|
||||
pub fn fetch(self) -> Result<MetricData<T>> {{
|
||||
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> {{
|
||||
config: EndpointConfig,
|
||||
_marker: std::marker::PhantomData<T>,
|
||||
}}
|
||||
|
||||
impl<T: DeserializeOwned> SkippedBuilder<T> {{
|
||||
/// Take n items after the skipped position.
|
||||
pub fn take(mut self, n: usize) -> RangeBuilder<T> {{
|
||||
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<MetricData<T>> {{
|
||||
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> {{
|
||||
config: EndpointConfig,
|
||||
_marker: std::marker::PhantomData<T>,
|
||||
}}
|
||||
|
||||
impl<T: DeserializeOwned> RangeBuilder<T> {{
|
||||
/// Fetch the range as parsed JSON.
|
||||
pub fn fetch(self) -> Result<MetricData<T>> {{
|
||||
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 function
|
||||
writeln!(
|
||||
output,
|
||||
r#"#[inline]
|
||||
fn _ep<T: DeserializeOwned>(c: &Arc<BrkClientBase>, n: &Arc<str>, i: Index) -> MetricEndpointBuilder<T> {{
|
||||
MetricEndpointBuilder::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);
|
||||
writeln!(
|
||||
output,
|
||||
" pub fn {}(&self) -> MetricEndpointBuilder<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 AnyMetricPattern trait
|
||||
writeln!(
|
||||
output,
|
||||
"impl<T> AnyMetricPattern for {}<T> {{ fn name(&self) -> &str {{ &self.name }} fn indexes(&self) -> &'static [Index] {{ {} }} }}",
|
||||
pattern.name, idx_const
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Implement MetricPattern<T> trait
|
||||
writeln!(
|
||||
output,
|
||||
"impl<T: DeserializeOwned> MetricPattern<T> for {}<T> {{ fn get(&self, index: Index) -> Option<MetricEndpointBuilder<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 = 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();
|
||||
|
||||
// Generate impl block with constructor for ALL patterns
|
||||
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 metric name."
|
||||
)
|
||||
.unwrap();
|
||||
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,44 @@
|
||||
//! 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, fs, io, path::Path};
|
||||
|
||||
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(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_metric_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);
|
||||
|
||||
fs::write(output_path, output)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Rust tree structure generation.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Write;
|
||||
|
||||
use brk_types::TreeNode;
|
||||
|
||||
use crate::{
|
||||
ClientMetadata, GenericSyntax, LanguageSyntax, PatternField, RustSyntax, build_child_path,
|
||||
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, "// Metrics tree\n").unwrap();
|
||||
|
||||
let pattern_lookup = metadata.pattern_lookup();
|
||||
let mut generated = HashSet::new();
|
||||
generate_tree_node(
|
||||
output,
|
||||
"MetricsTree",
|
||||
"",
|
||||
catalog,
|
||||
&pattern_lookup,
|
||||
metadata,
|
||||
&mut generated,
|
||||
);
|
||||
}
|
||||
|
||||
fn generate_tree_node(
|
||||
output: &mut String,
|
||||
name: &str,
|
||||
path: &str,
|
||||
node: &TreeNode,
|
||||
pattern_lookup: &std::collections::HashMap<Vec<PatternField>, String>,
|
||||
metadata: &ClientMetadata,
|
||||
generated: &mut HashSet<String>,
|
||||
) {
|
||||
let Some(ctx) = prepare_tree_node(node, name, path, pattern_lookup, metadata, generated) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Generate struct definition
|
||||
writeln!(output, "/// Metrics tree node.").unwrap();
|
||||
writeln!(output, "pub struct {} {{", name).unwrap();
|
||||
|
||||
for child in &ctx.children {
|
||||
let field_name = to_snake_case(child.name);
|
||||
let type_annotation = if child.should_inline {
|
||||
child.inline_type_name.clone()
|
||||
} else {
|
||||
metadata.resolve_tree_field_type(
|
||||
&child.field,
|
||||
child.child_fields.as_deref(),
|
||||
name,
|
||||
child.name,
|
||||
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 = 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 {
|
||||
// Pattern type - use ::new() constructor
|
||||
// All patterns have ::new(), parameterizable ones use detected mode,
|
||||
// non-parameterizable ones use field name fallback
|
||||
generate_tree_node_field(
|
||||
output,
|
||||
&syntax,
|
||||
&child.field,
|
||||
metadata,
|
||||
" ",
|
||||
child.name,
|
||||
Some(&child.base_result.base),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
"*" => "serde_json::Value".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
#![allow(clippy::type_complexity)]
|
||||
|
||||
use std::{collections::btree_map::Entry, fs::create_dir_all, io, path::PathBuf};
|
||||
|
||||
use brk_query::Vecs;
|
||||
|
||||
/// 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 syntax;
|
||||
mod types;
|
||||
|
||||
pub use analysis::*;
|
||||
pub use backends::*;
|
||||
pub use generate::*;
|
||||
pub use generators::*;
|
||||
pub use openapi::*;
|
||||
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.
|
||||
///
|
||||
/// 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
|
||||
let spec = parse_openapi_json(openapi_json)?;
|
||||
let endpoints = extract_endpoints(&spec);
|
||||
let mut schemas = extract_schemas(openapi_json);
|
||||
|
||||
// Collect leaf type schemas from the catalog and merge into schemas
|
||||
collect_leaf_type_schemas(&metadata.catalog, &mut schemas);
|
||||
|
||||
// Also collect definitions from all schemas (including OpenAPI schemas)
|
||||
// We need to do this after collecting leaf schemas so we process everything
|
||||
let schema_values: Vec<_> = schemas.values().cloned().collect();
|
||||
for schema in &schema_values {
|
||||
collect_schema_definitions(schema, &mut schemas);
|
||||
}
|
||||
|
||||
// Generate Rust client (uses real brk_types, no schema conversion needed)
|
||||
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)
|
||||
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)
|
||||
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(())
|
||||
}
|
||||
|
||||
use brk_types::TreeNode;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Recursively collect leaf type schemas from the tree and add to schemas map.
|
||||
/// Only adds schemas that aren't already present (OpenAPI schemas take precedence).
|
||||
/// Collects definitions from schemars-generated schemas (for referenced types).
|
||||
fn collect_leaf_type_schemas(node: &TreeNode, schemas: &mut TypeSchemas) {
|
||||
match node {
|
||||
TreeNode::Leaf(leaf) => {
|
||||
// Collect definitions from the schema (schemars puts type schemas here)
|
||||
// This includes the inner types like `Bitcoin` from `Close<Bitcoin>`
|
||||
collect_schema_definitions(&leaf.schema, schemas);
|
||||
|
||||
// Get the type name for this leaf
|
||||
let type_name = extract_inner_type(leaf.kind());
|
||||
|
||||
if let Entry::Vacant(e) = schemas.entry(type_name) {
|
||||
// Unwrap single-element allOf
|
||||
let schema = unwrap_allof(&leaf.schema);
|
||||
|
||||
// Add the schema if it's usable:
|
||||
// - Simple type (has "type")
|
||||
// - Object type with properties (complex types like OHLCCents, EmptyAddressData)
|
||||
// - Enum type (has "enum" or "oneOf")
|
||||
// - Or a $ref to another type
|
||||
let has_type = schema.get("type").is_some();
|
||||
let has_properties = schema.get("properties").is_some();
|
||||
let has_enum = schema.get("enum").is_some() || schema.get("oneOf").is_some();
|
||||
let is_ref = schema.get("$ref").is_some();
|
||||
|
||||
if has_type || has_properties || has_enum || is_ref {
|
||||
e.insert(schema.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
TreeNode::Branch(children) => {
|
||||
for child in children.values() {
|
||||
collect_leaf_type_schemas(child, schemas);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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,340 @@
|
||||
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>;
|
||||
|
||||
/// 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>,
|
||||
/// Response type (simplified)
|
||||
pub response_type: Option<String>,
|
||||
/// 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.
|
||||
/// 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/block/{hash} -> "get_block"
|
||||
// Skip "api" prefix, convert hyphens to underscores, avoid redundant param names
|
||||
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('}')) {
|
||||
// Only add "by_{param}" if the previous segment doesn't already contain the param name
|
||||
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("_"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<(&'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) -> Option<Endpoint> {
|
||||
let path_params = extract_path_parameters(path, operation);
|
||||
let query_params = extract_parameters(operation, ParameterIn::Query);
|
||||
|
||||
let response_type = extract_response_type(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,
|
||||
response_type,
|
||||
deprecated: operation.deprecated.unwrap_or(false),
|
||||
supports_csv,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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/metric/{metric}/{index}" -> ["metric", "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(|s| match s {
|
||||
ObjectOrReference::Ref { ref_path, .. } => {
|
||||
ref_to_type_name(ref_path).map(|s| s.to_string())
|
||||
}
|
||||
ObjectOrReference::Object(obj_schema) => schema_to_type_name(obj_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_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_to_type_name(ref_path)?.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, .. } => {
|
||||
// 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> {
|
||||
let schema_type = schema.schema_type.as_ref()?;
|
||||
|
||||
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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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("number".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,95 @@
|
||||
//! 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;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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, handling Rust keywords.
|
||||
pub fn to_snake_case(s: &str) -> String {
|
||||
// Convert to lowercase and replace dashes with underscores
|
||||
let sanitized = s.to_lowercase().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
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an Index to a snake_case field name (e.g., DateIndex -> dateindex).
|
||||
pub fn index_to_field_name(index: &Index) -> String {
|
||||
to_snake_case(index.serialize_long())
|
||||
}
|
||||
|
||||
/// 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",
|
||||
];
|
||||
|
||||
// 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,199 @@
|
||||
//! Client metadata extracted from brk_query.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use brk_query::Vecs;
|
||||
use brk_types::{Index, MetricLeafWithSchema};
|
||||
|
||||
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 metrics
|
||||
pub index_set_patterns: Vec<IndexSetPattern>,
|
||||
/// Maps concrete field signatures to pattern names
|
||||
concrete_to_pattern: HashMap<Vec<PatternField>, String>,
|
||||
/// Maps concrete field signatures to their type parameter (for generic patterns)
|
||||
concrete_to_type_param: HashMap<Vec<PatternField>, String>,
|
||||
/// Maps tree paths to their computed PatternBaseResult
|
||||
node_bases: HashMap<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);
|
||||
|
||||
ClientMetadata {
|
||||
catalog,
|
||||
structural_patterns,
|
||||
index_set_patterns,
|
||||
concrete_to_pattern,
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Check if a pattern by name is fully parameterizable.
|
||||
/// A pattern is parameterizable if it has a mode AND all its branch fields
|
||||
/// are also parameterizable (or not patterns at all).
|
||||
pub fn is_parameterizable(&self, name: &str) -> bool {
|
||||
self.find_pattern(name).is_some_and(|p| {
|
||||
if !p.is_parameterizable() {
|
||||
return false;
|
||||
}
|
||||
// Check all branch fields have parameterizable types (or are not patterns)
|
||||
p.fields.iter().all(|f| {
|
||||
if f.is_branch() {
|
||||
self.structural_patterns
|
||||
.iter()
|
||||
.find(|pat| pat.name == f.rust_type)
|
||||
.is_none_or(|pat| pat.is_parameterizable())
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if child fields match ANY pattern (parameterizable or not).
|
||||
/// Used for type annotations - we want to reuse pattern types for all patterns.
|
||||
pub fn matches_pattern(&self, fields: &[PatternField]) -> bool {
|
||||
self.concrete_to_pattern.contains_key(fields)
|
||||
|| self.structural_patterns.iter().any(|p| p.fields == fields)
|
||||
}
|
||||
|
||||
/// Find a pattern by its fields.
|
||||
pub fn find_pattern_by_fields(&self, fields: &[PatternField]) -> Option<&StructuralPattern> {
|
||||
self.concrete_to_pattern
|
||||
.get(fields)
|
||||
.and_then(|name| self.find_pattern(name))
|
||||
.or_else(|| self.structural_patterns.iter().find(|p| p.fields == fields))
|
||||
}
|
||||
|
||||
/// Resolve the type name for a tree field.
|
||||
/// If the field matches ANY pattern (parameterizable or not), returns pattern type.
|
||||
/// Otherwise returns the inline type name (parent_child format).
|
||||
pub fn resolve_tree_field_type(
|
||||
&self,
|
||||
field: &PatternField,
|
||||
child_fields: Option<&[PatternField]>,
|
||||
parent_name: &str,
|
||||
child_name: &str,
|
||||
syntax: GenericSyntax,
|
||||
) -> String {
|
||||
match child_fields {
|
||||
// Use pattern type for ANY matching pattern (parameterizable or not)
|
||||
Some(cf) if self.matches_pattern(cf) => {
|
||||
let generic_value_type = self.get_type_param(cf).map(String::as_str);
|
||||
self.field_type_annotation(field, false, generic_value_type, syntax)
|
||||
}
|
||||
Some(_) => crate::child_type_name(parent_name, child_name),
|
||||
None => self.field_type_annotation(field, false, None, syntax),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let value_type = if is_generic && field.rust_type == "T" {
|
||||
"T".to_string()
|
||||
} else {
|
||||
extract_inner_type(&field.rust_type)
|
||||
};
|
||||
|
||||
if self.is_pattern_type(&field.rust_type) {
|
||||
if self.is_pattern_generic(&field.rust_type) {
|
||||
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);
|
||||
}
|
||||
field.rust_type.clone()
|
||||
} else if field.is_branch() {
|
||||
field.rust_type.clone()
|
||||
} else if let Some(accessor) = self.find_index_set_pattern(&field.indexes) {
|
||||
syntax.wrap(&accessor.name, &value_type)
|
||||
} else {
|
||||
syntax.wrap("MetricNode", &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 `MetricLeafWithSchema` node instead of a `PatternField`.
|
||||
pub fn field_type_annotation_from_leaf(
|
||||
&self,
|
||||
leaf: &MetricLeafWithSchema,
|
||||
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("MetricNode", &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,26 @@
|
||||
//! Pattern mode and field parts for metric 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::HashMap;
|
||||
|
||||
/// How a pattern constructs metric 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 metric name when acc = "")
|
||||
relatives: HashMap<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: HashMap<String, String>,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use serde_json::Value;
|
||||
|
||||
/// Unwrap allOf with a single element, returning the inner schema.
|
||||
/// Schemars uses allOf for composition, but often with just one $ref.
|
||||
pub fn unwrap_allof(schema: &Value) -> &Value {
|
||||
if let Some(all_of) = schema.get("allOf").and_then(|v| v.as_array())
|
||||
&& all_of.len() == 1
|
||||
{
|
||||
return &all_of[0];
|
||||
}
|
||||
schema
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
// Handle proper generic wrappers like `Close<Dollars>` -> `Dollars`
|
||||
if let Some(start) = type_str.find('<')
|
||||
&& let Some(end) = type_str.rfind('>')
|
||||
&& start < end
|
||||
{
|
||||
return type_str[start + 1..end].to_string();
|
||||
}
|
||||
// Handle malformed types like `Dollars>` (trailing > without <)
|
||||
if type_str.ends_with('>') && !type_str.contains('<') {
|
||||
return type_str.trim_end_matches('>').to_string();
|
||||
}
|
||||
type_str.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,121 @@
|
||||
//! Structural pattern and field types.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use brk_types::Index;
|
||||
|
||||
use super::PatternMode;
|
||||
|
||||
/// 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 fields construct metric 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()),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this pattern is in suffix mode.
|
||||
pub fn is_suffix_mode(&self) -> bool {
|
||||
matches!(&self.mode, Some(PatternMode::Suffix { .. }))
|
||||
}
|
||||
|
||||
/// Check if the given instance field parts match this pattern's field parts.
|
||||
/// Returns true if all field parts in the pattern match the instance's field parts.
|
||||
pub fn field_parts_match(&self, instance_field_parts: &HashMap<String, String>) -> bool {
|
||||
match &self.mode {
|
||||
Some(PatternMode::Suffix { relatives }) => {
|
||||
// For each field in the pattern, check if the instance has the same suffix
|
||||
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 }) => {
|
||||
// For each field in the pattern, check if the instance has the same prefix
|
||||
prefixes.iter().all(|(field_name, pattern_prefix)| {
|
||||
instance_field_parts
|
||||
.get(field_name)
|
||||
.is_some_and(|instance_prefix| instance_prefix == pattern_prefix)
|
||||
})
|
||||
}
|
||||
None => false, // Non-parameterizable patterns don't use field parts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,913 @@
|
||||
//! Tests that verify pattern analysis using the real catalog.
|
||||
|
||||
use std::collections::HashSet;
|
||||
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 HashSet<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 = HashSet::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: HashSet<_> = pattern.fields.iter().map(|f| f.name.clone()).collect();
|
||||
|
||||
match mode {
|
||||
brk_bindgen::PatternMode::Suffix { relatives } => {
|
||||
let mode_fields: HashSet<_> = 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: HashSet<_> = 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 = HashSet::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 = HashSet::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 = HashSet::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").unwrap();
|
||||
writeln!(
|
||||
py_output,
|
||||
"from http.client import HTTPSConnection, HTTPConnection"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(py_output, "from urllib.parse import urlparse").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::HashMap<String, String> =
|
||||
std::collections::HashMap::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::HashMap<String, String>,
|
||||
is_suffix_mode: bool,
|
||||
}
|
||||
|
||||
fn collect_debug(
|
||||
node: &TreeNode,
|
||||
pattern_lookup: &std::collections::HashMap<Vec<brk_bindgen::PatternField>, String>,
|
||||
all_analyses: &mut std::collections::HashMap<String, Vec<DebugInstanceAnalysis>>,
|
||||
) -> Option<String> {
|
||||
match node {
|
||||
TreeNode::Leaf(leaf) => Some(leaf.name().to_string()),
|
||||
TreeNode::Branch(children) => {
|
||||
let mut child_bases: std::collections::HashMap<String, String> =
|
||||
std::collections::HashMap::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::HashMap::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: std::collections::HashMap<String, Vec<DebugInstanceAnalysis>> =
|
||||
std::collections::HashMap::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"
|
||||
brk_rolldown = "0.1.4"
|
||||
# brk_rolldown = { path = "../../../rolldown/crates/rolldown"}
|
||||
sugar_path = "1.2.0"
|
||||
tokio = { 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,147 +0,0 @@
|
||||
use std::{
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use brk_rolldown::{Bundler, BundlerOptions, RawMinifyOptions, SourceMapType};
|
||||
use log::error;
|
||||
use notify::{EventKind, RecursiveMode, Watcher};
|
||||
use sugar_path::SugarPath;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
pub async fn bundle(websites_path: &Path, source_folder: &str, watch: bool) -> io::Result<PathBuf> {
|
||||
let source_path = websites_path.join(source_folder);
|
||||
let dist_path = websites_path.join("dist");
|
||||
|
||||
let _ = fs::remove_dir_all(&dist_path);
|
||||
copy_dir_all(&source_path, &dist_path)?;
|
||||
|
||||
let source_scripts = format!("./{source_folder}/scripts");
|
||||
let source_entry = format!("{source_scripts}/entry.js");
|
||||
|
||||
let absolute_websites_path = websites_path.absolutize();
|
||||
|
||||
let mut bundler = Bundler::new(BundlerOptions {
|
||||
input: Some(vec![source_entry.into()]),
|
||||
dir: Some("./dist/scripts".to_string()),
|
||||
cwd: Some(absolute_websites_path),
|
||||
minify: Some(RawMinifyOptions::Bool(true)),
|
||||
sourcemap: Some(SourceMapType::File),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
bundler.write().await.unwrap();
|
||||
|
||||
let absolute_source_index_path = source_path.join("index.html").absolutize();
|
||||
let absolute_source_index_path_clone = absolute_source_index_path.clone();
|
||||
let absolute_source_path = source_path.absolutize();
|
||||
let absolute_source_path_clone = absolute_source_path.clone();
|
||||
let absolute_source_scripts_path = websites_path.join(source_scripts).absolutize();
|
||||
let absolute_source_sw_path = source_path.join("service-worker.js").absolutize();
|
||||
let absolute_source_sw_path_clone = absolute_source_sw_path.clone();
|
||||
|
||||
let absolute_dist_entry_path = dist_path.join("scripts/entry.js").absolutize();
|
||||
let absolute_dist_index_path = dist_path.join("index.html").absolutize();
|
||||
let absolute_dist_path = dist_path.absolutize();
|
||||
let absolute_dist_path_clone = absolute_dist_path.clone();
|
||||
let absolute_dist_sw_path = dist_path.join("service-worker.js").absolutize();
|
||||
|
||||
let write_index = move || {
|
||||
let mut contents = fs::read_to_string(&absolute_source_index_path).unwrap();
|
||||
|
||||
if let Ok(entry) = fs::read_to_string(absolute_dist_path_clone.join("scripts/entry.js"))
|
||||
&& 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 write_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);
|
||||
};
|
||||
|
||||
write_index();
|
||||
write_sw();
|
||||
|
||||
if !watch {
|
||||
return Ok(dist_path);
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let write_index_clone = write_index.clone();
|
||||
|
||||
let mut entry_watcher = notify::recommended_watcher(
|
||||
move |res: Result<notify::Event, notify::Error>| match res {
|
||||
Ok(_) => write_index_clone(),
|
||||
Err(e) => error!("watch error: {e:?}"),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
entry_watcher
|
||||
.watch(&absolute_dist_entry_path, RecursiveMode::Recursive)
|
||||
.unwrap();
|
||||
|
||||
let mut source_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()
|
||||
.filter(|path| path.starts_with(&absolute_source_path))
|
||||
.filter(|path| !path.starts_with(&absolute_source_scripts_path))
|
||||
.for_each(|source_path| {
|
||||
let suffix = source_path.strip_prefix(&absolute_source_path).unwrap();
|
||||
let dist_path = absolute_dist_path.join(suffix);
|
||||
|
||||
if source_path == absolute_source_index_path_clone {
|
||||
write_index();
|
||||
} else if source_path == absolute_source_sw_path_clone {
|
||||
write_sw();
|
||||
} else {
|
||||
let _ = fs::copy(&source_path, &dist_path);
|
||||
}
|
||||
}),
|
||||
Err(e) => error!("watch error: {e:?}"),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
source_watcher
|
||||
.watch(&absolute_source_path_clone, RecursiveMode::Recursive)
|
||||
.unwrap();
|
||||
|
||||
let watcher =
|
||||
brk_rolldown::Watcher::new(vec![Arc::new(Mutex::new(bundler))], None).unwrap();
|
||||
|
||||
watcher.start().await;
|
||||
});
|
||||
|
||||
Ok(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,32 +6,32 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
bitcoincore-rpc = { workspace = true }
|
||||
brk_bundler = { workspace = true }
|
||||
anyhow = "1.0"
|
||||
brk_alloc = { workspace = true }
|
||||
brk_computer = { workspace = true }
|
||||
brk_error = { workspace = true, features = ["tokio", "vecdb"] }
|
||||
brk_fetcher = { workspace = true }
|
||||
brk_indexer = { workspace = true }
|
||||
brk_interface = { workspace = true }
|
||||
brk_iterator = { workspace = true }
|
||||
brk_logger = { workspace = true }
|
||||
brk_parser = { workspace = true }
|
||||
brk_mempool = { workspace = true }
|
||||
brk_query = { workspace = true }
|
||||
brk_reader = { workspace = true }
|
||||
brk_rpc = { workspace = true }
|
||||
brk_server = { workspace = true }
|
||||
vecdb = { workspace = true }
|
||||
clap = { version = "4.5.43", features = ["string"] }
|
||||
clap_derive = "4.5.41"
|
||||
color-eyre = "0.6.5"
|
||||
log = { workspace = true }
|
||||
minreq = { workspace = true }
|
||||
brk_types = { workspace = true }
|
||||
lexopt = "0.3"
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
toml = "0.9.5"
|
||||
zip = { version = "4.3.0", default-features = false, features = ["deflate"] }
|
||||
toml = "0.9.11"
|
||||
vecdb = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "brk"
|
||||
path = "src/main.rs"
|
||||
|
||||
[package.metadata.dist]
|
||||
dist = false
|
||||
dist = true
|
||||
|
||||
@@ -1,90 +1,55 @@
|
||||
# BRK CLI
|
||||
# brk_cli
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/bitcoinresearchkit/brk">
|
||||
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/bitcoinresearchkit/brk?style=social">
|
||||
</a>
|
||||
<a href="https://github.com/bitcoinresearchkit/brk/blob/main/LICENSE.md">
|
||||
<img src="https://img.shields.io/crates/l/brk" alt="License" />
|
||||
</a>
|
||||
<a href="https://crates.io/crates/brk_cli">
|
||||
<img src="https://img.shields.io/crates/v/brk_cli" alt="Version" />
|
||||
</a>
|
||||
<a href="https://docs.rs/brk_cli">
|
||||
<img src="https://img.shields.io/docsrs/brk_cli" alt="Documentation" />
|
||||
</a>
|
||||
<img src="https://img.shields.io/crates/size/brk_cli" alt="Size" />
|
||||
<a href="https://deps.rs/crate/brk_cli">
|
||||
<img src="https://deps.rs/crate/brk_cli/latest/status.svg" alt="Dependency status">
|
||||
</a>
|
||||
<a href="https://discord.gg/HaR3wpH3nr">
|
||||
<img src="https://img.shields.io/discord/1350431684562124850?label=discord" alt="Discord" />
|
||||
</a>
|
||||
<a href="https://primal.net/p/nprofile1qqsfw5dacngjlahye34krvgz7u0yghhjgk7gxzl5ptm9v6n2y3sn03sqxu2e6">
|
||||
<img src="https://img.shields.io/badge/nostr-purple?link=https%3A%2F%2Fprimal.net%2Fp%2Fnprofile1qqsfw5dacngjlahye34krvgz7u0yghhjgk7gxzl5ptm9v6n2y3sn03sqxu2e6" alt="Nostr" />
|
||||
</a>
|
||||
</p>
|
||||
Command-line interface for running a Bitcoin Research Kit instance.
|
||||
|
||||
A command line interface to run a Bitcoin Research Kit instance.
|
||||
## Preview
|
||||
|
||||
It's very customizable with all parameters from the underlying tools (crates) used inside.
|
||||
|
||||
Run `brk -h` for more information.
|
||||
- https://bitview.space - web interface
|
||||
- https://bitview.space/api - API docs
|
||||
|
||||
## Requirements
|
||||
|
||||
### Hardware
|
||||
- Bitcoin Core running with RPC enabled
|
||||
- Access to `blk*.dat` files
|
||||
- ~400 GB disk space
|
||||
- 12+ GB RAM
|
||||
|
||||
#### Recommended
|
||||
|
||||
- [Latest base model Mac mini](https://www.apple.com/mac-mini/)
|
||||
- [Thunderbolt 4 SSD enclosure](https://satechi.net/products/usb4-nvme-ssd-pro-enclosure/Z2lkOi8vc2hvcGlmeS9Qcm9kdWN0VmFyaWFudC80MDE4ODQ3MDA2NzI4OA==?queryID=7961465089021ee203a60db7e62e90d2)
|
||||
- [2 TB NVMe SSD](https://shop.sandisk.com/products/ssd/internal-ssd/wd-black-sn850x-nvme-ssd?sku=WDS200T2X0E-00BCA0)
|
||||
|
||||
#### Minimum
|
||||
|
||||
To be determined
|
||||
|
||||
### Software
|
||||
|
||||
- [Bitcoin](https://bitcoin.org/en/full-node)
|
||||
- [Rust](https://www.rust-lang.org/tools/install)
|
||||
- Unix based operating system (Mac OS or Linux)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Ubuntu users need to install `open-ssl` via `sudo apt install libssl-dev pkg-config`
|
||||
|
||||
## Download
|
||||
|
||||
### Binaries
|
||||
|
||||
You can find a pre-built binary for your operating system in the [releases page](https://github.com/bitcoinresearchkit/brk/releases/latest).
|
||||
|
||||
### Cargo
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# Install
|
||||
cargo install brk --locked # or `cargo install brk_cli`, the result is the same
|
||||
|
||||
# Update
|
||||
cargo install brk --locked # or `cargo install-update -a` if you have `cargo-update` installed
|
||||
rustup update
|
||||
RUSTFLAGS="-C target-cpu=native" cargo install --locked brk_cli --version "$(cargo search brk_cli | head -1 | awk -F'"' '{print $2}')"
|
||||
```
|
||||
|
||||
### Source
|
||||
Portable build (without native CPU optimizations):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bitcoinresearchkit/brk.git
|
||||
cd brk/crates/brk
|
||||
cargo run -r
|
||||
cargo install --locked brk_cli
|
||||
```
|
||||
|
||||
## Usage
|
||||
## Run
|
||||
|
||||
Run `brk -h` to view each available parameter and their respective description.
|
||||
```bash
|
||||
brk
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Every parameter set will be saved at `~/.brk/config.toml`, which allows you to simply run `brk` next time.
|
||||
Indexes the blockchain, computes datasets, starts the server on `localhost:3110`, and waits for new blocks.
|
||||
|
||||
## Tunnel
|
||||
## Options
|
||||
|
||||
The easiest way to let others access your server is to use `cloudflared` which will also cache requests. For more information see [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) documentation.
|
||||
```bash
|
||||
brk -h # Show all options
|
||||
brk -V # Show version
|
||||
```
|
||||
|
||||
Options are saved to `~/.brk/config.toml` after first use.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
~/.brk/
|
||||
├── config.toml Configuration
|
||||
└── log Logs
|
||||
|
||||
<brkdir>/ Indexed data (default: ~/.brk)
|
||||
```
|
||||
|
||||
@@ -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,111 +0,0 @@
|
||||
use std::{fs, io, path::Path};
|
||||
|
||||
use brk_interface::{Index, Interface};
|
||||
use brk_server::VERSION;
|
||||
|
||||
use crate::website::Website;
|
||||
|
||||
const SCRIPTS: &str = "scripts";
|
||||
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub trait Bridge {
|
||||
fn generate_bridge_file(&self, website: Website, websites_path: &Path) -> io::Result<()>;
|
||||
}
|
||||
|
||||
impl Bridge for Interface<'static> {
|
||||
fn generate_bridge_file(&self, website: Website, websites_path: &Path) -> io::Result<()> {
|
||||
if website.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let path = websites_path.join(website.to_folder_name());
|
||||
|
||||
if !fs::exists(&path)? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let path = path.join(SCRIPTS);
|
||||
|
||||
fs::create_dir_all(&path)?;
|
||||
|
||||
let path = path.join(Path::new("vecid-to-indexes.js"));
|
||||
|
||||
let indexes = Index::all();
|
||||
|
||||
let mut contents = format!(
|
||||
"//
|
||||
// File auto-generated, any modifications will be overwritten
|
||||
//
|
||||
|
||||
export const VERSION = \"v{VERSION}\";
|
||||
|
||||
"
|
||||
);
|
||||
|
||||
contents += &indexes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i_of_i, i)| {
|
||||
// let lowered = i.to_string().to_lowercase();
|
||||
format!("/** @typedef {{{i_of_i}}} {i} */",)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
contents += &format!(
|
||||
"\n\n/** @typedef {{{}}} Index */\n",
|
||||
indexes
|
||||
.iter()
|
||||
.map(|i| i.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | ")
|
||||
);
|
||||
|
||||
contents += "
|
||||
/** @typedef {ReturnType<typeof createIndexes>} Indexes */
|
||||
|
||||
export function createIndexes() {
|
||||
return {
|
||||
";
|
||||
|
||||
contents += &indexes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i_of_i, i)| {
|
||||
let lowered = i.to_string().to_lowercase();
|
||||
format!(" {lowered}: /** @satisfies {{{i}}} */ ({i_of_i}),",)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
contents += " };\n}\n";
|
||||
|
||||
contents += "
|
||||
/** @typedef {ReturnType<typeof createVecIdToIndexes>} VecIdToIndexes
|
||||
/** @typedef {keyof VecIdToIndexes} VecId */
|
||||
|
||||
/**
|
||||
* @returns {Record<any, number[]>}
|
||||
*/
|
||||
export function createVecIdToIndexes() {
|
||||
return {
|
||||
";
|
||||
|
||||
self.id_to_index_to_vec()
|
||||
.iter()
|
||||
.for_each(|(id, index_to_vec)| {
|
||||
let indexes = index_to_vec
|
||||
.keys()
|
||||
.map(|i| (*i as u8).to_string())
|
||||
// .map(|i| i.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
contents += &format!(" \"{id}\": [{indexes}],\n");
|
||||
});
|
||||
|
||||
contents += " };\n}\n";
|
||||
|
||||
fs::write(path, contents)
|
||||
}
|
||||
}
|
||||
@@ -3,99 +3,56 @@ use std::{
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use bitcoincore_rpc::{self, Auth, Client};
|
||||
use brk_error::{Error, Result};
|
||||
use brk_fetcher::Fetcher;
|
||||
use clap::Parser;
|
||||
use clap_derive::Parser;
|
||||
use color_eyre::eyre::eyre;
|
||||
use brk_rpc::{Auth, Client};
|
||||
use brk_types::Port;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::{default_bitcoin_path, default_brk_path, dot_brk_path, website::Website};
|
||||
use crate::{default_brk_path, dot_brk_path, fix_user_path, website::WebsiteArg};
|
||||
|
||||
const DOWNLOADS: &str = "downloads";
|
||||
|
||||
#[derive(Parser, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
|
||||
#[command(version, about)]
|
||||
#[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
|
||||
pub struct Config {
|
||||
/// Bitcoin main directory path, defaults: ~/.bitcoin, ~/Library/Application\ Support/Bitcoin, saved
|
||||
#[serde(default, deserialize_with = "default_on_error")]
|
||||
#[arg(long, value_name = "PATH")]
|
||||
bitcoindir: Option<String>,
|
||||
|
||||
/// Bitcoin blocks directory path, default: --bitcoindir/blocks, saved
|
||||
#[serde(default, deserialize_with = "default_on_error")]
|
||||
#[arg(long, value_name = "PATH")]
|
||||
blocksdir: Option<String>,
|
||||
|
||||
/// Bitcoin Research Kit outputs directory path, default: ~/.brk, saved
|
||||
#[serde(default, deserialize_with = "default_on_error")]
|
||||
#[arg(long, value_name = "PATH")]
|
||||
brkdir: Option<String>,
|
||||
|
||||
/// Activate fetching prices from BRK's API and the computation of all price related datasets, default: true, saved
|
||||
#[serde(default, deserialize_with = "default_on_error")]
|
||||
#[arg(short = 'F', long, value_name = "BOOL")]
|
||||
brkport: Option<Port>,
|
||||
|
||||
#[serde(default, deserialize_with = "default_on_error")]
|
||||
website: Option<WebsiteArg>,
|
||||
|
||||
#[serde(default, deserialize_with = "default_on_error")]
|
||||
fetch: Option<bool>,
|
||||
|
||||
/// Activate fetching prices from exchanges APIs if `fetch` is also set to `true`, default: true, saved
|
||||
#[serde(default, deserialize_with = "default_on_error")]
|
||||
#[arg(long, value_name = "BOOL")]
|
||||
exchanges: Option<bool>,
|
||||
bitcoindir: Option<String>,
|
||||
|
||||
/// Website served by the server (if active), default: default, saved
|
||||
#[serde(default, deserialize_with = "default_on_error")]
|
||||
#[arg(short, long)]
|
||||
website: Option<Website>,
|
||||
blocksdir: Option<String>,
|
||||
|
||||
/// Bitcoin RPC ip, default: localhost, saved
|
||||
#[serde(default, deserialize_with = "default_on_error")]
|
||||
#[arg(long, value_name = "IP")]
|
||||
rpcconnect: Option<String>,
|
||||
|
||||
/// Bitcoin RPC port, default: 8332, saved
|
||||
#[serde(default, deserialize_with = "default_on_error")]
|
||||
#[arg(long, value_name = "PORT")]
|
||||
rpcport: Option<u16>,
|
||||
|
||||
/// Bitcoin RPC cookie file, default: --bitcoindir/.cookie, saved
|
||||
#[serde(default, deserialize_with = "default_on_error")]
|
||||
#[arg(long, value_name = "PATH")]
|
||||
rpccookiefile: Option<String>,
|
||||
|
||||
/// Bitcoin RPC username, saved
|
||||
#[serde(default, deserialize_with = "default_on_error")]
|
||||
#[arg(long, value_name = "USERNAME")]
|
||||
rpcuser: Option<String>,
|
||||
|
||||
/// Bitcoin RPC password, saved
|
||||
#[serde(default, deserialize_with = "default_on_error")]
|
||||
#[arg(long, value_name = "PASSWORD")]
|
||||
rpcpassword: Option<String>,
|
||||
|
||||
/// Delay between runs, default: 0, saved
|
||||
#[serde(default, deserialize_with = "default_on_error")]
|
||||
#[arg(long, value_name = "SECONDS")]
|
||||
delay: Option<u64>,
|
||||
|
||||
/// Activate the Model Context Protocol (MCP) endpoint to give LLMs access to BRK (experimental), default: true, saved
|
||||
#[serde(default, deserialize_with = "default_on_error")]
|
||||
#[arg(long, value_name = "BOOL")]
|
||||
mcp: Option<bool>,
|
||||
|
||||
/// DEV: Activate watching the selected website's folder for changes, default: false, saved
|
||||
#[serde(default, deserialize_with = "default_on_error")]
|
||||
#[arg(long, value_name = "BOOL")]
|
||||
watch: Option<bool>,
|
||||
|
||||
/// DEV: Activate checking address hashes for collisions when indexing, default: false, saved
|
||||
#[serde(default, deserialize_with = "default_on_error")]
|
||||
#[arg(long, value_name = "BOOL")]
|
||||
check_collisions: Option<bool>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn import() -> color_eyre::Result<Self> {
|
||||
let config_args = Some(Config::parse());
|
||||
pub fn import() -> Result<Self> {
|
||||
let config_args = Self::parse_args();
|
||||
|
||||
let path = dot_brk_path();
|
||||
|
||||
@@ -103,76 +60,44 @@ impl Config {
|
||||
|
||||
let path = path.join("config.toml");
|
||||
|
||||
let mut config_saved = Self::read(&path);
|
||||
let mut config = Self::read(&path);
|
||||
|
||||
if let Some(mut config_args) = config_args {
|
||||
if let Some(bitcoindir) = config_args.bitcoindir.take() {
|
||||
config_saved.bitcoindir = Some(bitcoindir);
|
||||
}
|
||||
|
||||
if let Some(blocksdir) = config_args.blocksdir.take() {
|
||||
config_saved.blocksdir = Some(blocksdir);
|
||||
}
|
||||
|
||||
if let Some(brkdir) = config_args.brkdir.take() {
|
||||
config_saved.brkdir = Some(brkdir);
|
||||
}
|
||||
|
||||
if let Some(fetch) = config_args.fetch.take() {
|
||||
config_saved.fetch = Some(fetch);
|
||||
}
|
||||
|
||||
if let Some(exchanges) = config_args.exchanges.take() {
|
||||
config_saved.exchanges = Some(exchanges);
|
||||
}
|
||||
|
||||
if let Some(website) = config_args.website.take() {
|
||||
config_saved.website = Some(website);
|
||||
}
|
||||
|
||||
if let Some(rpcconnect) = config_args.rpcconnect.take() {
|
||||
config_saved.rpcconnect = Some(rpcconnect);
|
||||
}
|
||||
|
||||
if let Some(rpcport) = config_args.rpcport.take() {
|
||||
config_saved.rpcport = Some(rpcport);
|
||||
}
|
||||
|
||||
if let Some(rpccookiefile) = config_args.rpccookiefile.take() {
|
||||
config_saved.rpccookiefile = Some(rpccookiefile);
|
||||
}
|
||||
|
||||
if let Some(rpcuser) = config_args.rpcuser.take() {
|
||||
config_saved.rpcuser = Some(rpcuser);
|
||||
}
|
||||
|
||||
if let Some(rpcpassword) = config_args.rpcpassword.take() {
|
||||
config_saved.rpcpassword = Some(rpcpassword);
|
||||
}
|
||||
|
||||
if let Some(delay) = config_args.delay.take() {
|
||||
config_saved.delay = Some(delay);
|
||||
}
|
||||
|
||||
if let Some(check_collisions) = config_args.check_collisions.take() {
|
||||
config_saved.check_collisions = Some(check_collisions);
|
||||
}
|
||||
|
||||
if let Some(mcp) = config_args.mcp.take() {
|
||||
config_saved.mcp = Some(mcp);
|
||||
}
|
||||
|
||||
if let Some(watch) = config_args.watch.take() {
|
||||
config_saved.watch = Some(watch);
|
||||
}
|
||||
|
||||
if config_args != Config::default() {
|
||||
dbg!(config_args);
|
||||
panic!("Didn't consume the full config")
|
||||
}
|
||||
if let Some(v) = config_args.brkdir {
|
||||
config.brkdir = Some(v);
|
||||
}
|
||||
if let Some(v) = config_args.brkport {
|
||||
config.brkport = Some(v);
|
||||
}
|
||||
if let Some(v) = config_args.website {
|
||||
config.website = Some(v);
|
||||
}
|
||||
if let Some(v) = config_args.fetch {
|
||||
config.fetch = Some(v);
|
||||
}
|
||||
if let Some(v) = config_args.bitcoindir {
|
||||
config.bitcoindir = Some(v);
|
||||
}
|
||||
if let Some(v) = config_args.blocksdir {
|
||||
config.blocksdir = Some(v);
|
||||
}
|
||||
if let Some(v) = config_args.rpcconnect {
|
||||
config.rpcconnect = Some(v);
|
||||
}
|
||||
if let Some(v) = config_args.rpcport {
|
||||
config.rpcport = Some(v);
|
||||
}
|
||||
if let Some(v) = config_args.rpccookiefile {
|
||||
config.rpccookiefile = Some(v);
|
||||
}
|
||||
if let Some(v) = config_args.rpcuser {
|
||||
config.rpcuser = Some(v);
|
||||
}
|
||||
if let Some(v) = config_args.rpcpassword {
|
||||
config.rpcpassword = Some(v);
|
||||
}
|
||||
if let Some(v) = config_args.check_collisions {
|
||||
config.check_collisions = Some(v);
|
||||
}
|
||||
|
||||
let config = config_saved;
|
||||
|
||||
config.check();
|
||||
|
||||
@@ -181,6 +106,73 @@ impl Config {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn parse_args() -> Self {
|
||||
use lexopt::prelude::*;
|
||||
|
||||
let mut config = Self::default();
|
||||
let mut parser = lexopt::Parser::from_env();
|
||||
|
||||
while let Some(arg) = parser.next().unwrap() {
|
||||
match arg {
|
||||
Short('h') | Long("help") => {
|
||||
Self::print_help();
|
||||
std::process::exit(0);
|
||||
}
|
||||
Short('V') | Long("version") => {
|
||||
println!("brk {}", env!("CARGO_PKG_VERSION"));
|
||||
std::process::exit(0);
|
||||
}
|
||||
Long("brkdir") => config.brkdir = Some(parser.value().unwrap().parse().unwrap()),
|
||||
Long("brkport") => config.brkport = Some(parser.value().unwrap().parse().unwrap()),
|
||||
Long("website") => config.website = Some(parser.value().unwrap().parse().unwrap()),
|
||||
Long("fetch") => config.fetch = Some(parser.value().unwrap().parse().unwrap()),
|
||||
Long("bitcoindir") => config.bitcoindir = Some(parser.value().unwrap().parse().unwrap()),
|
||||
Long("blocksdir") => config.blocksdir = Some(parser.value().unwrap().parse().unwrap()),
|
||||
Long("rpcconnect") => config.rpcconnect = Some(parser.value().unwrap().parse().unwrap()),
|
||||
Long("rpcport") => config.rpcport = Some(parser.value().unwrap().parse().unwrap()),
|
||||
Long("rpccookiefile") => config.rpccookiefile = Some(parser.value().unwrap().parse().unwrap()),
|
||||
Long("rpcuser") => config.rpcuser = Some(parser.value().unwrap().parse().unwrap()),
|
||||
Long("rpcpassword") => config.rpcpassword = Some(parser.value().unwrap().parse().unwrap()),
|
||||
Long("check-collisions") => config.check_collisions = Some(parser.value().unwrap().parse().unwrap()),
|
||||
_ => {
|
||||
eprintln!("{}", arg.unexpected());
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"brk {}
|
||||
Bitcoin Research Kit
|
||||
|
||||
USAGE:
|
||||
brk [OPTIONS]
|
||||
|
||||
OPTIONS:
|
||||
-h, --help Print help
|
||||
-V, --version Print version
|
||||
|
||||
--brkdir <PATH> Output directory [~/.brk]
|
||||
--brkport <PORT> Server port [3110]
|
||||
--website <BOOL|PATH> Website: true, false, or path [true]
|
||||
--fetch <BOOL> Fetch prices [true]
|
||||
|
||||
--bitcoindir <PATH> Bitcoin directory [~/.bitcoin, ~/Library/Application Support/Bitcoin]
|
||||
--blocksdir <PATH> Blocks directory [<bitcoindir>/blocks]
|
||||
|
||||
--rpcconnect <IP> RPC host [localhost]
|
||||
--rpcport <PORT> RPC port [8332]
|
||||
--rpccookiefile <PATH> RPC cookie file [<bitcoindir>/.cookie]
|
||||
--rpcuser <USERNAME> RPC username
|
||||
--rpcpassword <PASSWORD> RPC password",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
}
|
||||
|
||||
fn check(&self) {
|
||||
if !self.bitcoindir().is_dir() {
|
||||
println!("{:?} isn't a valid directory", self.bitcoindir());
|
||||
@@ -224,18 +216,18 @@ Finally, you can run the program with '-h' for help."
|
||||
fs::write(path, toml::to_string(self).unwrap())
|
||||
}
|
||||
|
||||
pub fn rpc(&self) -> color_eyre::Result<&'static Client> {
|
||||
Ok(Box::leak(Box::new(Client::new(
|
||||
pub fn rpc(&self) -> Result<Client> {
|
||||
Client::new(
|
||||
&format!(
|
||||
"http://{}:{}",
|
||||
self.rpcconnect().unwrap_or(&"localhost".to_string()),
|
||||
self.rpcport().unwrap_or(8332)
|
||||
),
|
||||
self.rpc_auth().unwrap(),
|
||||
)?)))
|
||||
self.rpc_auth()?,
|
||||
)
|
||||
}
|
||||
|
||||
fn rpc_auth(&self) -> color_eyre::Result<Auth> {
|
||||
fn rpc_auth(&self) -> Result<Auth> {
|
||||
let cookie = self.path_cookiefile();
|
||||
|
||||
if cookie.is_file() {
|
||||
@@ -246,7 +238,7 @@ Finally, you can run the program with '-h' for help."
|
||||
self.rpcpassword.clone().unwrap(),
|
||||
))
|
||||
} else {
|
||||
Err(eyre!("Failed to find correct auth"))
|
||||
Err(Error::AuthFailed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,90 +250,56 @@ Finally, you can run the program with '-h' for help."
|
||||
self.rpcport
|
||||
}
|
||||
|
||||
pub fn delay(&self) -> Option<u64> {
|
||||
self.delay
|
||||
}
|
||||
|
||||
pub fn bitcoindir(&self) -> PathBuf {
|
||||
self.bitcoindir
|
||||
.as_ref()
|
||||
.map_or_else(default_bitcoin_path, |s| Self::fix_user_path(s.as_ref()))
|
||||
.map_or_else(Client::default_bitcoin_path, |s| fix_user_path(s.as_ref()))
|
||||
}
|
||||
|
||||
pub fn blocksdir(&self) -> PathBuf {
|
||||
self.blocksdir.as_ref().map_or_else(
|
||||
|| self.bitcoindir().join("blocks"),
|
||||
|blocksdir| Self::fix_user_path(blocksdir.as_str()),
|
||||
|blocksdir| fix_user_path(blocksdir.as_str()),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn brkdir(&self) -> PathBuf {
|
||||
self.brkdir
|
||||
.as_ref()
|
||||
.map_or_else(default_brk_path, |s| Self::fix_user_path(s.as_ref()))
|
||||
.map_or_else(default_brk_path, |s| fix_user_path(s.as_ref()))
|
||||
}
|
||||
|
||||
pub fn harsdir(&self) -> PathBuf {
|
||||
self.brkdir().join("hars")
|
||||
}
|
||||
|
||||
pub fn downloads_dir(&self) -> PathBuf {
|
||||
dot_brk_path().join(DOWNLOADS)
|
||||
}
|
||||
|
||||
fn path_cookiefile(&self) -> PathBuf {
|
||||
self.rpccookiefile.as_ref().map_or_else(
|
||||
|| self.bitcoindir().join(".cookie"),
|
||||
|p| Self::fix_user_path(p.as_str()),
|
||||
|p| fix_user_path(p.as_str()),
|
||||
)
|
||||
}
|
||||
|
||||
fn fix_user_path(path: &str) -> PathBuf {
|
||||
let fix = move |pattern: &str| {
|
||||
if path.starts_with(pattern) {
|
||||
let path = &path
|
||||
.replace(&format!("{pattern}/"), "")
|
||||
.replace(pattern, "");
|
||||
|
||||
let home = std::env::var("HOME").unwrap();
|
||||
|
||||
Some(Path::new(&home).join(path))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
fix("~").unwrap_or_else(|| fix("$HOME").unwrap_or_else(|| PathBuf::from(&path)))
|
||||
pub fn website(&self) -> WebsiteArg {
|
||||
self.website.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn website(&self) -> Website {
|
||||
self.website.unwrap_or(Website::Default)
|
||||
pub fn brkport(&self) -> Option<Port> {
|
||||
self.brkport
|
||||
}
|
||||
|
||||
pub fn fetch(&self) -> bool {
|
||||
self.fetch.is_none_or(|b| b)
|
||||
}
|
||||
|
||||
pub fn exchanges(&self) -> bool {
|
||||
self.exchanges.is_none_or(|b| b)
|
||||
}
|
||||
|
||||
pub fn fetcher(&self) -> Option<Fetcher> {
|
||||
self.fetch()
|
||||
.then(|| Fetcher::import(self.exchanges(), Some(self.harsdir().as_path())).unwrap())
|
||||
.then(|| Fetcher::import(Some(self.harsdir().as_path())).unwrap())
|
||||
}
|
||||
|
||||
pub fn check_collisions(&self) -> bool {
|
||||
self.check_collisions.is_some_and(|b| b)
|
||||
}
|
||||
|
||||
pub fn mcp(&self) -> bool {
|
||||
self.mcp.is_none_or(|b| b)
|
||||
}
|
||||
|
||||
pub fn watch(&self) -> bool {
|
||||
self.watch.is_some_and(|b| b)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_on_error<'de, D, T>(deserializer: D) -> Result<T, D::Error>
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
io::Cursor,
|
||||
path::Path,
|
||||
thread::{self, sleep},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use bitcoincore_rpc::{self, RpcApi};
|
||||
use brk_bundler::bundle;
|
||||
use brk_computer::Computer;
|
||||
use brk_indexer::Indexer;
|
||||
use brk_interface::Interface;
|
||||
use brk_server::{Server, VERSION};
|
||||
use log::info;
|
||||
use vecdb::Exit;
|
||||
|
||||
mod bridge;
|
||||
mod config;
|
||||
mod paths;
|
||||
mod website;
|
||||
|
||||
use crate::{bridge::Bridge, config::Config, paths::*};
|
||||
|
||||
pub fn main() -> color_eyre::Result<()> {
|
||||
color_eyre::install()?;
|
||||
|
||||
fs::create_dir_all(dot_brk_path())?;
|
||||
|
||||
brk_logger::init(Some(&dot_brk_log_path()));
|
||||
|
||||
thread::Builder::new()
|
||||
.stack_size(256 * 1024 * 1024)
|
||||
.spawn(run)?
|
||||
.join()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn run() -> color_eyre::Result<()> {
|
||||
let config = Config::import()?;
|
||||
|
||||
let rpc = config.rpc()?;
|
||||
|
||||
let exit = Exit::new();
|
||||
exit.set_ctrlc_handler();
|
||||
|
||||
let parser = brk_parser::Parser::new(config.blocksdir(), config.brkdir(), rpc);
|
||||
|
||||
let mut indexer = Indexer::forced_import(&config.brkdir())?;
|
||||
|
||||
let wait_for_synced_node = |rpc_client: &bitcoincore_rpc::Client| -> color_eyre::Result<()> {
|
||||
let is_synced = || -> color_eyre::Result<bool> {
|
||||
let info = rpc_client.get_blockchain_info()?;
|
||||
Ok(info.headers == info.blocks)
|
||||
};
|
||||
|
||||
if !is_synced()? {
|
||||
info!("Waiting for node to be synced...");
|
||||
while !is_synced()? {
|
||||
sleep(Duration::from_secs(1))
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let mut computer = Computer::forced_import(&config.brkdir(), &indexer, config.fetcher())?;
|
||||
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(async {
|
||||
let interface = Interface::build(&indexer, &computer);
|
||||
|
||||
let website = config.website();
|
||||
|
||||
let downloads_path = config.downloads_dir();
|
||||
|
||||
let bundle_path = if website.is_some() {
|
||||
let websites_dev_path = Path::new("../../websites");
|
||||
|
||||
let websites_path = if fs::exists(websites_dev_path)? {
|
||||
websites_dev_path.to_path_buf()
|
||||
} else {
|
||||
let downloaded_websites_path =
|
||||
downloads_path.join(format!("brk-{VERSION}")).join("websites");
|
||||
|
||||
if !fs::exists(&downloaded_websites_path)? {
|
||||
info!("Downloading websites from Github...");
|
||||
|
||||
let url = format!(
|
||||
"https://github.com/bitcoinresearchkit/brk/archive/refs/tags/v{VERSION}.zip",
|
||||
);
|
||||
|
||||
let response = minreq::get(url).send()?;
|
||||
let bytes = response.as_bytes();
|
||||
let cursor = Cursor::new(bytes);
|
||||
|
||||
let mut zip = zip::ZipArchive::new(cursor).unwrap();
|
||||
|
||||
zip.extract(downloads_path).unwrap();
|
||||
}
|
||||
|
||||
downloaded_websites_path
|
||||
};
|
||||
|
||||
interface.generate_bridge_file(website, websites_path.as_path())?;
|
||||
|
||||
let watch = config.watch();
|
||||
|
||||
Some(bundle(&websites_path, website.to_folder_name(), watch).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let server = Server::new(
|
||||
interface,
|
||||
bundle_path,
|
||||
);
|
||||
|
||||
let mcp = config.mcp();
|
||||
|
||||
tokio::spawn(async move {
|
||||
server.serve(mcp).await.unwrap();
|
||||
});
|
||||
|
||||
sleep(Duration::from_secs(1));
|
||||
|
||||
loop {
|
||||
wait_for_synced_node(rpc)?;
|
||||
|
||||
let block_count = rpc.get_block_count()?;
|
||||
|
||||
info!("{} blocks found.", block_count + 1);
|
||||
|
||||
let starting_indexes =
|
||||
indexer.index(&parser, rpc, &exit, config.check_collisions()).unwrap();
|
||||
|
||||
computer.compute(&indexer, starting_indexes, &exit).unwrap();
|
||||
|
||||
if let Some(delay) = config.delay() {
|
||||
sleep(Duration::from_secs(delay))
|
||||
}
|
||||
|
||||
info!("Waiting for new blocks...");
|
||||
|
||||
while block_count == rpc.get_block_count()? {
|
||||
sleep(Duration::from_secs(1))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1 +1,114 @@
|
||||
use brk_cli::main;
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
thread::{self, sleep},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use brk_alloc::Mimalloc;
|
||||
use brk_computer::Computer;
|
||||
use brk_error::Result;
|
||||
use brk_indexer::Indexer;
|
||||
use brk_iterator::Blocks;
|
||||
use brk_mempool::Mempool;
|
||||
use brk_query::AsyncQuery;
|
||||
use brk_reader::Reader;
|
||||
use brk_server::{Server, Website};
|
||||
use tracing::info;
|
||||
use vecdb::Exit;
|
||||
|
||||
mod config;
|
||||
mod paths;
|
||||
mod website;
|
||||
|
||||
use crate::{config::Config, paths::*, website::WebsiteArg};
|
||||
|
||||
pub fn main() -> anyhow::Result<()> {
|
||||
// Can't increase main thread's stack size, thus we need to use another thread
|
||||
thread::Builder::new()
|
||||
.stack_size(512 * 1024 * 1024)
|
||||
.spawn(run)?
|
||||
.join()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn run() -> anyhow::Result<()> {
|
||||
fs::create_dir_all(dot_brk_path())?;
|
||||
|
||||
brk_logger::init(Some(&dot_brk_log_path()))?;
|
||||
|
||||
let config = Config::import()?;
|
||||
|
||||
let client = config.rpc()?;
|
||||
|
||||
let exit = Exit::new();
|
||||
exit.set_ctrlc_handler();
|
||||
|
||||
let reader = Reader::new(config.blocksdir(), &client);
|
||||
|
||||
let blocks = Blocks::new(&client, &reader);
|
||||
|
||||
let mut indexer = Indexer::forced_import(&config.brkdir())?;
|
||||
|
||||
let mut computer = Computer::forced_import(&config.brkdir(), &indexer, config.fetcher())?;
|
||||
|
||||
let mempool = Mempool::new(&client);
|
||||
|
||||
let mempool_clone = mempool.clone();
|
||||
thread::spawn(move || {
|
||||
mempool_clone.start();
|
||||
});
|
||||
|
||||
let query = AsyncQuery::build(&reader, &indexer, &computer, Some(mempool));
|
||||
|
||||
let data_path = config.brkdir();
|
||||
|
||||
let website = match config.website() {
|
||||
WebsiteArg::Enabled(false) => Website::Disabled,
|
||||
WebsiteArg::Enabled(true) => Website::Default,
|
||||
WebsiteArg::Path(p) => Website::Filesystem(p),
|
||||
};
|
||||
|
||||
let port = config.brkport();
|
||||
|
||||
let future = async move {
|
||||
let server = Server::new(&query, data_path, website);
|
||||
|
||||
tokio::spawn(async move {
|
||||
server.serve(port).await.unwrap();
|
||||
});
|
||||
|
||||
Ok(()) as Result<()>
|
||||
};
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
let _handle = runtime.spawn(future);
|
||||
|
||||
loop {
|
||||
client.wait_for_synced_node()?;
|
||||
|
||||
let last_height = client.get_last_height()?;
|
||||
|
||||
info!("{} blocks found.", u32::from(last_height) + 1);
|
||||
|
||||
let starting_indexes = if config.check_collisions() {
|
||||
indexer.checked_index(&blocks, &client, &exit)?
|
||||
} else {
|
||||
indexer.index(&blocks, &client, &exit)?
|
||||
};
|
||||
|
||||
Mimalloc::collect();
|
||||
|
||||
computer.compute(&indexer, starting_indexes, &reader, &exit)?;
|
||||
|
||||
info!("Waiting for new blocks...");
|
||||
|
||||
while last_height == client.get_last_height()? {
|
||||
sleep(Duration::from_secs(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use std::{
|
||||
env,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn dot_brk_path() -> PathBuf {
|
||||
let home = std::env::var("HOME").unwrap();
|
||||
@@ -16,21 +13,11 @@ pub fn default_brk_path() -> PathBuf {
|
||||
dot_brk_path()
|
||||
}
|
||||
|
||||
pub fn default_bitcoin_path() -> PathBuf {
|
||||
if env::consts::OS == "macos" {
|
||||
default_mac_bitcoin_path()
|
||||
} else {
|
||||
default_linux_bitcoin_path()
|
||||
pub fn fix_user_path(path: &str) -> PathBuf {
|
||||
if let Some(rest) = path.strip_prefix("~/").or(path.strip_prefix("$HOME/"))
|
||||
&& let Ok(home) = std::env::var("HOME")
|
||||
{
|
||||
return PathBuf::from(home).join(rest);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_linux_bitcoin_path() -> PathBuf {
|
||||
Path::new(&std::env::var("HOME").unwrap()).join(".bitcoin")
|
||||
}
|
||||
|
||||
pub fn default_mac_bitcoin_path() -> PathBuf {
|
||||
Path::new(&std::env::var("HOME").unwrap())
|
||||
.join("Library")
|
||||
.join("Application Support")
|
||||
.join("Bitcoin")
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
use clap_derive::ValueEnum;
|
||||
use std::{path::PathBuf, str::FromStr};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, ValueEnum)]
|
||||
pub enum Website {
|
||||
None,
|
||||
Default,
|
||||
Custom,
|
||||
use crate::paths::fix_user_path;
|
||||
|
||||
/// Website configuration:
|
||||
/// - `true` or omitted: serve embedded website
|
||||
/// - `false`: disable website serving
|
||||
/// - `"/path/to/website"`: serve custom website from path
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum WebsiteArg {
|
||||
Enabled(bool),
|
||||
Path(PathBuf),
|
||||
}
|
||||
|
||||
impl Website {
|
||||
pub fn is_none(&self) -> bool {
|
||||
self == &Self::None
|
||||
}
|
||||
|
||||
pub fn is_some(&self) -> bool {
|
||||
!self.is_none()
|
||||
}
|
||||
|
||||
pub fn to_folder_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Custom => "custom",
|
||||
Self::Default => "default",
|
||||
Self::None => unreachable!(),
|
||||
}
|
||||
impl Default for WebsiteArg {
|
||||
fn default() -> Self {
|
||||
Self::Enabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for WebsiteArg {
|
||||
type Err = std::convert::Infallible;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(match s.to_lowercase().as_str() {
|
||||
"true" | "1" | "yes" | "on" => Self::Enabled(true),
|
||||
"false" | "0" | "no" | "off" => Self::Enabled(false),
|
||||
_ => Self::Path(fix_user_path(s)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "brk_client"
|
||||
description = "Rust client for the Bitcoin Research Kit API"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
keywords = ["bitcoin", "blockchain", "analytics", "on-chain"]
|
||||
categories = ["api-bindings", "cryptography::cryptocurrencies"]
|
||||
|
||||
[dependencies]
|
||||
brk_cohort = { workspace = true }
|
||||
brk_types = { workspace = true }
|
||||
minreq = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,53 @@
|
||||
# brk_client
|
||||
|
||||
Rust client for the [Bitcoin Research Kit](https://github.com/bitcoinresearchkit/brk) API.
|
||||
|
||||
[crates.io](https://crates.io/crates/brk_client) | [docs.rs](https://docs.rs/brk_client)
|
||||
|
||||
## Installation
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
brk_client = "0.1"
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```rust
|
||||
use brk_client::{BrkClient, Index};
|
||||
|
||||
fn main() -> brk_client::Result<()> {
|
||||
let client = BrkClient::new("http://localhost:3110");
|
||||
|
||||
// Blockchain data (mempool.space compatible)
|
||||
let block = client.get_block_by_height(800000)?;
|
||||
let tx = client.get_tx("abc123...")?;
|
||||
let address = client.get_address("bc1q...")?;
|
||||
|
||||
// Metrics API - typed, chainable
|
||||
let prices = client.metrics()
|
||||
.price.usd.split.close
|
||||
.by.dateindex()
|
||||
.range(Some(-30), None)?; // Last 30 days
|
||||
|
||||
// Generic metric fetching
|
||||
let data = client.get_metric(
|
||||
"price_close".into(),
|
||||
Index::DateIndex,
|
||||
Some(-30), None, None, None,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```rust
|
||||
use brk_client::{BrkClient, BrkClientOptions};
|
||||
|
||||
let client = BrkClient::with_options(BrkClientOptions {
|
||||
base_url: "http://localhost:3110".to_string(),
|
||||
timeout_secs: 60,
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Basic example of using the BRK client.
|
||||
|
||||
use brk_client::{BrkClient, BrkClientOptions};
|
||||
use brk_types::{FormatResponse, Index, Metric};
|
||||
|
||||
fn main() -> brk_client::Result<()> {
|
||||
// Create client with default options
|
||||
let client = BrkClient::new("http://localhost:3110");
|
||||
|
||||
// Or with custom options
|
||||
let _client_with_options = BrkClient::with_options(BrkClientOptions {
|
||||
base_url: "http://localhost:3110".to_string(),
|
||||
timeout_secs: 60,
|
||||
});
|
||||
|
||||
// Fetch price data using the typed metrics API
|
||||
// Using new idiomatic API: last(3).fetch()
|
||||
let price_close = client
|
||||
.metrics()
|
||||
.price
|
||||
.usd
|
||||
.split
|
||||
.close
|
||||
.by
|
||||
.dateindex()
|
||||
.last(3)
|
||||
.fetch()?;
|
||||
println!("Last 3 price close values: {:?}", price_close);
|
||||
|
||||
// Fetch block data
|
||||
let block_count = client
|
||||
.metrics()
|
||||
.blocks
|
||||
.count
|
||||
.block_count
|
||||
.sum
|
||||
.by
|
||||
.dateindex()
|
||||
.last(3)
|
||||
.fetch()?;
|
||||
println!("Last 3 block count values: {:?}", block_count);
|
||||
|
||||
// Fetch supply data
|
||||
dbg!(
|
||||
client
|
||||
.metrics()
|
||||
.supply
|
||||
.circulating
|
||||
.bitcoin
|
||||
.by
|
||||
.dateindex()
|
||||
.path()
|
||||
);
|
||||
let circulating = client
|
||||
.metrics()
|
||||
.supply
|
||||
.circulating
|
||||
.bitcoin
|
||||
.by
|
||||
.dateindex()
|
||||
.last(3)
|
||||
.fetch_csv()?;
|
||||
println!("Last 3 circulating supply values: {:?}", circulating);
|
||||
|
||||
// Using generic metric fetching
|
||||
let metricdata = client.get_metric(
|
||||
Metric::from("price_close"),
|
||||
Index::DateIndex,
|
||||
Some(-3),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
match metricdata {
|
||||
FormatResponse::Json(m) => {
|
||||
println!("Generic fetch result count: {}", m.data.len());
|
||||
}
|
||||
FormatResponse::Csv(_) => panic!(),
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Comprehensive test that fetches all endpoints in the tree.
|
||||
//!
|
||||
//! This example demonstrates how to recursively traverse the metrics catalog tree
|
||||
//! and fetch data from each endpoint. Run with: cargo run --example tree
|
||||
|
||||
use brk_client::BrkClient;
|
||||
use brk_types::{Index, TreeNode};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// A collected metric with its path and available indexes.
|
||||
struct CollectedMetric {
|
||||
path: String,
|
||||
name: String,
|
||||
indexes: BTreeSet<Index>,
|
||||
}
|
||||
|
||||
/// Recursively collect all metrics from the tree.
|
||||
fn collect_metrics(node: &TreeNode, path: &str) -> Vec<CollectedMetric> {
|
||||
let mut metrics = Vec::new();
|
||||
|
||||
match node {
|
||||
TreeNode::Branch(children) => {
|
||||
for (key, child) in children {
|
||||
let child_path = if path.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
format!("{}.{}", path, key)
|
||||
};
|
||||
metrics.extend(collect_metrics(child, &child_path));
|
||||
}
|
||||
}
|
||||
TreeNode::Leaf(leaf) => {
|
||||
metrics.push(CollectedMetric {
|
||||
path: path.to_string(),
|
||||
name: leaf.name().to_string(),
|
||||
indexes: leaf.indexes().clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
metrics
|
||||
}
|
||||
|
||||
fn main() -> brk_client::Result<()> {
|
||||
let client = BrkClient::new("http://localhost:3110");
|
||||
|
||||
// Get the metrics catalog tree
|
||||
let tree = client.get_metrics_tree()?;
|
||||
|
||||
// Recursively collect all metrics
|
||||
let metrics = collect_metrics(&tree, "");
|
||||
println!("\nFound {} metrics", metrics.len());
|
||||
|
||||
let mut success = 0;
|
||||
|
||||
for metric in &metrics {
|
||||
for index in &metric.indexes {
|
||||
let index_str = index.serialize_long();
|
||||
let full_path = format!("{}.by.{}", metric.path, index_str);
|
||||
|
||||
match client.get_metric(
|
||||
metric.name.as_str().into(),
|
||||
*index,
|
||||
None,
|
||||
Some(0),
|
||||
None,
|
||||
None,
|
||||
) {
|
||||
Ok(_) => {
|
||||
success += 1;
|
||||
println!("OK: {}", full_path);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("FAIL: {} -> {}", full_path, e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n=== Results ===");
|
||||
println!("Success: {}", success);
|
||||
|
||||
Ok(())
|
||||
}
|
||||