mirror of
https://github.com/lov3b/ecb-rates.git
synced 2025-12-20 03:10:38 +01:00
Compare commits
24 Commits
perspectiv
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 75f73e5d5e | |||
| 980ae6f8cb | |||
| 364875a84e | |||
| f388c31594 | |||
|
|
837a8e9851 | ||
| 0f930271c7 | |||
| 61deca69c6 | |||
|
|
292c9d4f34 | ||
| 0f921e978e | |||
| 3fce153c1c | |||
| 416233afde | |||
| f2f27a25b6 | |||
| f971f3ba81 | |||
| 0919a189cf | |||
| f4c1b04edb | |||
| 68f63e9cea | |||
| ec8aa710e0 | |||
| 52377e9988 | |||
| c381ed2a79 | |||
| f501569040 | |||
| a39d0331b3 | |||
| 1431e2b14f | |||
| 65ad88acae | |||
| 5f3a075580 |
51
.github/workflows/package.yml
vendored
Normal file
51
.github/workflows/package.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
name: Build Packages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*" # Triggers on tags starting with 'v'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write # Needed for creating releases
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_and_release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: actions-rs/toolchain@v1
|
||||||
|
with:
|
||||||
|
toolchain: stable
|
||||||
|
override: true
|
||||||
|
|
||||||
|
- name: Install packaging tools
|
||||||
|
run: |
|
||||||
|
cargo install cargo-deb
|
||||||
|
cargo install cargo-generate-rpm
|
||||||
|
|
||||||
|
- name: Build Debian package
|
||||||
|
run: cargo deb
|
||||||
|
|
||||||
|
- id: get_tag_info
|
||||||
|
name: Get Tag Info
|
||||||
|
run: |
|
||||||
|
tag="${GITHUB_REF##*/}"
|
||||||
|
echo "Found tag: $tag"
|
||||||
|
message=$(git tag -l --format='%(contents)' "$tag")
|
||||||
|
# Set outputs using the recommended environment files approach
|
||||||
|
echo "tag=$tag" >> $GITHUB_OUTPUT
|
||||||
|
echo "message=$message" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: ncipollo/release-action@v1
|
||||||
|
with:
|
||||||
|
tag: ${{ steps.get_tag_info.outputs.tag }}
|
||||||
|
name: Release ${{ steps.get_tag_info.outputs.tag }}
|
||||||
|
body: ${{ steps.get_tag_info.outputs.message }}
|
||||||
|
artifacts: |
|
||||||
|
target/debian/*.deb
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
allowUpdates: true
|
||||||
929
Cargo.lock
generated
929
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
45
Cargo.toml
45
Cargo.toml
@@ -1,40 +1,37 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ecb-rates"
|
name = "ecb-rates"
|
||||||
description = "Query exchange rates from the European Central Bank (ECB)"
|
description = "Query exchange rates from the European Central Bank (ECB)"
|
||||||
version = "0.1.0"
|
version = "1.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
authors = ["Love Billenius <lovebillenius@disroot.org>"]
|
authors = ["Love Billenius <lovebillenius@disroot.org>"]
|
||||||
license-file = "LICENSE"
|
license = "Zlib"
|
||||||
keywords = [
|
keywords = [
|
||||||
"ECB",
|
"ECB",
|
||||||
"European Central Bank",
|
|
||||||
"Bank",
|
"Bank",
|
||||||
"Central Bank",
|
"Central",
|
||||||
"exchange",
|
"exchange",
|
||||||
"rates",
|
"rates",
|
||||||
"eur",
|
|
||||||
"sek",
|
|
||||||
"usd",
|
|
||||||
"nok",
|
|
||||||
"gbp",
|
|
||||||
"pln",
|
|
||||||
"dkk",
|
|
||||||
"czk",
|
|
||||||
"isk",
|
|
||||||
"chf",
|
|
||||||
]
|
]
|
||||||
|
repository = "https://github.com/lov3b/ecb-rates"
|
||||||
|
rust-version = "1.92"
|
||||||
|
categories = ["finance", "command-line-utilities"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
codegen-units = 1
|
||||||
|
lto = true
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "ecb-rates"
|
name = "ecb-rates"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.95"
|
anyhow = "1.0"
|
||||||
chrono = { version = "0.4.39", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
clap = { version = "4.5.23", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
colored = "3.0.0"
|
colored = "3.0"
|
||||||
quick-xml = { version = "0.37.2", features = ["async-tokio", "tokio"] }
|
quick-xml = { version = "0.38", features = ["async-tokio", "tokio"] }
|
||||||
reqwest = "0.12.12"
|
reqwest = "0.12"
|
||||||
serde = { version = "1.0.217", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0.134"
|
serde_json = "1.0"
|
||||||
tokio = { version = "1.42.0", features = ["macros"] }
|
smol_str = { version = "0.3", features = ["serde"] }
|
||||||
|
tokio = "1.48"
|
||||||
|
|||||||
94
README.md
94
README.md
@@ -1,45 +1,91 @@
|
|||||||
# ECB Rates
|
# ECB Rates
|
||||||
|
|
||||||
A cli utility to fetch the currency rates against the Euro from the ECB.
|
<p style="text-align: center">
|
||||||
|
<img
|
||||||
|
src="images/Logo_European_Central_Bank.svg"
|
||||||
|
width="200"
|
||||||
|
alt="European Central Bank Logo"
|
||||||
|
align="left"
|
||||||
|
/>
|
||||||
|
A CLI utility to fetch exchange rates from the European Central Bank.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
</p>
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
|
### Binary
|
||||||
|
|
||||||
|
If you're on Debian Linux, then just go over to the releases, and install the latest _.deb_ package with `dpkg`
|
||||||
|
|
||||||
|
### Source
|
||||||
|
|
||||||
First, make sure that you have the rust toolchain installed. If not, then go to [rustup](https://rustup.rs) to install it.
|
First, make sure that you have the rust toolchain installed. If not, then go to [rustup](https://rustup.rs) to install it.
|
||||||
|
|
||||||
Now, Git clone the project, then cd into the projects root-dir. Thereafter run:
|
Now, run the following cargo command:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo install --path .
|
cargo install ecb-rates
|
||||||
```
|
```
|
||||||
|
|
||||||
Congratulations! Now the cli binary `ecb-rates` will be in your cargo bin folder.
|
Congratulations! Now the cli binary `ecb-rates` will be in your cargo bin folder.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Fetch and display select currencies:
|
#### Fetch as many days as you want
|
||||||
- as an ASCII table
|
|
||||||
- in JSON prettified
|
|
||||||
- in JSON minified
|
|
||||||
- Fetch in different "resolutions":
|
|
||||||
- Last available day.
|
|
||||||
- Since the dawn of the *EUR*
|
|
||||||
- in day resolution
|
|
||||||
- in 90 day resolution
|
|
||||||
|
|
||||||
### Example
|
It will fetch any of the following api nodes, and reduce them for you.
|
||||||
|
|
||||||
```sh
|
- Last available day.
|
||||||
ecb-rates -c sek -c nok -c usd
|
- Last 90 days
|
||||||
```
|
- Since the dawn of the _EUR_
|
||||||
|
|
||||||
```plain
|
#### Display select currencies
|
||||||
2025-01-07
|
|
||||||
Currency Rate
|
- as an ASCII table
|
||||||
---------------------
|
- in JSON prettified
|
||||||
USD 1.0393
|
- in JSON minified
|
||||||
SEK 11.475
|
|
||||||
NOK 11.7385
|
#### Cache
|
||||||
```
|
|
||||||
|
It features an extensive cache, which will [calculate hollidays](src/holiday.rs) in order to know whether to invalidate it or not.
|
||||||
|
|
||||||
|
#### Show the rates in your way
|
||||||
|
|
||||||
|
Change the rates for the perspective of any currency with the `--perspective` or `-p` flag.
|
||||||
|
|
||||||
|
Flip it from `EUR to ALL` to `ALL to EUR` with the `--invert` or `-i` flag. It will work as expected with the _perspective_ option.
|
||||||
|
|
||||||
|
#### Fast
|
||||||
|
|
||||||
|
It wouldn't be a rust project without being _BLAZINGLY FAST_! When the cache is valid a single day will on my computer be shown in 3 ms. When the cache isn't being used it will be ~90ms. The cache speed will largely depend on your drive, the latter will depend on your network speed. Both options are fast enought to be in a `.bashrc` or `.zshrc`
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
#### Show the original data from ECB
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### ...with only select currencies
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Put the exchange rate in the perspective of any currency
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Flip it
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Show multiple days
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Acknowledgment
|
## Acknowledgment
|
||||||
|
|
||||||
|
|||||||
154
images/Logo_European_Central_Bank.svg
Normal file
154
images/Logo_European_Central_Bank.svg
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:i="http://ns.adobe.com/AdobeIllustrator/10.0/"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="398.5"
|
||||||
|
height="329.42899"
|
||||||
|
id="svg2362"
|
||||||
|
sodipodi:version="0.32"
|
||||||
|
inkscape:version="0.92.1 r15371"
|
||||||
|
version="1.0"
|
||||||
|
sodipodi:docname="Logo_European_Central_Bank.svg"
|
||||||
|
inkscape:output_extension="org.inkscape.output.svg.inkscape">
|
||||||
|
<defs
|
||||||
|
id="defs3" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
id="base"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:zoom="0.70710678"
|
||||||
|
inkscape:cx="-3.3417132"
|
||||||
|
inkscape:cy="39.599076"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
inkscape:window-width="1600"
|
||||||
|
inkscape:window-height="837"
|
||||||
|
inkscape:window-x="-8"
|
||||||
|
inkscape:window-y="-8"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:window-maximized="1" />
|
||||||
|
<metadata
|
||||||
|
id="metadata4">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-150.75,-367.64768)">
|
||||||
|
<g
|
||||||
|
id="g2384">
|
||||||
|
<path
|
||||||
|
i:knockout="Off"
|
||||||
|
d="M 496.009,494.23968 C 496.071,495.97068 496.267,497.65768 496.267,499.40768 C 496.267,579.87268 430.849,645.29868 350.388,645.29868 C 269.915,645.29868 204.476,579.87268 204.476,499.40768 C 204.476,497.65768 204.679,495.97068 204.742,494.23968 L 150.75,494.23968 L 150.75,697.07668 L 549.25,697.07668 L 549.25,494.23968 L 496.009,494.23968"
|
||||||
|
id="path13"
|
||||||
|
style="fill:#003399;fill-opacity:1" />
|
||||||
|
<path
|
||||||
|
i:knockout="Off"
|
||||||
|
d="M 218.624,499.40768 C 218.624,426.74068 277.712,367.64768 350.388,367.64768 C 423.056,367.64768 482.14,426.74068 482.14,499.40768 C 482.14,572.07468 423.056,631.15868 350.388,631.15868 C 277.712,631.15868 218.624,572.07468 218.624,499.40768"
|
||||||
|
id="path15"
|
||||||
|
style="fill:#003399;fill-opacity:1" />
|
||||||
|
<polygon
|
||||||
|
i:knockout="Off"
|
||||||
|
points="893.164,12.178 890.506,20.424 881.746,20.424 888.885,25.756 886.117,34.219 893.164,29.002 900.223,34.219 897.48,25.756 904.553,20.424 895.758,20.424 893.164,12.178 "
|
||||||
|
id="polygon17"
|
||||||
|
style="fill:#ffcc00;fill-opacity:1"
|
||||||
|
transform="translate(-542.487,367.64768)" />
|
||||||
|
<polygon
|
||||||
|
i:knockout="Off"
|
||||||
|
points="839.947,26.717 837.285,34.972 828.518,34.972 835.674,40.304 832.889,48.766 839.947,43.55 847.002,48.766 844.242,40.304 851.346,34.972 842.537,34.972 839.947,26.717 "
|
||||||
|
id="polygon19"
|
||||||
|
style="fill:#ffcc00;fill-opacity:1"
|
||||||
|
transform="translate(-542.487,367.64768)" />
|
||||||
|
<polygon
|
||||||
|
i:knockout="Off"
|
||||||
|
points="800.373,64.744 797.717,72.99 788.949,72.99 796.092,78.322 793.32,86.785 800.373,81.577 807.42,86.785 804.674,78.322 811.764,72.99 802.969,72.99 800.373,64.744 "
|
||||||
|
id="polygon21"
|
||||||
|
style="fill:#ffcc00;fill-opacity:1"
|
||||||
|
transform="translate(-542.487,367.64768)" />
|
||||||
|
<polygon
|
||||||
|
i:knockout="Off"
|
||||||
|
points="784.711,120.441 782.053,128.696 773.285,128.687 780.432,134.027 777.656,142.49 784.711,137.273 791.77,142.49 789.02,134.027 796.1,128.696 787.305,128.696 784.711,120.441 "
|
||||||
|
id="polygon23"
|
||||||
|
style="fill:#ffcc00;fill-opacity:1"
|
||||||
|
transform="translate(-542.487,367.64768)" />
|
||||||
|
<polygon
|
||||||
|
i:knockout="Off"
|
||||||
|
points="801.535,172.971 798.877,181.217 790.117,181.217 797.266,186.558 794.48,195.012 801.535,189.804 808.594,195.012 805.844,186.558 812.938,181.217 804.139,181.217 801.535,172.971 "
|
||||||
|
id="polygon25"
|
||||||
|
style="fill:#ffcc00;fill-opacity:1"
|
||||||
|
transform="translate(-542.487,367.64768)" />
|
||||||
|
<polygon
|
||||||
|
i:knockout="Off"
|
||||||
|
points="837.967,211.207 835.311,219.453 826.541,219.453 833.686,224.784 830.904,233.247 837.967,228.04 845.014,233.247 842.275,224.784 849.357,219.453 840.562,219.453 837.967,211.207 "
|
||||||
|
id="polygon27"
|
||||||
|
style="fill:#ffcc00;fill-opacity:1"
|
||||||
|
transform="translate(-542.487,367.64768)" />
|
||||||
|
<polygon
|
||||||
|
i:knockout="Off"
|
||||||
|
points="892.584,229.292 889.936,237.539 881.166,237.539 888.314,242.879 885.537,251.342 892.584,246.125 899.65,251.342 896.9,242.879 903.986,237.539 895.188,237.539 892.584,229.292 "
|
||||||
|
id="polygon29"
|
||||||
|
style="fill:#ffcc00;fill-opacity:1"
|
||||||
|
transform="translate(-542.487,367.64768)" />
|
||||||
|
<polygon
|
||||||
|
i:knockout="Off"
|
||||||
|
points="947.262,211.517 944.613,219.763 935.854,219.763 942.984,225.103 940.221,233.566 947.262,228.349 954.33,233.566 951.584,225.103 958.66,219.763 949.857,219.763 947.262,211.517 "
|
||||||
|
id="polygon31"
|
||||||
|
style="fill:#ffcc00;fill-opacity:1"
|
||||||
|
transform="translate(-542.487,367.64768)" />
|
||||||
|
<polygon
|
||||||
|
i:knockout="Off"
|
||||||
|
points="983.902,173.48 981.246,181.722 972.486,181.722 979.633,187.067 976.854,195.525 983.902,190.313 990.953,195.525 988.217,187.067 995.293,181.722 986.498,181.722 983.902,173.48 "
|
||||||
|
id="polygon33"
|
||||||
|
style="fill:#ffcc00;fill-opacity:1"
|
||||||
|
transform="translate(-542.487,367.64768)" />
|
||||||
|
<polygon
|
||||||
|
i:knockout="Off"
|
||||||
|
points="1001.059,121.021 998.402,129.267 989.625,129.267 996.781,134.607 994,143.062 1001.059,137.854 1008.1,143.062 1005.363,134.607 1012.449,129.267 1003.645,129.267 1001.059,121.021 "
|
||||||
|
id="polygon35"
|
||||||
|
style="fill:#ffcc00;fill-opacity:1"
|
||||||
|
transform="translate(-542.487,367.64768)" />
|
||||||
|
<polygon
|
||||||
|
i:knockout="Off"
|
||||||
|
points="985.684,65.262 983.018,73.517 974.258,73.517 981.404,78.849 978.623,87.312 985.684,82.095 992.732,87.312 989.988,78.849 997.072,73.517 988.277,73.517 985.684,65.262 "
|
||||||
|
id="polygon37"
|
||||||
|
style="fill:#ffcc00;fill-opacity:1"
|
||||||
|
transform="translate(-542.487,367.64768)" />
|
||||||
|
<polygon
|
||||||
|
i:knockout="Off"
|
||||||
|
points="946.322,27.018 943.666,35.259 934.896,35.259 942.035,40.6 939.256,49.054 946.322,43.851 953.363,49.054 950.619,40.6 957.713,35.259 948.908,35.259 946.322,27.018 "
|
||||||
|
id="polygon39"
|
||||||
|
style="fill:#ffcc00;fill-opacity:1"
|
||||||
|
transform="translate(-542.487,367.64768)" />
|
||||||
|
<path
|
||||||
|
i:knockout="Off"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M 357.036,558.23968 C 333.548,558.23968 313.673,540.80068 306.972,516.75868 L 379.452,516.75868 L 384.456,504.87768 L 304.823,504.87768 C 304.651,502.92868 304.55,500.93168 304.55,498.93468 C 304.55,496.92468 304.652,494.94468 304.823,492.98768 L 389.452,492.98768 L 394.456,481.10668 L 306.972,481.10668 C 313.673,457.06468 333.548,439.63368 357.036,439.63368 C 373.173,439.63368 390.647,447.84868 403.003,460.77968 L 407.696,449.58868 C 393.684,436.06868 374.917,427.65868 357.036,427.65868 C 327.647,427.65868 302.956,450.36768 295.954,481.10568 L 279.511,481.10568 L 274.511,492.98668 L 294.183,492.98668 C 294.029,494.95268 293.966,496.93268 293.966,498.93368 C 293.966,500.93168 294.028,502.91968 294.183,504.87668 L 279.454,504.87668 L 274.458,516.75768 L 295.954,516.75768 C 302.956,547.50468 327.647,570.20468 357.036,570.20468 C 372.995,570.20468 389.647,563.49968 403.02,552.45468 L 403.02,537.13668 L 403.002,537.08768 C 390.648,550.01668 373.173,558.23968 357.036,558.23968"
|
||||||
|
id="path41"
|
||||||
|
style="fill:#ffec00;fill-rule:evenodd" />
|
||||||
|
<path
|
||||||
|
i:knockout="Off"
|
||||||
|
d="M 357.036,558.23968 C 333.548,558.23968 313.673,540.80068 306.972,516.75868 L 379.452,516.75868 L 384.456,504.87768 L 304.823,504.87768 C 304.651,502.92868 304.55,500.93168 304.55,498.93468 C 304.55,496.92468 304.652,494.94468 304.823,492.98768 L 389.452,492.98768 L 394.456,481.10668 L 306.972,481.10668 C 313.673,457.06468 333.548,439.63368 357.036,439.63368 C 373.173,439.63368 390.647,447.84868 403.003,460.77968 L 407.696,449.58868 C 393.684,436.06868 374.917,427.65868 357.036,427.65868 C 327.647,427.65868 302.956,450.36768 295.954,481.10568 L 279.511,481.10568 L 274.511,492.98668 L 294.183,492.98668 C 294.029,494.95268 293.966,496.93268 293.966,498.93368 C 293.966,500.93168 294.028,502.91968 294.183,504.87668 L 279.454,504.87668 L 274.458,516.75768 L 295.954,516.75768 C 302.956,547.50468 327.647,570.20468 357.036,570.20468 C 372.995,570.20468 389.647,563.49968 403.02,552.45468 L 403.02,537.13668 L 403.002,537.08768 C 390.648,550.01668 373.173,558.23968 357.036,558.23968 z "
|
||||||
|
id="path43"
|
||||||
|
style="fill:#ffcc00;stroke:#ffec00;stroke-width:0.1;fill-opacity:1" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.9 KiB |
BIN
images/all-to-usd.png
Normal file
BIN
images/all-to-usd.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
BIN
images/eur-to-all-multiple-days.png
Normal file
BIN
images/eur-to-all-multiple-days.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
BIN
images/eur-to-all-select.png
Normal file
BIN
images/eur-to-all-select.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
BIN
images/eur-to-all.png
Normal file
BIN
images/eur-to-all.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
BIN
images/usd-to-all.png
Normal file
BIN
images/usd-to-all.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
96
src/cache/cache.rs
vendored
96
src/cache/cache.rs
vendored
@@ -1,96 +0,0 @@
|
|||||||
use std::fs;
|
|
||||||
use std::io::{BufReader, BufWriter};
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::cli::Resolution;
|
|
||||||
use crate::os::Os;
|
|
||||||
|
|
||||||
use super::CacheLine;
|
|
||||||
|
|
||||||
const FILE_NAME: &'static str = "cache.json";
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
|
||||||
pub struct Cache {
|
|
||||||
day: Option<CacheLine>,
|
|
||||||
hist_90: Option<CacheLine>,
|
|
||||||
hist_day: Option<CacheLine>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Cache {
|
|
||||||
pub fn load() -> Option<Self> {
|
|
||||||
let config_opt = Os::get_current()?.get_config_path();
|
|
||||||
let mut config_path = match config_opt {
|
|
||||||
Ok(k) => k,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to locate config dir: {:?}", e);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Err(e) = fs::create_dir_all(&config_path) {
|
|
||||||
eprintln!("Failed to create config dir: {:?}", e);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
config_path.push(FILE_NAME);
|
|
||||||
if !config_path.try_exists().unwrap_or_default() {
|
|
||||||
return Some(Self {
|
|
||||||
day: None,
|
|
||||||
hist_90: None,
|
|
||||||
hist_day: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
match Self::read_config(&config_path) {
|
|
||||||
Ok(k) => Some(k),
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Config path is invalid, or cannot be created: {:?}", e);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_cache_line(&self, resolution: Resolution) -> Option<&CacheLine> {
|
|
||||||
match resolution {
|
|
||||||
Resolution::TODAY => self.day.as_ref(),
|
|
||||||
Resolution::HistDays90 => self.hist_90.as_ref(),
|
|
||||||
Resolution::HistDaysAll => self.hist_day.as_ref(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_cache_line(&mut self, resolution: Resolution, cache_line: CacheLine) {
|
|
||||||
let cache_line_opt = Some(cache_line);
|
|
||||||
match resolution {
|
|
||||||
Resolution::TODAY => self.day = cache_line_opt,
|
|
||||||
Resolution::HistDays90 => self.hist_90 = cache_line_opt,
|
|
||||||
Resolution::HistDaysAll => self.hist_day = cache_line_opt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save(&self) -> anyhow::Result<()> {
|
|
||||||
let mut config_path = Os::get_current()
|
|
||||||
.context("Failed to get config home")?
|
|
||||||
.get_config_path()?;
|
|
||||||
fs::create_dir_all(&config_path)?;
|
|
||||||
|
|
||||||
config_path.push(FILE_NAME);
|
|
||||||
|
|
||||||
let file = fs::File::options()
|
|
||||||
.write(true)
|
|
||||||
.create(true)
|
|
||||||
.truncate(true)
|
|
||||||
.open(&config_path)?;
|
|
||||||
|
|
||||||
let writer = BufWriter::new(file);
|
|
||||||
serde_json::to_writer(writer, self)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_config(path: &Path) -> anyhow::Result<Self> {
|
|
||||||
let file = fs::File::open(path)?;
|
|
||||||
let reader = BufReader::new(file);
|
|
||||||
Ok(serde_json::from_reader(reader)?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
75
src/caching/cache.rs
Normal file
75
src/caching/cache.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::io::{BufReader, BufWriter};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use super::CacheLine;
|
||||||
|
use crate::View;
|
||||||
|
use crate::os::Os;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Cache {
|
||||||
|
cache_line: Option<CacheLine>,
|
||||||
|
config_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cache {
|
||||||
|
pub fn load(view: &View) -> Option<Self> {
|
||||||
|
let config_opt = Os::get_current()?.get_config_path();
|
||||||
|
let mut config_path = match config_opt {
|
||||||
|
Ok(k) => k,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to locate config dir: {:?}", e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = fs::create_dir_all(&config_path) {
|
||||||
|
eprintln!("Failed to create config dir: {:?}", e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
config_path.push(format!("{}.json", view.get_name()));
|
||||||
|
if !config_path.try_exists().unwrap_or_default() {
|
||||||
|
return Some(Self {
|
||||||
|
cache_line: None,
|
||||||
|
config_path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
match Self::read_config(&config_path) {
|
||||||
|
Ok(cache_line) => Some(Self {
|
||||||
|
cache_line: Some(cache_line),
|
||||||
|
config_path,
|
||||||
|
}),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Config path is invalid, or cannot be created: {:?}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_cache_line(&self) -> Option<&CacheLine> {
|
||||||
|
self.cache_line.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_cache_line(&mut self, cache_line: CacheLine) {
|
||||||
|
self.cache_line = Some(cache_line);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> anyhow::Result<()> {
|
||||||
|
let file = fs::File::options()
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(&self.config_path)?;
|
||||||
|
|
||||||
|
let writer = BufWriter::new(file);
|
||||||
|
serde_json::to_writer(writer, &self.cache_line)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_config(path: &Path) -> anyhow::Result<CacheLine> {
|
||||||
|
let file = fs::File::open(path)?;
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
Ok(serde_json::from_reader(reader)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ use chrono::serde::ts_seconds;
|
|||||||
use chrono::{DateTime, Datelike, FixedOffset, Local, NaiveDate, TimeDelta, Utc, Weekday};
|
use chrono::{DateTime, Datelike, FixedOffset, Local, NaiveDate, TimeDelta, Utc, Weekday};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::models::ExchangeRateResult;
|
|
||||||
use crate::Hollidays;
|
use crate::Hollidays;
|
||||||
|
use crate::models::ExchangeRateResult;
|
||||||
|
|
||||||
const CET: FixedOffset = unsafe { FixedOffset::east_opt(3600).unwrap_unchecked() };
|
const CET: FixedOffset = unsafe { FixedOffset::east_opt(3600).unwrap_unchecked() };
|
||||||
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
use clap::{arg, Parser, ValueEnum};
|
use clap::{Parser, ValueEnum};
|
||||||
|
use smol_str::SmolStr;
|
||||||
|
|
||||||
use crate::ecb_url;
|
use super::{ShowDays, SortBy};
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
#[command(author, version, about)]
|
#[command(author, version, about)]
|
||||||
|
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
/// Which currencies do you want to fetch rates for?
|
/// Which currencies do you want to fetch rates for?
|
||||||
#[arg(long = "currencies", short = 'c')]
|
#[arg(long = "currencies", short = 'c')]
|
||||||
pub currencies: Vec<String>,
|
pub currencies: Vec<SmolStr>,
|
||||||
|
|
||||||
#[arg(value_enum, default_value_t = FormatOption::Plain)]
|
#[arg(value_enum, default_value_t = FormatOption::Plain)]
|
||||||
pub command: FormatOption,
|
pub command: FormatOption,
|
||||||
@@ -30,58 +30,24 @@ pub struct Cli {
|
|||||||
pub force_color: bool,
|
pub force_color: bool,
|
||||||
|
|
||||||
/// Sort by the currency name (in alphabetical order), or by the rate value (low -> high)
|
/// Sort by the currency name (in alphabetical order), or by the rate value (low -> high)
|
||||||
#[arg(value_enum, long = "sort-by", short = 's', default_value_t = SortBy::Currency)]
|
#[arg(value_enum, long = "sort-by", default_value_t = SortBy::Currency)]
|
||||||
pub sort_by: SortBy,
|
pub sort_by: SortBy,
|
||||||
|
|
||||||
/// Recalculate to the perspective from an included currency
|
/// Recalculate to the perspective from an included currency
|
||||||
#[arg(long = "perspective", short = 'p')]
|
#[arg(long = "perspective", short = 'p')]
|
||||||
pub perspective: Option<String>,
|
pub perspective: Option<SmolStr>,
|
||||||
|
|
||||||
/// Invert the rate
|
/// Invert the rate
|
||||||
#[arg(long = "invert", short = 'i')]
|
#[arg(long = "invert", short = 'i')]
|
||||||
pub should_invert: bool,
|
pub should_invert: bool,
|
||||||
|
|
||||||
//// Max decimals to keep in price.
|
/// Max decimals to keep in price.
|
||||||
#[arg(long = "max-decimals", short = 'd', default_value_t = 5)]
|
#[arg(long = "max-decimals", short = 'd', default_value_t = 5)]
|
||||||
pub max_decimals: u8,
|
pub max_decimals: u8,
|
||||||
|
|
||||||
/// Amount of data
|
/// Amount of data
|
||||||
#[arg(value_enum, default_value_t = Resolution::TODAY, long="resolution", short='r')]
|
#[arg(default_value_t = ShowDays::Days(1), long="show-days", short='s')]
|
||||||
pub resolution: Resolution,
|
pub show_days: ShowDays,
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
|
||||||
pub enum SortBy {
|
|
||||||
Currency,
|
|
||||||
Rate,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
|
||||||
pub enum Resolution {
|
|
||||||
TODAY,
|
|
||||||
#[clap(name = "hist-90-days")]
|
|
||||||
HistDays90,
|
|
||||||
#[clap(name = "hist-all-days")]
|
|
||||||
HistDaysAll,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Resolution {
|
|
||||||
pub fn to_ecb_url(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Resolution::TODAY => ecb_url::TODAY,
|
|
||||||
Resolution::HistDays90 => ecb_url::hist::DAYS_90,
|
|
||||||
Resolution::HistDaysAll => ecb_url::hist::DAYS_ALL,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SortBy {
|
|
||||||
pub fn get_comparer(&self) -> fn(&(&str, f64), &(&str, f64)) -> std::cmp::Ordering {
|
|
||||||
match self {
|
|
||||||
Self::Currency => |a, b| a.0.cmp(&b.0),
|
|
||||||
Self::Rate => |a, b| a.1.total_cmp(&b.1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||||
7
src/cli/mod.rs
Normal file
7
src/cli/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
mod cli_t;
|
||||||
|
mod since;
|
||||||
|
mod sort_by;
|
||||||
|
|
||||||
|
pub use cli_t::{Cli, FormatOption};
|
||||||
|
pub use since::ShowDays;
|
||||||
|
pub use sort_by::SortBy;
|
||||||
54
src/cli/since.rs
Normal file
54
src/cli/since.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
use crate::View;
|
||||||
|
use std::{fmt, str::FromStr};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum ShowDays {
|
||||||
|
Days(usize),
|
||||||
|
All,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for ShowDays {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
if s.eq_ignore_ascii_case("all") {
|
||||||
|
return Ok(ShowDays::All);
|
||||||
|
}
|
||||||
|
s.parse::<usize>().map(ShowDays::Days).map_err(|_| {
|
||||||
|
format!(
|
||||||
|
"Invalid value for since: '{}'. Use a positive integer or 'start'.",
|
||||||
|
s
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl fmt::Display for ShowDays {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
ShowDays::Days(days) => write!(f, "{}", days),
|
||||||
|
ShowDays::All => write!(f, "all"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShowDays {
|
||||||
|
/// None represents infinity
|
||||||
|
pub fn to_option(&self) -> Option<usize> {
|
||||||
|
match self {
|
||||||
|
ShowDays::Days(d) => Some(*d),
|
||||||
|
ShowDays::All => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_view(&self) -> Option<View> {
|
||||||
|
match self {
|
||||||
|
ShowDays::Days(d) => match d {
|
||||||
|
0 => None,
|
||||||
|
1 => Some(View::TODAY),
|
||||||
|
2..=90 => Some(View::HistDays90),
|
||||||
|
91.. => Some(View::HistDaysAll),
|
||||||
|
},
|
||||||
|
ShowDays::All => Some(View::HistDaysAll),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/cli/sort_by.rs
Normal file
16
src/cli/sort_by.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
use clap::ValueEnum;
|
||||||
|
|
||||||
|
#[derive(Debug, ValueEnum, Clone)]
|
||||||
|
pub enum SortBy {
|
||||||
|
Currency,
|
||||||
|
Rate,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SortBy {
|
||||||
|
pub fn get_comparer(&self) -> fn(&(&str, f64), &(&str, f64)) -> std::cmp::Ordering {
|
||||||
|
match self {
|
||||||
|
Self::Currency => |a, b| a.0.cmp(b.0),
|
||||||
|
Self::Rate => |a, b| a.1.total_cmp(&b.1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,12 @@ pub struct HeaderDescription<'a> {
|
|||||||
header_description: [&'a str; 2],
|
header_description: [&'a str; 2],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'a> Default for HeaderDescription<'a> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<'a> HeaderDescription<'a> {
|
impl<'a> HeaderDescription<'a> {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
13
src/lib.rs
13
src/lib.rs
@@ -1,4 +1,4 @@
|
|||||||
pub mod cache;
|
pub mod caching;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
mod header_description;
|
mod header_description;
|
||||||
mod holiday;
|
mod holiday;
|
||||||
@@ -7,20 +7,21 @@ pub mod os;
|
|||||||
pub mod parsing;
|
pub mod parsing;
|
||||||
pub mod table;
|
pub mod table;
|
||||||
pub mod utils_calc;
|
pub mod utils_calc;
|
||||||
|
mod view;
|
||||||
|
|
||||||
pub use header_description::HeaderDescription;
|
pub use header_description::HeaderDescription;
|
||||||
pub use holiday::Hollidays;
|
pub use holiday::Hollidays;
|
||||||
|
pub use view::View;
|
||||||
|
|
||||||
const APP_NAME: &'static str = "ECB-rates";
|
const APP_NAME: &str = "ECB-rates";
|
||||||
const DEFAULT_WIDTH: usize = 20;
|
const DEFAULT_WIDTH: usize = 20;
|
||||||
|
|
||||||
pub mod ecb_url {
|
pub mod ecb_url {
|
||||||
pub const TODAY: &'static str = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml";
|
pub const TODAY: &str = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml";
|
||||||
|
|
||||||
pub mod hist {
|
pub mod hist {
|
||||||
pub const DAYS_ALL: &'static str =
|
pub const DAYS_ALL: &str = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml";
|
||||||
"https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml";
|
pub const DAYS_90: &str =
|
||||||
pub const DAYS_90: &'static str =
|
|
||||||
"https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml";
|
"https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
96
src/main.rs
96
src/main.rs
@@ -1,7 +1,9 @@
|
|||||||
|
use anyhow::Context;
|
||||||
use clap::Parser as _;
|
use clap::Parser as _;
|
||||||
use ecb_rates::cache::{Cache, CacheLine};
|
use ecb_rates::caching::{Cache, CacheLine};
|
||||||
use ecb_rates::HeaderDescription;
|
use ecb_rates::HeaderDescription;
|
||||||
use reqwest::{Client, IntoUrl};
|
use reqwest::{Client, IntoUrl};
|
||||||
|
use smol_str::StrExt;
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
use ecb_rates::cli::{Cli, FormatOption};
|
use ecb_rates::cli::{Cli, FormatOption};
|
||||||
@@ -16,72 +18,82 @@ async fn get_and_parse(url: impl IntoUrl) -> anyhow::Result<Vec<ExchangeRateResu
|
|||||||
parse(&xml_content)
|
parse(&xml_content)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main(flavor = "current_thread")]
|
fn main() -> ExitCode {
|
||||||
async fn main() -> ExitCode {
|
let cli = Cli::parse();
|
||||||
let mut cli = Cli::parse();
|
|
||||||
|
let runtime = match tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(runtime) => runtime,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to initialize asynchronous runtime: {:?}", e);
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match runtime.block_on(async_main(cli)) {
|
||||||
|
Ok(_) => ExitCode::SUCCESS,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Fatal: {:?}", e);
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn async_main(mut cli: Cli) -> anyhow::Result<()> {
|
||||||
if cli.force_color {
|
if cli.force_color {
|
||||||
colored::control::set_override(true);
|
colored::control::set_override(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut header_description = HeaderDescription::new();
|
let mut header_description = HeaderDescription::new();
|
||||||
let use_cache = !cli.no_cache;
|
let use_cache = !cli.no_cache;
|
||||||
let mut cache = if use_cache { Cache::load() } else { None };
|
let view = cli
|
||||||
|
.show_days
|
||||||
|
.to_view()
|
||||||
|
.context("It doesn't make any sence to fetch 0 days right?")?;
|
||||||
|
let mut cache = if use_cache { Cache::load(&view) } else { None };
|
||||||
let cache_ok = cache.as_ref().map_or_else(
|
let cache_ok = cache.as_ref().map_or_else(
|
||||||
|| false,
|
|| false,
|
||||||
|c| {
|
|c| c.get_cache_line().map_or_else(|| false, |cl| cl.is_valid()),
|
||||||
c.get_cache_line(cli.resolution)
|
|
||||||
.map_or_else(|| false, |cl| cl.is_valid())
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
let mut parsed = if cache_ok {
|
let mut parsed = if cache_ok {
|
||||||
// These are safe unwraps
|
// These are safe unwraps
|
||||||
cache
|
cache
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.get_cache_line(cli.resolution)
|
.get_cache_line()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.exchange_rate_results
|
.exchange_rate_results
|
||||||
.clone()
|
.clone()
|
||||||
} else {
|
} else {
|
||||||
let parsed = match get_and_parse(cli.resolution.to_ecb_url()).await {
|
let parsed = get_and_parse(view.to_ecb_url())
|
||||||
Ok(k) => k,
|
.await
|
||||||
Err(e) => {
|
.context("Failed to get/parse data from ECB")?;
|
||||||
eprintln!("Failed to get/parse data from ECB: {}", e);
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !cache_ok {
|
if !cache_ok {
|
||||||
let not_equal_cache = cache.as_ref().map_or_else(
|
let not_equal_cache = cache.as_ref().map_or_else(
|
||||||
|| true,
|
|| true,
|
||||||
|cache_local| {
|
|cache_local| {
|
||||||
cache_local
|
cache_local
|
||||||
.get_cache_line(cli.resolution)
|
.get_cache_line()
|
||||||
.map_or_else(|| true, |cache_line| cache_line == &parsed)
|
.map_or_else(|| true, |cache_line| cache_line == &parsed)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if not_equal_cache {
|
if not_equal_cache && let Some(cache_safe) = cache.as_mut() {
|
||||||
if let Some(cache_safe) = cache.as_mut() {
|
let cache_line = CacheLine::new(parsed.clone());
|
||||||
let cache_line = CacheLine::new(parsed.clone());
|
cache_safe.set_cache_line(cache_line);
|
||||||
cache_safe.set_cache_line(cli.resolution, cache_line);
|
cache_safe.save()?;
|
||||||
if let Err(e) = cache_safe.save() {
|
|
||||||
eprintln!("Failed to save to cache with: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
parsed
|
parsed
|
||||||
};
|
};
|
||||||
|
|
||||||
cli.perspective = cli.perspective.map(|s| s.to_uppercase());
|
cli.perspective = cli.perspective.map(|s| s.to_uppercase_smolstr());
|
||||||
if let Some(currency) = cli.perspective.as_ref() {
|
if let Some(currency) = cli.perspective.as_ref() {
|
||||||
header_description.replace_eur(¤cy);
|
header_description.replace_eur(currency);
|
||||||
let error_occured = change_perspective(&mut parsed, ¤cy).is_none();
|
change_perspective(&mut parsed, currency)
|
||||||
if error_occured {
|
.context("The currency wasn't in the data from the ECB!")?;
|
||||||
eprintln!("The currency wasn't in the data from the ECB!");
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if cli.should_invert {
|
if cli.should_invert {
|
||||||
@@ -95,12 +107,24 @@ async fn main() -> ExitCode {
|
|||||||
let currencies = cli
|
let currencies = cli
|
||||||
.currencies
|
.currencies
|
||||||
.iter()
|
.iter()
|
||||||
.map(|x| x.to_uppercase())
|
.map(|x| x.to_uppercase_smolstr())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
filter_currencies(&mut parsed, ¤cies);
|
filter_currencies(&mut parsed, ¤cies);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
parsed.reverse();
|
||||||
|
let parsed = match cli.show_days.to_option() {
|
||||||
|
Some(n) => {
|
||||||
|
if parsed.len() <= n {
|
||||||
|
parsed.as_slice()
|
||||||
|
} else {
|
||||||
|
&parsed[0..n]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => parsed.as_slice(),
|
||||||
|
};
|
||||||
|
|
||||||
let output = match cli.command {
|
let output = match cli.command {
|
||||||
FormatOption::Json => {
|
FormatOption::Json => {
|
||||||
let mut json_values = parsed
|
let mut json_values = parsed
|
||||||
@@ -144,5 +168,5 @@ async fn main() -> ExitCode {
|
|||||||
};
|
};
|
||||||
|
|
||||||
println!("{}", &output);
|
println!("{}", &output);
|
||||||
ExitCode::SUCCESS
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use smol_str::SmolStr;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||||
pub struct ExchangeRateResult {
|
pub struct ExchangeRateResult {
|
||||||
pub time: String,
|
pub time: SmolStr,
|
||||||
pub rates: HashMap<String, f64>,
|
pub rates: HashMap<SmolStr, f64>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,45 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use quick_xml::events::Event;
|
|
||||||
use quick_xml::Reader;
|
use quick_xml::Reader;
|
||||||
|
use quick_xml::events::Event;
|
||||||
|
use smol_str::SmolStr;
|
||||||
|
|
||||||
use crate::models::ExchangeRateResult;
|
use crate::models::ExchangeRateResult;
|
||||||
|
|
||||||
|
fn smol_from_utf8(bytes: &[u8]) -> SmolStr {
|
||||||
|
str::from_utf8(bytes)
|
||||||
|
.map(SmolStr::new)
|
||||||
|
.unwrap_or_else(|_| SmolStr::new(String::from_utf8_lossy(bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn parse(xml: &str) -> anyhow::Result<Vec<ExchangeRateResult>> {
|
pub fn parse(xml: &str) -> anyhow::Result<Vec<ExchangeRateResult>> {
|
||||||
let mut reader = Reader::from_str(xml);
|
let mut reader = Reader::from_str(xml);
|
||||||
reader.config_mut().trim_text(true);
|
reader.config_mut().trim_text(true);
|
||||||
|
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
let mut current_time: Option<String> = None;
|
let mut current_time: Option<SmolStr> = None;
|
||||||
let mut inside_cube_time = false;
|
let mut inside_cube_time = false;
|
||||||
let mut current_rates = HashMap::new();
|
let mut current_rates = HashMap::new();
|
||||||
|
|
||||||
fn handle_cube_element(
|
fn handle_cube_element(
|
||||||
e: &quick_xml::events::BytesStart,
|
e: &quick_xml::events::BytesStart,
|
||||||
current_time: &mut Option<String>,
|
current_time: &mut Option<SmolStr>,
|
||||||
inside_cube_time: &mut bool,
|
inside_cube_time: &mut bool,
|
||||||
current_rates: &mut HashMap<String, f64>,
|
current_rates: &mut HashMap<SmolStr, f64>,
|
||||||
results: &mut Vec<ExchangeRateResult>,
|
results: &mut Vec<ExchangeRateResult>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
if e.name().local_name().as_ref() != b"Cube" {
|
if e.name().local_name().as_ref() != b"Cube" {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut time_attr: Option<String> = None;
|
let mut time_attr: Option<SmolStr> = None;
|
||||||
let mut currency_attr: Option<String> = None;
|
let mut currency_attr: Option<SmolStr> = None;
|
||||||
let mut rate_attr: Option<String> = None;
|
let mut rate_attr: Option<SmolStr> = None;
|
||||||
|
|
||||||
for attr_result in e.attributes() {
|
for attr_result in e.attributes() {
|
||||||
let attr = attr_result?;
|
let attr = attr_result?;
|
||||||
let key = attr.key.as_ref();
|
let key = attr.key.as_ref();
|
||||||
let val = String::from_utf8_lossy(attr.value.as_ref()).to_string();
|
let val = smol_from_utf8(attr.value.as_ref());
|
||||||
|
|
||||||
match key {
|
match key {
|
||||||
b"time" => {
|
b"time" => {
|
||||||
@@ -61,11 +68,9 @@ pub fn parse(xml: &str) -> anyhow::Result<Vec<ExchangeRateResult>> {
|
|||||||
*inside_cube_time = true;
|
*inside_cube_time = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if *inside_cube_time {
|
if *inside_cube_time && let (Some(c), Some(r_str)) = (currency_attr, rate_attr) {
|
||||||
if let (Some(c), Some(r_str)) = (currency_attr, rate_attr) {
|
let r = r_str.parse::<f64>()?;
|
||||||
let r = r_str.parse::<f64>()?;
|
current_rates.insert(c, r);
|
||||||
current_rates.insert(c, r);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
|
mod table_display;
|
||||||
mod table_getter;
|
mod table_getter;
|
||||||
mod table_owned;
|
mod table_owned;
|
||||||
mod table_ref;
|
mod table_ref;
|
||||||
mod table_trait;
|
mod table_trait;
|
||||||
mod table_display;
|
|
||||||
|
|
||||||
pub use table_getter::TableGet;
|
pub use table_getter::TableGet;
|
||||||
pub use table_owned::Table;
|
pub use table_owned::Table;
|
||||||
pub use table_ref::TableRef;
|
pub use table_ref::TableRef;
|
||||||
pub use table_trait::TableTrait;
|
pub use table_trait::TableTrait;
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
|
|
||||||
|
use smol_str::SmolStr;
|
||||||
|
|
||||||
|
use crate::DEFAULT_WIDTH;
|
||||||
use crate::cli::SortBy;
|
use crate::cli::SortBy;
|
||||||
use crate::models::ExchangeRateResult;
|
use crate::models::ExchangeRateResult;
|
||||||
use crate::DEFAULT_WIDTH;
|
|
||||||
|
|
||||||
use super::table_display::helper_table_print;
|
use super::table_display::helper_table_print;
|
||||||
use super::{TableGet, TableTrait};
|
use super::{TableGet, TableTrait};
|
||||||
|
|
||||||
pub struct Table {
|
pub struct Table {
|
||||||
pub(super) header: Option<String>,
|
pub(super) header: Option<SmolStr>,
|
||||||
pub(super) column_left: String,
|
pub(super) column_left: SmolStr,
|
||||||
pub(super) column_right: String,
|
pub(super) column_right: SmolStr,
|
||||||
pub(super) rows: Vec<(String, f64)>,
|
pub(super) rows: Vec<(SmolStr, f64)>,
|
||||||
pub color: bool,
|
pub color: bool,
|
||||||
pub width: usize,
|
pub width: usize,
|
||||||
pub left_offset: usize,
|
pub left_offset: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> TableTrait<'a> for Table {
|
impl<'a> TableTrait<'a> for Table {
|
||||||
type Header = String;
|
type Header = SmolStr;
|
||||||
type ColumnLeft = String;
|
type ColumnLeft = SmolStr;
|
||||||
type ColumnRight = String;
|
type ColumnRight = SmolStr;
|
||||||
type RowLeft = String;
|
type RowLeft = SmolStr;
|
||||||
|
|
||||||
fn new(
|
fn new(
|
||||||
header: Option<Self::Header>,
|
header: Option<Self::Header>,
|
||||||
@@ -59,8 +61,8 @@ impl<'a> TableTrait<'a> for Table {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TableGet for Table {
|
impl TableGet for Table {
|
||||||
type RowLeftRef = String;
|
type RowLeftRef = SmolStr;
|
||||||
type RowRightRef = String;
|
type RowRightRef = SmolStr;
|
||||||
|
|
||||||
fn get_header(&self) -> Option<&str> {
|
fn get_header(&self) -> Option<&str> {
|
||||||
self.header.as_deref()
|
self.header.as_deref()
|
||||||
@@ -85,7 +87,7 @@ impl TableGet for Table {
|
|||||||
|
|
||||||
impl From<ExchangeRateResult> for Table {
|
impl From<ExchangeRateResult> for Table {
|
||||||
fn from(value: ExchangeRateResult) -> Self {
|
fn from(value: ExchangeRateResult) -> Self {
|
||||||
let mut table = Table::new(Some(value.time), "Currency".to_string(), "Rate".to_string());
|
let mut table = Table::new(Some(value.time), "Currency".into(), "Rate".into());
|
||||||
for (key, val) in value.rates.into_iter() {
|
for (key, val) in value.rates.into_iter() {
|
||||||
table.add_row(key, val);
|
table.add_row(key, val);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
|
|
||||||
|
use crate::DEFAULT_WIDTH;
|
||||||
use crate::cli::SortBy;
|
use crate::cli::SortBy;
|
||||||
use crate::models::ExchangeRateResult;
|
use crate::models::ExchangeRateResult;
|
||||||
use crate::DEFAULT_WIDTH;
|
|
||||||
|
|
||||||
|
use super::Table;
|
||||||
use super::table_display::helper_table_print;
|
use super::table_display::helper_table_print;
|
||||||
use super::table_getter::TableGet;
|
use super::table_getter::TableGet;
|
||||||
use super::table_trait::TableTrait;
|
use super::table_trait::TableTrait;
|
||||||
use super::Table;
|
|
||||||
|
|
||||||
pub struct TableRef<'a> {
|
pub struct TableRef<'a> {
|
||||||
header: Option<&'a str>,
|
header: Option<&'a str>,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
use std::{borrow::BorrowMut, collections::HashMap, ops::Deref};
|
use std::{borrow::BorrowMut, collections::HashMap, ops::Deref};
|
||||||
|
|
||||||
|
use smol_str::SmolStr;
|
||||||
|
|
||||||
use crate::models::ExchangeRateResult;
|
use crate::models::ExchangeRateResult;
|
||||||
|
|
||||||
pub fn filter_currencies(exchange_rate_results: &mut [ExchangeRateResult], currencies: &[String]) {
|
pub fn filter_currencies(exchange_rate_results: &mut [ExchangeRateResult], currencies: &[SmolStr]) {
|
||||||
for exchange_rate in exchange_rate_results {
|
for exchange_rate in exchange_rate_results {
|
||||||
let rates_ptr: *mut HashMap<String, f64> = &mut exchange_rate.rates;
|
let rates_ptr: *mut HashMap<_, _> = &mut exchange_rate.rates;
|
||||||
exchange_rate
|
exchange_rate
|
||||||
.rates
|
.rates
|
||||||
.keys()
|
.keys()
|
||||||
@@ -32,7 +34,7 @@ pub fn change_perspective(
|
|||||||
*iter_rate = eur_rate * iter_rate.deref();
|
*iter_rate = eur_rate * iter_rate.deref();
|
||||||
}
|
}
|
||||||
|
|
||||||
rate_res.rates.insert("EUR".to_string(), eur_rate);
|
rate_res.rates.insert("EUR".into(), eur_rate);
|
||||||
}
|
}
|
||||||
Some(())
|
Some(())
|
||||||
}
|
}
|
||||||
|
|||||||
25
src/view.rs
Normal file
25
src/view.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use crate::ecb_url;
|
||||||
|
|
||||||
|
pub enum View {
|
||||||
|
TODAY,
|
||||||
|
HistDays90,
|
||||||
|
HistDaysAll,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl View {
|
||||||
|
pub fn to_ecb_url(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::TODAY => ecb_url::TODAY,
|
||||||
|
Self::HistDays90 => ecb_url::hist::DAYS_90,
|
||||||
|
Self::HistDaysAll => ecb_url::hist::DAYS_ALL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::TODAY => "today",
|
||||||
|
Self::HistDays90 => "last-90-days",
|
||||||
|
Self::HistDaysAll => "all-days",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user