add currency

This commit is contained in:
Zhang, Tianrong 2025-12-11 20:11:53 -05:00
parent 49b05d0876
commit 7e76fc4008
8 changed files with 1033 additions and 6 deletions

604
Cargo.lock generated
View File

@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
version = "1.1.3"
@ -17,6 +23,12 @@ version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bnum"
version = "0.12.1"
@ -29,6 +41,16 @@ version = "3.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
[[package]]
name = "cc"
version = "1.2.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.0"
@ -52,12 +74,36 @@ dependencies = [
"console_error_panic_hook",
"fastnum",
"js-sys",
"once_cell",
"regex",
"serde",
"serde_json",
"unicode-segmentation",
"ureq",
"wasm-bindgen",
"web-time",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "fastnum"
version = "0.2.9"
@ -68,6 +114,150 @@ dependencies = [
"bnum",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
[[package]]
name = "flate2"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "getrandom"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "icu_collections"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
dependencies = [
"displaydoc",
"potential_utf",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
[[package]]
name = "icu_properties"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
dependencies = [
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
[[package]]
name = "icu_provider"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "js-sys"
version = "0.3.77"
@ -78,6 +268,18 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.178"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
[[package]]
name = "litemap"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
[[package]]
name = "log"
version = "0.4.27"
@ -90,12 +292,37 @@ version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "potential_utf"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
dependencies = [
"zerovec",
]
[[package]]
name = "proc-macro2"
version = "1.0.95"
@ -143,12 +370,140 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"libc",
"untrusted",
"windows-sys",
]
[[package]]
name = "rustls"
version = "0.23.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d"
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "simd-adler32"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.101"
@ -160,6 +515,27 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tinystr"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]]
name = "unicode-ident"
version = "1.0.18"
@ -172,6 +548,54 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "url"
version = "2.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-bindgen"
version = "0.2.100"
@ -239,3 +663,183 @@ dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.4",
]
[[package]]
name = "webpki-roots"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "writeable"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
name = "yoke"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "zerofrom"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]

View File

@ -10,6 +10,7 @@ homepage = "https://github.com/probablykasper/cpc#readme"
repository = "https://github.com/probablykasper/cpc"
documentation = "https://docs.rs/cpc"
keywords = ["math", "expression", "evaluate", "units", "convert"]
autobins = false
categories = [
"mathematics",
"science",
@ -18,6 +19,10 @@ categories = [
"value-formatting",
]
[[bin]]
name = "cpc_cli"
path = "src/main.rs"
[lib]
crate-type = ["cdylib", "rlib"]
@ -25,6 +30,12 @@ crate-type = ["cdylib", "rlib"]
fastnum = "0.2"
unicode-segmentation = "1.12"
web-time = "1.1.0"
once_cell = "1.20"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
ureq = { version = "2.12", features = ["json"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
console_error_panic_hook = "0.1.7"

145
src/currency.rs Normal file
View File

@ -0,0 +1,145 @@
use crate::extra_units;
use crate::units::UnitType;
use fastnum::decimal::Context;
use fastnum::D128;
use serde_json::Value;
const EXCHANGE_RATE_API_USD_LATEST: &str = "https://api.exchangerate-api.com/v4/latest/USD";
fn parse_d128_from_json_number(v: &Value) -> Result<D128, String> {
let s = match v {
Value::Number(n) => n.to_string(),
_ => return Err("Expected JSON number".to_string()),
};
D128::from_str(&s, Context::default())
.map_err(|_| format!("Invalid decimal number: {s}"))
}
/// Registers/updates currencies from the ExchangeRate API JSON response (base USD).
///
/// The JSON is expected to look like `{"base":"USD","rates":{"EUR":0.852,...}}`.
///
/// Internally we store a "weight" as USD-per-unit, so conversions use the existing
/// `from.weight()/to.weight()` logic.
pub fn register_currencies_from_exchange_rate_api_json(json: &str) -> Result<usize, String> {
let v: Value = serde_json::from_str(json).map_err(|e| format!("Invalid JSON: {e}"))?;
let base = v
.get("base")
.and_then(|b| b.as_str())
.ok_or("Missing 'base'")?;
if base != "USD" {
return Err(format!("Expected base USD but got {base}"));
}
let rates = v
.get("rates")
.and_then(|r| r.as_object())
.ok_or("Missing 'rates' object")?;
let mut count = 0usize;
for (code, rate_value) in rates {
// Skip weird/empty keys defensively
if code.len() != 3 {
continue;
}
let code_upper = code.to_ascii_uppercase();
let code_lower = code.to_ascii_lowercase();
let rate = parse_d128_from_json_number(rate_value)?;
if rate.is_zero() {
continue;
}
// weight = USD per 1 {CODE} = 1 / (CODE per 1 USD)
let weight = D128::ONE / rate;
extra_units::upsert_unit(
&code_lower,
&code_upper,
&code_upper,
UnitType::Currency,
weight,
);
count += 1;
}
Ok(count)
}
#[cfg(not(target_arch = "wasm32"))]
fn cache_path() -> std::path::PathBuf {
let mut p = std::env::temp_dir();
p.push("cpc_currency_rates_usd.json");
p
}
#[cfg(not(target_arch = "wasm32"))]
pub fn init_currencies_blocking() -> Result<usize, String> {
// 1) Load cached (if any) so currencies work offline.
let mut cached_count = 0usize;
let cache_path = cache_path();
if let Ok(s) = std::fs::read_to_string(&cache_path) {
if let Ok(n) = register_currencies_from_exchange_rate_api_json(&s) {
cached_count = n;
}
}
// 2) Try refresh from network.
let resp = ureq::get(EXCHANGE_RATE_API_USD_LATEST)
.call()
.map_err(|e| format!("Currency fetch failed: {e}"))?;
let body = resp
.into_string()
.map_err(|e| format!("Currency fetch read failed: {e}"))?;
let n = register_currencies_from_exchange_rate_api_json(&body)?;
// Best-effort cache write
let _ = std::fs::write(&cache_path, body);
Ok(std::cmp::max(cached_count, n))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extra_units;
use crate::units::{convert, Unit};
use crate::Number;
use fastnum::dec128 as d;
#[test]
fn register_and_convert_currency() {
extra_units::clear_for_tests();
let json = r#"{
"base": "USD",
"rates": {
"USD": 1,
"EUR": 0.5
}
}"#;
let n = register_currencies_from_exchange_rate_api_json(json).unwrap();
assert_eq!(n, 2);
let usd = extra_units::lookup_unit("usd").unwrap();
let eur = extra_units::lookup_unit("eur").unwrap();
let one_usd = Number::new(d!(1), usd);
let eur_amount = convert(one_usd, eur).unwrap();
assert_eq!(eur_amount.value, d!(0.5));
let one_eur = Number::new(d!(1), eur);
let usd_amount = convert(one_eur, usd).unwrap();
assert_eq!(usd_amount.value, d!(2));
// Display name should be currency code
assert_eq!(eur_amount.unit.singular(), "EUR");
assert_eq!(eur_amount.unit.plural(), "EUR");
// Sanity: it's still a Unit
let _ = Unit::NoUnit;
}
}

125
src/extra_units.rs Normal file
View File

@ -0,0 +1,125 @@
use crate::units::{Unit, UnitType};
use fastnum::D128;
use once_cell::sync::Lazy;
use std::collections::HashMap;
use std::sync::RwLock;
#[derive(Clone, Copy)]
pub(crate) struct ExtraUnitDef {
pub(crate) category: UnitType,
pub(crate) weight: D128,
pub(crate) singular: &'static str,
pub(crate) plural: &'static str,
}
#[derive(Default)]
struct Registry {
by_name: HashMap<&'static str, u16>,
units: Vec<ExtraUnitDef>,
}
static REGISTRY: Lazy<RwLock<Registry>> = Lazy::new(|| RwLock::new(Registry::default()));
fn leak_str(s: String) -> &'static str {
Box::leak(s.into_boxed_str())
}
pub(crate) fn lookup_unit(word: &str) -> Option<Unit> {
let reg = REGISTRY.read().ok()?;
let id = *reg.by_name.get(word)?;
Some(Unit::Extra(id))
}
pub(crate) fn upsert_unit(
name_lowercase: &str,
singular: &str,
plural: &str,
category: UnitType,
weight: D128,
) -> Unit {
let mut reg = REGISTRY
.write()
.expect("extra unit registry lock poisoned");
if let Some(existing_id) = reg.by_name.get(name_lowercase).copied() {
let idx = existing_id as usize;
reg.units[idx] = ExtraUnitDef {
category,
weight,
singular: reg.units[idx].singular,
plural: reg.units[idx].plural,
};
return Unit::Extra(existing_id);
}
let id: u16 = reg
.units
.len()
.try_into()
.expect("too many extra units registered");
let key = leak_str(name_lowercase.to_string());
let singular = leak_str(singular.to_string());
let plural = leak_str(plural.to_string());
reg.by_name.insert(key, id);
reg.units.push(ExtraUnitDef {
category,
weight,
singular,
plural,
});
Unit::Extra(id)
}
pub(crate) fn get_category(id: u16) -> UnitType {
let reg = REGISTRY
.read()
.expect("extra unit registry lock poisoned");
reg.units
.get(id as usize)
.map(|u| u.category)
.unwrap_or(UnitType::NoType)
}
pub(crate) fn get_weight(id: u16) -> D128 {
let reg = REGISTRY
.read()
.expect("extra unit registry lock poisoned");
reg.units
.get(id as usize)
.map(|u| u.weight)
.unwrap_or(D128::NAN)
}
pub(crate) fn get_singular(id: u16) -> &'static str {
let reg = REGISTRY
.read()
.expect("extra unit registry lock poisoned");
reg.units
.get(id as usize)
.map(|u| u.singular)
.unwrap_or("")
}
pub(crate) fn get_plural(id: u16) -> &'static str {
let reg = REGISTRY
.read()
.expect("extra unit registry lock poisoned");
reg.units
.get(id as usize)
.map(|u| u.plural)
.unwrap_or("")
}
#[cfg(test)]
pub(crate) fn clear_for_tests() {
let mut reg = REGISTRY
.write()
.expect("extra unit registry lock poisoned");
reg.by_name.clear();
reg.units.clear();
}

View File

@ -10,6 +10,8 @@ use crate::Constant::{E, Pi};
use crate::LexerKeyword::{In, PercentChar, Per, Mercury, Hg, PoundForce, Force, DoubleQuotes, Revolution};
use crate::FunctionIdentifier::{Cbrt, Ceil, Cos, Exp, Abs, Floor, Ln, Log, Round, Sin, Sqrt, Tan};
use crate::units::Unit::*;
use crate::extra_units;
use crate::units::UnitType;
use unicode_segmentation::{Graphemes, UnicodeSegmentation};
fn is_word_char_str(input: &str) -> bool {
@ -591,7 +593,11 @@ fn parse_word(word: &str, lexer: &mut Lexer) -> Result<(), String> {
"f" | "fahrenheit" | "fahrenheits" => Token::Unit(Fahrenheit),
string => {
return Err(format!("Invalid string: {}", string));
if let Some(unit) = extra_units::lookup_unit(string) {
Token::Unit(unit)
} else {
return Err(format!("Invalid string: {}", string));
}
}
};
lexer.tokens.push(token);
@ -903,6 +909,103 @@ pub fn lex(input: &str, remove_trailing_operator: bool) -> Result<Vec<Token>, St
(Token::LexerKeyword(Revolution), Token::LexerKeyword(Per), Token::Unit(Minute)) => {
tokens[token_index-2] = Token::Unit(RevolutionsPerMinute);
},
// currency per {mass|volume|length|area}, for example usd/lb, cny/kg, usd/l, usd/m, usd/m2
(Token::Unit(left_u), Token::LexerKeyword(Per), Token::Unit(right_u)) => {
let left_cat = left_u.category();
let right_cat = right_u.category();
if left_cat == UnitType::Currency
&& (right_cat == UnitType::Mass
|| right_cat == UnitType::Volume
|| right_cat == UnitType::Length
|| right_cat == UnitType::Area)
{
let cur_upper = left_u.singular().to_ascii_uppercase();
let cur_lower = cur_upper.to_ascii_lowercase();
let denom_abbrev: &str = match (*right_u, right_cat) {
// Mass
(Milligram, UnitType::Mass) => "mg",
(Gram, UnitType::Mass) => "g",
(Hectogram, UnitType::Mass) => "hg",
(Kilogram, UnitType::Mass) => "kg",
(MetricTon, UnitType::Mass) => "t",
(Ounce, UnitType::Mass) => "oz",
(Pound, UnitType::Mass) => "lb",
(Stone, UnitType::Mass) => "st",
(ShortTon, UnitType::Mass) => "ston",
(LongTon, UnitType::Mass) => "lton",
// Volume
(Milliliter, UnitType::Volume) => "ml",
(Centiliter, UnitType::Volume) => "cl",
(Deciliter, UnitType::Volume) => "dl",
(Liter, UnitType::Volume) => "l",
(Teaspoon, UnitType::Volume) => "tsp",
(Tablespoon, UnitType::Volume) => "tbsp",
(FluidOunce, UnitType::Volume) => "floz",
(Cup, UnitType::Volume) => "cup",
(Pint, UnitType::Volume) => "pt",
(Quart, UnitType::Volume) => "qt",
(Gallon, UnitType::Volume) => "gal",
(OilBarrel, UnitType::Volume) => "bbl",
// Length
(Millimeter, UnitType::Length) => "mm",
(Centimeter, UnitType::Length) => "cm",
(Decimeter, UnitType::Length) => "dm",
(Meter, UnitType::Length) => "m",
(Kilometer, UnitType::Length) => "km",
(Inch, UnitType::Length) => "in",
(Foot, UnitType::Length) => "ft",
(Yard, UnitType::Length) => "yd",
(Mile, UnitType::Length) => "mi",
(Marathon, UnitType::Length) => "marathon",
(NauticalMile, UnitType::Length) => "nmi",
(LightYear, UnitType::Length) => "ly",
(LightSecond, UnitType::Length) => "lightsec",
// Area
(SquareMillimeter, UnitType::Area) => "mm2",
(SquareCentimeter, UnitType::Area) => "cm2",
(SquareDecimeter, UnitType::Area) => "dm2",
(SquareMeter, UnitType::Area) => "m2",
(SquareKilometer, UnitType::Area) => "km2",
(SquareInch, UnitType::Area) => "in2",
(SquareFoot, UnitType::Area) => "ft2",
(SquareYard, UnitType::Area) => "yd2",
(SquareMile, UnitType::Area) => "mi2",
(Are, UnitType::Area) => "a",
(Decare, UnitType::Area) => "daa",
(Hectare, UnitType::Area) => "ha",
(Acre, UnitType::Area) => "acre",
_ => right_u.singular(),
};
let display = format!("{}/{}", cur_upper, denom_abbrev);
let key = format!("{cur_lower}_per_{denom_abbrev}");
// weight = (USD per currency unit) / (base volume-or-mass per denom unit)
let weight = left_u.weight() / right_u.weight();
let composite_type = match right_cat {
UnitType::Mass => UnitType::CurrencyPerMass,
UnitType::Volume => UnitType::CurrencyPerVolume,
UnitType::Length => UnitType::CurrencyPerLength,
UnitType::Area => UnitType::CurrencyPerArea,
_ => {
replaced = false;
UnitType::CurrencyPerMass
}
};
let composite = extra_units::upsert_unit(
&key,
&display,
&display,
composite_type,
weight,
);
tokens[token_index-2] = Token::Unit(composite);
} else {
replaced = false;
}
},
_ => {
replaced = false;
},

View File

@ -34,6 +34,10 @@ pub mod evaluator;
pub mod lexer;
#[rustfmt::skip]
mod lookup;
#[rustfmt::skip]
mod extra_units;
/// Currency rate loading / registration (USD base from exchangerate-api)
pub mod currency;
/// Turns [`Token`]s into an [`AstNode`](parser::AstNode)
pub mod parser;
/// Units, and functions you can use with them
@ -299,6 +303,16 @@ pub fn wasm_eval(expression: &str) -> String {
}
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen]
pub fn wasm_set_currency_rates(json: &str) -> String {
console_error_panic_hook::set_once();
match currency::register_currencies_from_exchange_rate_api_json(json) {
Ok(n) => format!("OK ({n} currencies)"),
Err(e) => format!("Error: {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -1,4 +1,6 @@
use cpc::eval;
#[cfg(not(target_arch = "wasm32"))]
use cpc::currency;
use std::env;
use std::process::exit;
@ -60,6 +62,12 @@ fn main() {
}
};
// Best-effort: load currency rates so `usd`, `eur`, etc work as extra units.
#[cfg(not(target_arch = "wasm32"))]
{
let _ = currency::init_currencies_blocking();
}
match eval(&expression, true, verbose) {
Ok(answer) => {
if !verbose {

View File

@ -1,5 +1,6 @@
use fastnum::{dec128 as d, D128};
use crate::Number;
use crate::extra_units;
#[derive(Clone, Copy, PartialEq, Debug)]
/// An enum of all possible unit types, like [`Length`], [`DigitalStorage`] etc.
@ -39,6 +40,16 @@ pub enum UnitType {
Speed,
/// A unit of temperature, for example [`Kelvin`]
Temperature,
/// A currency unit, for example USD / EUR (loaded dynamically)
Currency,
/// A currency-per-mass unit, for example USD/lb or CNY/kg
CurrencyPerMass,
/// A currency-per-volume unit, for example USD/l or EUR/ml
CurrencyPerVolume,
/// A currency-per-length unit, for example USD/m or EUR/ft
CurrencyPerLength,
/// A currency-per-area unit, for example USD/m2 or EUR/ft2
CurrencyPerArea,
}
use UnitType::*;
@ -50,7 +61,9 @@ macro_rules! create_units {
#[derive(Clone, Copy, PartialEq, Debug)]
/// A Unit enum. Note that it can also be [`NoUnit`].
pub enum Unit {
$($variant),*
$($variant),*,
/// Runtime-registered unit (currencies, custom units, etc)
Extra(u16)
}
use Unit::*;
@ -59,28 +72,32 @@ macro_rules! create_units {
match self {
$(
Unit::$variant => $properties.0
),*
),*,
Unit::Extra(id) => extra_units::get_category(*id),
}
}
pub fn weight(&self) -> D128 {
match self {
$(
Unit::$variant => $properties.1
),*
),*,
Unit::Extra(id) => extra_units::get_weight(*id),
}
}
pub(crate) fn singular(&self) -> &str {
match self {
$(
Unit::$variant => $properties.2
),*
),*,
Unit::Extra(id) => extra_units::get_singular(*id),
}
}
pub(crate) fn plural(&self) -> &str {
match self {
$(
Unit::$variant => $properties.3
),*
),*,
Unit::Extra(id) => extra_units::get_plural(*id),
}
}
}