diff --git a/Cargo.lock b/Cargo.lock index 912322e..28b5eb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml index c3c2b68..a9866c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/currency.rs b/src/currency.rs new file mode 100644 index 0000000..081eed1 --- /dev/null +++ b/src/currency.rs @@ -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 { + 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 { + 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 { + // 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; + } +} + + diff --git a/src/extra_units.rs b/src/extra_units.rs new file mode 100644 index 0000000..28e9db7 --- /dev/null +++ b/src/extra_units.rs @@ -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, +} + +static REGISTRY: Lazy> = 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 { + 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(); +} + + diff --git a/src/lexer.rs b/src/lexer.rs index fb9f2a0..374aa81 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -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, 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; }, diff --git a/src/lib.rs b/src/lib.rs index 82f5143..28d91fd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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::*; diff --git a/src/main.rs b/src/main.rs index 7764528..bda7345 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { diff --git a/src/units.rs b/src/units.rs index d095cad..0b1968b 100644 --- a/src/units.rs +++ b/src/units.rs @@ -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), } } }