34 Commits

Author SHA1 Message Date
75f73e5d5e bump to version 1.1.0 2025-12-17 22:35:09 +01:00
980ae6f8cb breakout async_main 2025-12-17 22:33:31 +01:00
364875a84e Bump MSRV and cleanup with clippy 2025-12-17 22:20:31 +01:00
f388c31594 Don't specify the license twice 2025-12-17 22:14:20 +01:00
Love
837a8e9851 Merge pull request #3 from lov3b/feature/smol-str
Use smolstr since we only expect small strings
2025-12-17 22:12:35 +01:00
0f930271c7 Use smolstr since we only expect small strings 2025-12-17 22:10:46 +01:00
61deca69c6 Update dependencies, fix a spelling mistake, set license field and bump package-version 2025-12-17 11:46:17 +01:00
Love
292c9d4f34 Merge pull request #2 from lov3b/feature/lto
enable LTO for release-builds
2025-12-17 11:32:04 +01:00
0f921e978e enable LTO 2025-12-17 11:29:55 +01:00
3fce153c1c update for crates.io 2025-01-09 17:29:35 +01:00
416233afde For crates.io 2025-01-09 17:27:48 +01:00
f2f27a25b6 Instructions for dpkg 2025-01-09 17:16:12 +01:00
f971f3ba81 Add package yaml 2025-01-09 17:10:51 +01:00
0919a189cf Table for github compatability 2025-01-09 17:10:20 +01:00
f4c1b04edb Include ECB logo 2025-01-09 17:10:20 +01:00
68f63e9cea Update the install instructions 2025-01-09 17:10:20 +01:00
ec8aa710e0 More readme 2025-01-09 16:00:11 +01:00
52377e9988 update to - 2025-01-09 15:45:55 +01:00
c381ed2a79 Simplify down to show-days 2025-01-09 15:44:09 +01:00
f501569040 Readme with images 2025-01-09 15:06:00 +01:00
a39d0331b3 Merge branch 'faster-cache' 2025-01-09 14:44:58 +01:00
1431e2b14f Keep caches in different files 2025-01-09 14:43:46 +01:00
65ad88acae rename view 2025-01-09 14:11:46 +01:00
5f3a075580 Merge branch 'perspective' 2025-01-09 13:57:45 +01:00
af6f1682c5 Header description 2025-01-09 13:55:28 +01:00
4b36904f3a Rounding 2025-01-09 12:33:00 +01:00
d842fb786c invert option 2025-01-09 12:21:10 +01:00
4445685444 perspective to upper 2025-01-09 12:13:06 +01:00
19646e0001 Perspective 2025-01-08 21:03:15 +01:00
e20b7d22a5 perspective option 2025-01-08 19:42:15 +01:00
0624499fcf Break out utils 2025-01-08 19:42:06 +01:00
a8dcc79111 Drop Rc feature from serde 2025-01-08 19:32:06 +01:00
b0df48293c Package metadata 2025-01-08 19:30:23 +01:00
1eb7716956 Merge branch 'better-cache' 2025-01-08 19:20:52 +01:00
32 changed files with 1340 additions and 727 deletions

51
.github/workflows/package.yml vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,37 @@
[package] [package]
name = "ecb-rates" name = "ecb-rates"
version = "0.1.0" description = "Query exchange rates from the European Central Bank (ECB)"
edition = "2021" version = "1.1.0"
edition = "2024"
authors = ["Love Billenius <lovebillenius@disroot.org>"]
license = "Zlib"
keywords = [
"ECB",
"Bank",
"Central",
"exchange",
"rates",
]
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", "rc"] } 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"

View File

@@ -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
It will fetch any of the following api nodes, and reduce them for you.
- Last available day.
- Last 90 days
- Since the dawn of the _EUR_
#### Display select currencies
- as an ASCII table - as an ASCII table
- in JSON prettified - in JSON prettified
- in JSON minified - in JSON minified
- Fetch in different "resolutions":
- Last available day.
- Since the dawn of the *EUR*
- in day resolution
- in 90 day resolution
### Example #### Cache
```sh It features an extensive cache, which will [calculate hollidays](src/holiday.rs) in order to know whether to invalidate it or not.
ecb-rates -c sek -c nok -c usd
```
```plain #### Show the rates in your way
2025-01-07
Currency Rate Change the rates for the perspective of any currency with the `--perspective` or `-p` flag.
---------------------
USD 1.0393 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.
SEK 11.475
NOK 11.7385 #### 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
![eur-to-all](images/eur-to-all.png)
#### ...with only select currencies
![eur-to-all](images/eur-to-all-select.png)
#### Put the exchange rate in the perspective of any currency
![usd-to-all](images/usd-to-all.png)
#### Flip it
![all-to-usd](images/all-to-usd.png)
#### Show multiple days
![eur-to-all-multiple-days](images/eur-to-all-multiple-days.png)
## Acknowledgment ## Acknowledgment

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

96
src/cache/cache.rs vendored
View File

@@ -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
View 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)?)
}
}

View File

@@ -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() };

View File

@@ -1,81 +0,0 @@
use clap::{arg, Parser, ValueEnum};
use crate::ecb_url;
#[derive(Debug, Parser)]
#[command(author, version, about)]
pub struct Cli {
/// Which currencies do you want to fetch rates for?
#[arg(long = "currencies", short = 'c')]
pub currencies: Vec<String>,
#[arg(value_enum, default_value_t = FormatOption::Plain)]
pub command: FormatOption,
/// Don't show time in output
#[arg(long = "no-time")]
pub no_time: bool,
/// Print currencies in a compact single line
#[arg(long = "compact")]
pub compact: bool,
/// Override the cache
#[arg(long = "no-cache")]
pub no_cache: bool,
/// Force color in output. Normally it will disable color in pipes
#[arg(long = "force-color")]
pub force_color: bool,
/// 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)]
pub sort_by: SortBy,
/// Amount of data
#[arg(value_enum, default_value_t = Resolution::TODAY, long="resolution", short='r')]
pub resolution: Resolution,
}
#[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)]
pub enum FormatOption {
/// JSON output
Json,
/// Plain line-by-line output (with extra flags)
Plain,
}

59
src/cli/cli_t.rs Normal file
View File

@@ -0,0 +1,59 @@
use clap::{Parser, ValueEnum};
use smol_str::SmolStr;
use super::{ShowDays, SortBy};
#[derive(Debug, Parser)]
#[command(author, version, about)]
pub struct Cli {
/// Which currencies do you want to fetch rates for?
#[arg(long = "currencies", short = 'c')]
pub currencies: Vec<SmolStr>,
#[arg(value_enum, default_value_t = FormatOption::Plain)]
pub command: FormatOption,
/// Don't show time in output
#[arg(long = "no-time")]
pub no_time: bool,
/// Print currencies in a compact single line
#[arg(long = "compact")]
pub compact: bool,
/// Override the cache
#[arg(long = "no-cache")]
pub no_cache: bool,
/// Force color in output. Normally it will disable color in pipes
#[arg(long = "force-color")]
pub force_color: bool,
/// Sort by the currency name (in alphabetical order), or by the rate value (low -> high)
#[arg(value_enum, long = "sort-by", default_value_t = SortBy::Currency)]
pub sort_by: SortBy,
/// Recalculate to the perspective from an included currency
#[arg(long = "perspective", short = 'p')]
pub perspective: Option<SmolStr>,
/// Invert the rate
#[arg(long = "invert", short = 'i')]
pub should_invert: bool,
/// Max decimals to keep in price.
#[arg(long = "max-decimals", short = 'd', default_value_t = 5)]
pub max_decimals: u8,
/// Amount of data
#[arg(default_value_t = ShowDays::Days(1), long="show-days", short='s')]
pub show_days: ShowDays,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum FormatOption {
/// JSON output
Json,
/// Plain line-by-line output (with extra flags)
Plain,
}

7
src/cli/mod.rs Normal file
View 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
View 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
View 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),
}
}
}

57
src/header_description.rs Normal file
View File

@@ -0,0 +1,57 @@
use colored::Colorize;
use std::fmt::Display;
use crate::DEFAULT_WIDTH;
pub struct HeaderDescription<'a> {
header_description: [&'a str; 2],
}
impl<'a> Default for HeaderDescription<'a> {
fn default() -> Self {
Self::new()
}
}
impl<'a> HeaderDescription<'a> {
pub fn new() -> Self {
Self {
header_description: ["EUR", /*"\u{2217}"*/ "ALL"], // Unicode is
}
}
pub fn invert(&mut self) {
self.header_description.swap(0, 1);
}
pub fn replace_eur(&mut self, currency: &'a str) {
self.header_description[0] = currency;
}
}
impl<'a> Display for HeaderDescription<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let width = DEFAULT_WIDTH - 2;
let formatted = format!(
"{} {} {}",
self.header_description[0].purple().bold(),
"to".italic(),
self.header_description[1].purple().bold()
);
let unformatted_len =
self.header_description[0].len() + self.header_description[1].len() + 4;
let left_padding = " ".repeat((width - unformatted_len) / 2);
let vertical = "".repeat(width);
writeln!(f, " ╔{}╗", &vertical)?;
writeln!(
f,
" {}{}{} ",
&left_padding,
formatted,
" ".repeat(width - left_padding.len() - unformatted_len)
)?;
writeln!(f, " ╚{}╝\n", &vertical)?;
Ok(())
}
}

View File

@@ -1,22 +1,27 @@
pub mod cache; pub mod caching;
pub mod cli; pub mod cli;
mod header_description;
mod holiday; mod holiday;
pub mod models; pub mod models;
pub mod os; pub mod os;
pub mod parsing; pub mod parsing;
pub mod table; pub mod table;
pub mod utils_calc;
mod view;
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;
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";
} }
} }

View File

@@ -1,12 +1,16 @@
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 reqwest::{Client, IntoUrl}; use reqwest::{Client, IntoUrl};
use std::{borrow::BorrowMut, collections::HashMap, process::ExitCode}; use smol_str::StrExt;
use std::process::ExitCode;
use ecb_rates::cli::{Cli, FormatOption}; use ecb_rates::cli::{Cli, FormatOption};
use ecb_rates::models::ExchangeRateResult; use ecb_rates::models::ExchangeRateResult;
use ecb_rates::parsing::parse; use ecb_rates::parsing::parse;
use ecb_rates::table::{TableRef, TableTrait as _}; use ecb_rates::table::{TableRef, TableTrait as _};
use ecb_rates::utils_calc::{change_perspective, filter_currencies, invert_rates, round};
async fn get_and_parse(url: impl IntoUrl) -> anyhow::Result<Vec<ExchangeRateResult>> { async fn get_and_parse(url: impl IntoUrl) -> anyhow::Result<Vec<ExchangeRateResult>> {
let client = Client::new(); let client = Client::new();
@@ -14,91 +18,113 @@ async fn get_and_parse(url: impl IntoUrl) -> anyhow::Result<Vec<ExchangeRateResu
parse(&xml_content) parse(&xml_content)
} }
fn filter_currencies(exchange_rate_results: &mut [ExchangeRateResult], currencies: &[String]) { fn main() -> ExitCode {
for exchange_rate in exchange_rate_results { let cli = Cli::parse();
let rates_ptr: *mut HashMap<String, f64> = &mut exchange_rate.rates;
exchange_rate let runtime = match tokio::runtime::Builder::new_current_thread()
.rates .enable_all()
.keys() .build()
.filter(|x| !currencies.contains(x)) {
.for_each(|key_to_remove| { Ok(runtime) => runtime,
/* This is safe, since we: Err(e) => {
* 1. Already have a mutable reference. eprintln!("Failed to initialize asynchronous runtime: {:?}", e);
* 2. Don't run the code in paralell return ExitCode::FAILURE;
*/ }
let rates = unsafe { (*rates_ptr).borrow_mut() }; };
rates.remove_entry(key_to_remove);
}); match runtime.block_on(async_main(cli)) {
Ok(_) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("Fatal: {:?}", e);
ExitCode::FAILURE
}
} }
} }
#[tokio::main(flavor = "current_thread")] async fn async_main(mut cli: Cli) -> anyhow::Result<()> {
async fn main() -> ExitCode {
let cli = Cli::parse();
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 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(cli.resolution, cache_line); cache_safe.set_cache_line(cache_line);
if let Err(e) = cache_safe.save() { cache_safe.save()?;
eprintln!("Failed to save to cache with: {:?}", e);
}
}
} }
} }
parsed parsed
}; };
cli.perspective = cli.perspective.map(|s| s.to_uppercase_smolstr());
if let Some(currency) = cli.perspective.as_ref() {
header_description.replace_eur(currency);
change_perspective(&mut parsed, currency)
.context("The currency wasn't in the data from the ECB!")?;
}
if cli.should_invert {
invert_rates(&mut parsed);
header_description.invert();
}
round(&mut parsed, cli.max_decimals);
if !cli.currencies.is_empty() { if !cli.currencies.is_empty() {
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, &currencies); filter_currencies(&mut parsed, &currencies);
} }
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
@@ -122,7 +148,8 @@ async fn main() -> ExitCode {
}; };
to_string_json(&json_values).expect("Failed to parse content as JSON") to_string_json(&json_values).expect("Failed to parse content as JSON")
} }
FormatOption::Plain => parsed FormatOption::Plain => {
let rates = parsed
.iter() .iter()
.map(|x| { .map(|x| {
let mut t: TableRef = x.into(); let mut t: TableRef = x.into();
@@ -133,9 +160,13 @@ async fn main() -> ExitCode {
t.to_string() t.to_string()
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"), .join("\n");
let mut s = header_description.to_string();
s.push_str(&rates);
s
}
}; };
println!("{}", &output); println!("{}", &output);
ExitCode::SUCCESS Ok(())
} }

View File

@@ -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>,
} }

View File

@@ -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,12 +68,10 @@ 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(())
} }

View File

@@ -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;

View File

@@ -7,6 +7,7 @@ pub fn helper_table_print<T: TableGet>(
table: &T, table: &T,
) -> std::fmt::Result { ) -> std::fmt::Result {
let width = table.get_width(); let width = table.get_width();
let left_offset = " ".repeat(table.get_left_offset());
if let Some(header) = table.get_header() { if let Some(header) = table.get_header() {
let middle_padding_amount = (width - header.len()) / 2; let middle_padding_amount = (width - header.len()) / 2;
@@ -14,7 +15,8 @@ pub fn helper_table_print<T: TableGet>(
let middle_padding = " ".repeat(middle_padding_amount); let middle_padding = " ".repeat(middle_padding_amount);
writeln!( writeln!(
f, f,
"{}{}{}", "{}{}{}{}",
&left_offset,
middle_padding, middle_padding,
header.bold().cyan(), header.bold().cyan(),
middle_padding middle_padding
@@ -27,19 +29,27 @@ pub fn helper_table_print<T: TableGet>(
let right_padding = " ".repeat(right_padding_amount); let right_padding = " ".repeat(right_padding_amount);
writeln!( writeln!(
f, f,
"{}{}{}", "{}{}{}{}",
&left_offset,
column_left.bold().yellow(), column_left.bold().yellow(),
right_padding, right_padding,
column_right.bold().yellow() column_right.bold().yellow()
)?; )?;
writeln!(f, "{}", "-".repeat(width))?; writeln!(f, "{}{}", &left_offset, "-".repeat(width))?;
for (left, right) in table.get_rows().iter() { for (left, right) in table.get_rows().iter() {
let left_str = left.as_ref(); let left_str = left.as_ref();
let right_str = right.to_string(); let right_str = right.to_string();
let padding_amount = width.saturating_sub(left_str.len() + right_str.len()); let padding_amount = width.saturating_sub(left_str.len() + right_str.len());
let padding = " ".repeat(padding_amount); let padding = " ".repeat(padding_amount);
writeln!(f, "{}{}{}", left_str.bold().green(), padding, right_str)?; writeln!(
f,
"{}{}{}{}",
&left_offset,
left_str.bold().green(),
padding,
right_str
)?;
} }
Ok(()) Ok(())

View File

@@ -7,4 +7,5 @@ pub trait TableGet {
fn get_column_right(&self) -> &str; fn get_column_right(&self) -> &str;
fn get_rows(&self) -> &Vec<(Self::RowLeftRef, f64)>; fn get_rows(&self) -> &Vec<(Self::RowLeftRef, f64)>;
fn get_width(&self) -> usize; fn get_width(&self) -> usize;
fn get_left_offset(&self) -> usize;
} }

View File

@@ -1,5 +1,8 @@
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;
@@ -7,19 +10,20 @@ 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,
} }
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>,
@@ -32,7 +36,8 @@ impl<'a> TableTrait<'a> for Table {
column_right, column_right,
rows: Vec::new(), rows: Vec::new(),
color: false, color: false,
width: 21, width: DEFAULT_WIDTH,
left_offset: 1,
} }
} }
@@ -56,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()
@@ -74,11 +79,15 @@ impl TableGet for Table {
fn get_width(&self) -> usize { fn get_width(&self) -> usize {
self.width self.width
} }
fn get_left_offset(&self) -> usize {
self.left_offset
}
} }
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);
} }

View File

@@ -1,12 +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 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>,
@@ -15,6 +16,7 @@ pub struct TableRef<'a> {
rows: Vec<(&'a str, f64)>, rows: Vec<(&'a str, f64)>,
pub color: bool, pub color: bool,
pub width: usize, pub width: usize,
pub left_offset: usize,
} }
impl<'a> TableTrait<'a> for TableRef<'a> { impl<'a> TableTrait<'a> for TableRef<'a> {
@@ -34,7 +36,8 @@ impl<'a> TableTrait<'a> for TableRef<'a> {
column_right, column_right,
rows: Vec::new(), rows: Vec::new(),
color: false, color: false,
width: 21, width: DEFAULT_WIDTH,
left_offset: 1,
} }
} }
@@ -75,6 +78,10 @@ impl<'a> TableGet for TableRef<'a> {
fn get_width(&self) -> usize { fn get_width(&self) -> usize {
self.width self.width
} }
fn get_left_offset(&self) -> usize {
self.left_offset
}
} }
impl<'a> From<&'a ExchangeRateResult> for TableRef<'a> { impl<'a> From<&'a ExchangeRateResult> for TableRef<'a> {
@@ -109,6 +116,7 @@ impl<'a> From<&'a Table> for TableRef<'a> {
rows, rows,
color: table.color, color: table.color,
width: table.width, width: table.width,
left_offset: table.left_offset,
} }
} }
} }

58
src/utils_calc.rs Normal file
View File

@@ -0,0 +1,58 @@
use std::{borrow::BorrowMut, collections::HashMap, ops::Deref};
use smol_str::SmolStr;
use crate::models::ExchangeRateResult;
pub fn filter_currencies(exchange_rate_results: &mut [ExchangeRateResult], currencies: &[SmolStr]) {
for exchange_rate in exchange_rate_results {
let rates_ptr: *mut HashMap<_, _> = &mut exchange_rate.rates;
exchange_rate
.rates
.keys()
.filter(|x| !currencies.contains(x))
.for_each(|key_to_remove| {
/* This is safe, since we:
* 1. Already have a mutable reference.
* 2. Don't run the code in paralell
*/
let rates = unsafe { (*rates_ptr).borrow_mut() };
rates.remove_entry(key_to_remove);
});
}
}
pub fn change_perspective(
exchange_rate_results: &mut [ExchangeRateResult],
currency: &str,
) -> Option<()> {
for rate_res in exchange_rate_results {
let currency_rate = rate_res.rates.remove(currency)?;
let eur_rate = 1.0 / currency_rate;
for (_, iter_rate) in rate_res.rates.iter_mut() {
*iter_rate = eur_rate * iter_rate.deref();
}
rate_res.rates.insert("EUR".into(), eur_rate);
}
Some(())
}
pub fn invert_rates(exchange_rate_results: &mut [ExchangeRateResult]) {
for rate_res in exchange_rate_results {
for (_, iter_rate) in rate_res.rates.iter_mut() {
*iter_rate = 1.0 / *iter_rate;
}
}
}
pub fn round(exchange_rate_results: &mut [ExchangeRateResult], max_decimals: u8) {
let power = 10.0_f64.powf(max_decimals as f64);
for rate_res in exchange_rate_results {
for (_, iter_rate) in rate_res.rates.iter_mut() {
let more = iter_rate.deref() * power;
*iter_rate = more.round() / power;
}
}
}

25
src/view.rs Normal file
View 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",
}
}
}