diff options
| author | 魏曹先生 <1992414357@qq.com> | 2026-04-16 21:31:57 +0800 |
|---|---|---|
| committer | 魏曹先生 <1992414357@qq.com> | 2026-04-16 21:31:57 +0800 |
| commit | 363fbc6e98f832471a17a10ec18e8823df6a2ed5 (patch) | |
| tree | 98f71ab1796c1a9c1df411eee5174dd92001ef94 | |
Initialize Rust project with billing calculation functionality
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Cargo.lock | 695 | ||||
| -rw-r--r-- | Cargo.toml | 22 | ||||
| -rw-r--r-- | build.rs | 3 | ||||
| -rw-r--r-- | src/bill.rs | 167 | ||||
| -rw-r--r-- | src/calc.rs | 163 | ||||
| -rw-r--r-- | src/cli.rs | 6 | ||||
| -rw-r--r-- | src/cli/calc_cmd.rs | 9 | ||||
| -rw-r--r-- | src/cli/consts.rs | 1 | ||||
| -rw-r--r-- | src/cli/dispatchers.rs | 12 | ||||
| -rw-r--r-- | src/cli/entry.rs | 21 | ||||
| -rw-r--r-- | src/cli/io_error.rs | 42 | ||||
| -rw-r--r-- | src/cli/ops_cmd.rs | 80 | ||||
| -rw-r--r-- | src/error.rs | 8 | ||||
| -rw-r--r-- | src/main.rs | 23 | ||||
| -rw-r--r-- | src/test.rs | 409 | ||||
| -rw-r--r-- | src/who.rs | 44 |
17 files changed, 1706 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..fcdbdc8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,695 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cobill" +version = "0.1.0" +dependencies = [ + "mingling", + "serde", + "thiserror 1.0.69", + "tokio", + "uuid", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "just_fmt" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5454cda0d57db59778608d7a47bff5b16c6705598265869fb052b657f66cf05e" + +[[package]] +name = "just_template" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3edb658c34b10b69c4b3b58f7ba989cd09c82c0621dee1eef51843c2327225" +dependencies = [ + "just_fmt", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mingling" +version = "0.1.5" +dependencies = [ + "mingling_core", + "mingling_macros", + "size", +] + +[[package]] +name = "mingling_core" +version = "0.1.4" +dependencies = [ + "just_fmt", + "just_template", + "once_cell", + "ron", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "toml", +] + +[[package]] +name = "mingling_macros" +version = "0.1.4" +dependencies = [ + "just_fmt", + "once_cell", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ron" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" +dependencies = [ + "bitflags", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "size" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b6709c7b6754dca1311b3c73e79fcce40dd414c782c66d88e8823030093b02b" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" +dependencies = [ + "bytes", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0d58106 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "cobill" # chaos_billing +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "cobill" +path = "src/main.rs" + +[dependencies] +mingling = { path = "../mingling/mingling", features = ["full"] } + +tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread" ] } + +uuid = { version = "1", features = ["v4"] } + +serde = { version = "1", features = ["derive"] } + +thiserror = "1.0.69" + +[build-dependencies] +mingling = { path = "../mingling/mingling", features = ["comp"] } diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..61abda8 --- /dev/null +++ b/build.rs @@ -0,0 +1,3 @@ +fn main() { + mingling::build::build_comp_scripts("cobill").unwrap(); +} diff --git a/src/bill.rs b/src/bill.rs new file mode 100644 index 0000000..16923f9 --- /dev/null +++ b/src/bill.rs @@ -0,0 +1,167 @@ +use std::collections::BTreeMap; + +use uuid::Uuid; + +use crate::who::Who; + +#[derive(Default)] +pub struct Bills { + pub items: BTreeMap<Uuid, BillItem>, +} + +pub struct BillItem { + pub who_paid: Who, + pub reason: String, + pub paid: f64, + pub split: Vec<Who>, +} + +pub struct SplitResult { + pub items: BTreeMap<Who, Vec<SplitResultItem>>, + pub final_result: BTreeMap<(Who, Who), f64>, +} + +pub struct SplitResultItem { + pub payee: Who, + pub bill: f64, + pub reason: String, +} + +impl Bills { + /// Add a new bill item + pub fn add_bill(&mut self, who_paid: &str, reason: &str, paid: f64, split: Vec<&str>) -> Uuid { + let item = BillItem { + who_paid: who_paid.into(), + reason: reason.to_string(), + paid, + split: split.into_iter().map(|s| s.into()).collect(), + }; + self.add_item(item) + } + + /// Add a new bill item + pub fn add_item(&mut self, item: BillItem) -> Uuid { + let id = Uuid::new_v4(); + self.items.insert(id, item); + id + } + + /// Get a bill item by ID (immutable reference) + pub fn get_item(&self, id: &Uuid) -> Option<&BillItem> { + self.items.get(id) + } + + /// Get a bill item by ID (mutable reference) + pub fn get_item_mut(&mut self, id: &Uuid) -> Option<&mut BillItem> { + self.items.get_mut(id) + } + + /// Update the bill item with the specified ID + pub fn update_item(&mut self, id: &Uuid, item: BillItem) -> bool { + if self.items.contains_key(id) { + self.items.insert(*id, item); + true + } else { + false + } + } + + /// Delete the bill item with the specified ID + pub fn delete_item(&mut self, id: &Uuid) -> Option<BillItem> { + self.items.remove(id) + } + + /// Get all bill items + pub fn get_all_items(&self) -> &BTreeMap<Uuid, BillItem> { + &self.items + } + + /// Check if a bill item with the specified ID exists + pub fn contains_item(&self, id: &Uuid) -> bool { + self.items.contains_key(id) + } + + /// Clear all bill items + pub fn clear_items(&mut self) { + self.items.clear(); + } +} + +impl SplitResult { + /// Add a bill (who pays whom, amount, reason) + pub fn add_bill(&mut self, payer: Who, payee: Who, amount: f64, reason: String) { + let result_item = SplitResultItem { + payee, + bill: amount, + reason, + }; + + self.items + .entry(payer) + .or_insert_with(Vec::new) + .push(result_item); + } + + /// Get all bill items for a specified payer (immutable reference) + pub fn get_bills(&self, payer: &Who) -> Option<&Vec<SplitResultItem>> { + self.items.get(payer) + } + + /// Get all bill items for a specified payer (mutable reference) + pub fn get_bills_mut(&mut self, payer: &Who) -> Option<&mut Vec<SplitResultItem>> { + self.items.get_mut(payer) + } + + /// Update the bill list for a specified payer + pub fn update_bills( + &mut self, + payer: Who, + bills: Vec<SplitResultItem>, + ) -> Option<Vec<SplitResultItem>> { + self.items.insert(payer, bills) + } + + /// Delete all bill items for a specified payer + pub fn delete_bills(&mut self, payer: &Who) -> Option<Vec<SplitResultItem>> { + self.items.remove(payer) + } + + /// Get all bill items for all payers + pub fn get_all_bills(&self) -> &BTreeMap<Who, Vec<SplitResultItem>> { + &self.items + } + + /// Check if bill items exist for a specified payer + pub fn contains_payer(&self, payer: &Who) -> bool { + self.items.contains_key(payer) + } + + /// Clear all bill items + pub fn clear_bills(&mut self) { + self.items.clear(); + } + /// Set the simplified result + pub fn set_final_result(&mut self, result: BTreeMap<(Who, Who), f64>) { + self.final_result = result; + } + + /// Get the simplified result (immutable reference) + pub fn get_final_result(&self) -> &BTreeMap<(Who, Who), f64> { + &self.final_result + } + + /// Get the simplified result (mutable reference) + pub fn get_final_result_mut(&mut self) -> &mut BTreeMap<(Who, Who), f64> { + &mut self.final_result + } + + /// Clear the simplified result + pub fn clear_final_result(&mut self) { + self.final_result.clear(); + } + + /// Get a specific item from the simplified result (who pays whom, returns Option<f64>) + pub fn get_final_result_item(&self, payer: Who, payee: Who) -> Option<f64> { + self.final_result.get(&(payer, payee)).copied() + } +} diff --git a/src/calc.rs b/src/calc.rs new file mode 100644 index 0000000..fcd0f5b --- /dev/null +++ b/src/calc.rs @@ -0,0 +1,163 @@ +use std::collections::BTreeMap; + +use crate::{ + bill::{Bills, SplitResult, SplitResultItem}, + error::BillSplitError, + who::Who, +}; + +pub fn calculate_from(item: Bills) -> Result<SplitResult, BillSplitError> { + // Validate input data + precheck(&item)?; + + // Calculate each person's net balance and original transactions + let (direct_transactions, items) = calculate_balances_and_transactions(&item); + + // Generate the simplest result: net settlement between each pair + let final_result = calculate_net_settlements(&direct_transactions); + + // Add "Total" reason to final_result + let mut items = items; + add_total_reason(&mut items); + + Ok(SplitResult { + items, + final_result, + }) +} + +fn precheck(item: &Bills) -> Result<(), BillSplitError> { + for (_, bill_item) in &item.items { + // Check if the paid amount is negative + if bill_item.paid < 0.0 { + return Err(BillSplitError::NegativePaidAmount); + } + + // Check for duplicate members in the split list + let mut seen = std::collections::HashSet::new(); + for person in &bill_item.split { + if !seen.insert(person) { + return Err(BillSplitError::DuplicateSplitMembers); + } + } + } + Ok(()) +} + +fn calculate_balances_and_transactions( + item: &Bills, +) -> ( + BTreeMap<(Who, Who), f64>, + BTreeMap<Who, Vec<SplitResultItem>>, +) { + let mut direct_transactions: BTreeMap<(Who, Who), f64> = BTreeMap::new(); + let mut items: BTreeMap<Who, Vec<SplitResultItem>> = BTreeMap::new(); + + for (_, bill_item) in &item.items { + let who_paid = &bill_item.who_paid; + let paid = bill_item.paid; + let split_count = bill_item.split.len() as f64; + + if split_count == 0.0 { + continue; + } + + // Round + let share = (paid / split_count * 100.0).round() / 100.0; + + // Calculate the amount each person should pay + for person in &bill_item.split { + // If the payer is also in the split list, deduct their own share + if person != who_paid { + // Record direct transaction + let key = (person.clone(), who_paid.clone()); + *direct_transactions.entry(key).or_insert(0.0) += share; + + // Add to full record + let bill_result_item = SplitResultItem { + payee: who_paid.clone(), + bill: share, + reason: bill_item.reason.clone(), + }; + + items + .entry(person.clone()) + .or_insert_with(Vec::new) + .push(bill_result_item); + } + } + } + + (direct_transactions, items) +} + +fn calculate_net_settlements( + direct_transactions: &BTreeMap<(Who, Who), f64>, +) -> BTreeMap<(Who, Who), f64> { + let mut final_result: BTreeMap<(Who, Who), f64> = BTreeMap::new(); + + // First, calculate net amounts for each transaction pair + let mut net_transactions: BTreeMap<(Who, Who), f64> = BTreeMap::new(); + for ((from, to), amount) in direct_transactions { + let key = (from.clone(), to.clone()); + *net_transactions.entry(key).or_insert(0.0) += amount; + } + + // Now process net transactions, ensuring correct direction + let mut processed_pairs = std::collections::HashSet::new(); + + for ((from, to), amount) in &net_transactions { + // Create a normalized transaction pair key (sorted alphabetically) + let pair_key = if from < to { + (from.clone(), to.clone()) + } else { + (to.clone(), from.clone()) + }; + + // If this pair has already been processed, skip it + if processed_pairs.contains(&pair_key) { + continue; + } + processed_pairs.insert(pair_key.clone()); + + // Check for reverse transaction + let reverse_key = (to.clone(), from.clone()); + if let Some(reverse_amount) = net_transactions.get(&reverse_key) { + // There is a reverse transaction, calculate net amount + let net_amount = *amount - *reverse_amount; + + if net_amount > 0.0001 { + // from owes to (net) + final_result.insert( + (from.clone(), to.clone()), + (net_amount * 100.0).round() / 100.0, + ); + } else if net_amount < -0.0001 { + // to owes from (net) + final_result.insert( + (to.clone(), from.clone()), + (-net_amount * 100.0).round() / 100.0, + ); + } + // If net amount is close to 0, don't add any transaction + } else { + // No reverse transaction, add directly + if *amount > 0.0001 { + final_result.insert( + (from.clone(), to.clone()), + (*amount * 100.0).round() / 100.0, + ); + } + } + } + + final_result +} + +fn add_total_reason(items: &mut BTreeMap<Who, Vec<SplitResultItem>>) { + for (_payer, bills_list) in items.iter_mut() { + for bill in bills_list { + bill.reason = format!("{} (Total)", bill.reason); + } + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..c1d1240 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,6 @@ +pub mod calc_cmd; +pub mod consts; +pub mod dispatchers; +pub mod entry; +pub mod io_error; +pub mod ops_cmd; diff --git a/src/cli/calc_cmd.rs b/src/cli/calc_cmd.rs new file mode 100644 index 0000000..09f4e03 --- /dev/null +++ b/src/cli/calc_cmd.rs @@ -0,0 +1,9 @@ +// use mingling::macros::dispatcher; +// use mingling::{macros::chain, marker::NextProcess}; + +// use crate::cli::entry::*; + +// dispatcher!("calc", CalculateCommand => CalculateEntry); + +// #[chain] +// pub async fn parse_calc_entry(prev: CalculateEntry) -> NextProcess {} diff --git a/src/cli/consts.rs b/src/cli/consts.rs new file mode 100644 index 0000000..254b611 --- /dev/null +++ b/src/cli/consts.rs @@ -0,0 +1 @@ +pub const BILL_WORKSPACE_CONFIG_FILE: &str = "cobill.yml"; diff --git a/src/cli/dispatchers.rs b/src/cli/dispatchers.rs new file mode 100644 index 0000000..27b7747 --- /dev/null +++ b/src/cli/dispatchers.rs @@ -0,0 +1,12 @@ +use mingling::{Program, macros::program_setup}; + +use crate::ThisProgram; +use crate::cli::ops_cmd::{CreateCommand, InitHereCommand}; + +#[program_setup] +pub fn chaos_billing_setup(program: &mut Program<ThisProgram, ThisProgram>) { + program.with_dispatcher(InitHereCommand); + program.with_dispatcher(CreateCommand); + + // program.with_dispatcher(CalculateCommand); +} diff --git a/src/cli/entry.rs b/src/cli/entry.rs new file mode 100644 index 0000000..e68b7b4 --- /dev/null +++ b/src/cli/entry.rs @@ -0,0 +1,21 @@ +use mingling::setup::GeneralRendererSetup; + +use crate::__completion_gen::CompletionDispatcher; +use crate::ThisProgram; +use crate::cli::dispatchers::*; + +pub async fn entry() { + let mut program = ThisProgram::new(); + + // Add Completion + program.with_dispatcher(CompletionDispatcher); + + // Add General Renderer + program.with_setup(GeneralRendererSetup); + + // Setup `cobill` + program.with_setup(ChaosBillingSetup); + + // Execute + program.exec().await; +} diff --git a/src/cli/io_error.rs b/src/cli/io_error.rs new file mode 100644 index 0000000..49b9939 --- /dev/null +++ b/src/cli/io_error.rs @@ -0,0 +1,42 @@ +use mingling::{ + Groupped, + macros::{r_println, renderer}, +}; +use serde::Serialize; + +use crate::ThisProgram; + +#[derive(Groupped)] +pub struct IOError { + inner: std::io::Error, +} + +impl IOError { + pub fn new(error: std::io::Error) -> Self { + Self { inner: error } + } +} + +impl From<std::io::Error> for IOError { + fn from(error: std::io::Error) -> Self { + Self::new(error) + } +} + +impl Serialize for IOError { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut state = serializer.serialize_struct("IOError", 2)?; + state.serialize_field("kind", &self.inner.kind().to_string())?; + state.serialize_field("info", &self.inner.to_string())?; + state.end() + } +} + +#[renderer] +pub fn render_io_error(prev: IOError) { + r_println!("{}: {}", prev.inner.kind(), prev.inner.to_string()) +} diff --git a/src/cli/ops_cmd.rs b/src/cli/ops_cmd.rs new file mode 100644 index 0000000..4b0eea7 --- /dev/null +++ b/src/cli/ops_cmd.rs @@ -0,0 +1,80 @@ +use std::{ + env::current_dir, + fs::{self, create_dir_all}, + path::PathBuf, +}; + +use mingling::{ + AnyOutput, + macros::{chain, dispatcher, pack, r_println, renderer}, + marker::NextProcess, + parser::Picker, +}; + +use crate::{ + ThisProgram, + cli::{consts::BILL_WORKSPACE_CONFIG_FILE, io_error::IOError}, +}; + +dispatcher!("init", InitHereCommand => InitEntry); +dispatcher!("create", CreateCommand => CreateEntry); + +pack!(StateCreateWorkspace = PathBuf); + +#[chain] +pub async fn handle_init_command(_prev: InitEntry) -> NextProcess { + let current_dir = match current_dir() { + Ok(d) => d, + Err(e) => return AnyOutput::new(IOError::from(e)).route_renderer(), + }; + StateCreateWorkspace::new(current_dir).to_chain() +} + +#[chain] +pub async fn handle_create_command(prev: CreateEntry) -> NextProcess { + let path = pick_path(prev.inner); + StateCreateWorkspace::new(path).to_chain() +} + +#[chain] +pub async fn handle_state_create_workspace(prev: StateCreateWorkspace) -> NextProcess { + let dir = prev.inner; + let file = dir.join(BILL_WORKSPACE_CONFIG_FILE); + + match create_dir_all(&dir) { + Ok(d) => d, + Err(e) => return AnyOutput::new(IOError::from(e)).route_renderer(), + }; + + if file.exists() { + return AnyOutput::new(WorkspaceConfigAlreadyExists::new(dir)).route_renderer(); + } + + if let Err(e) = fs::write(file, "") { + return AnyOutput::new(IOError::from(e)).route_renderer(); + } + + StateWorkspaceCreated::new(dir).to_render() +} + +pack!(StateWorkspaceCreated = PathBuf); + +#[renderer] +pub fn render_workspace_created(prev: StateWorkspaceCreated) { + r_println!("Workspace created at: {:?}", prev.inner); +} + +pack!(WorkspaceConfigAlreadyExists = PathBuf); + +#[renderer] +pub fn render_workspace_config_already_exists(prev: WorkspaceConfigAlreadyExists) { + r_println!("Workspace config already exists: {:?}", prev.inner); +} + +fn pick_path(args: Vec<String>) -> PathBuf { + let path = Picker::<()>::new(args) + .pick::<String>(()) + .unpack_directly() + .0; + PathBuf::from(path) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..a9c23b1 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,8 @@ +#[derive(thiserror::Error, Debug)] +pub enum BillSplitError { + #[error("Paid amount cannot be negative")] + NegativePaidAmount, + + #[error("Duplicate split members found")] + DuplicateSplitMembers, +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a97d952 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,23 @@ +use mingling::macros::gen_program; + +mod bill; +mod calc; +mod cli; +mod error; +mod who; + +#[cfg(test)] +mod test; + +#[tokio::main] +async fn main() { + cli::entry::entry().await +} + +use crate::cli::calc_cmd::*; +use crate::cli::dispatchers::*; +use crate::cli::entry::*; +use crate::cli::io_error::*; +use crate::cli::ops_cmd::*; + +gen_program!(); diff --git a/src/test.rs b/src/test.rs new file mode 100644 index 0000000..15a7002 --- /dev/null +++ b/src/test.rs @@ -0,0 +1,409 @@ +use crate::{bill::Bills, calc::calculate_from}; + +#[test] +fn test_no_zero_amount_transactions() { + let mut bills = Bills::default(); + + // Create some bills where some transaction amounts might be 0 + // A pays 30, split among A, B, C (10 each) + bills.add_bill("A", "Lunch", 30., vec!["A", "B", "C"]); + // B pays 30, split among A, B (15 each) + bills.add_bill("B", "Coffee", 30., vec!["A", "B"]); + + let result = calculate_from(bills); + assert!(result.is_ok(), "calculate should be success"); + let result = result.unwrap(); + + // Check if there are any transactions with amount 0 in the complete record (items) + let all_items = result.get_all_bills(); + for (payer, bills_list) in all_items { + for bill in bills_list { + assert_ne!( + bill.bill, 0.0, + "There should be no transactions with amount 0 in the complete record: {} to {} amount is 0", + payer, bill.payee + ); + } + } + + // Check if there are any transactions with amount 0 in the simplified result (final_result) + let final_result = result.get_final_result(); + for ((payer, payee), amount) in final_result { + assert_ne!( + *amount, 0.0, + "There should be no transactions with amount 0 in the simplified result: {} to {} amount is 0", + payer, payee + ); + } + + // Verify transaction count + // Should have: C->A (10), A->B (5) after netting + assert_eq!( + final_result.len(), + 2, + "There should be 2 non-zero transactions" + ); + assert!( + final_result.contains_key(&("C".into(), "A".into())), + "Should contain transaction C->A" + ); + assert!( + final_result.contains_key(&("A".into(), "B".into())), + "Should contain transaction A->B" + ); + + // Verify specific amounts + let c_to_a = final_result.get(&("C".into(), "A".into())).unwrap(); + assert_eq!(*c_to_a, 10.0, "C should pay A 10"); + + let a_to_b = final_result.get(&("A".into(), "B".into())).unwrap(); + assert_eq!(*a_to_b, 5.0, "A should pay B 5"); +} + +#[test] +fn test_zero_amount_edge_cases() { + let mut bills = Bills::default(); + + // Test perfectly balanced case: A and B prepay the same amount for each other + // A prepays 20 for A, B (10 each) + bills.add_bill("A", "Dinner", 20., vec!["A", "B"]); + // B prepays 20 for A, B (10 each) + bills.add_bill("B", "Movie", 20., vec!["A", "B"]); + + let result = calculate_from(bills); + assert!(result.is_ok(), "calculate should be success"); + let result = result.unwrap(); + + // Check complete record + let all_items = result.get_all_bills(); + for (payer, bills_list) in all_items { + for bill in bills_list { + assert_ne!( + bill.bill, 0.0, + "There should be no transactions with amount 0 in the complete record: {} to {} amount is 0", + payer, bill.payee + ); + } + } + + // Check simplified result - should be empty because all transactions cancel out + let final_result = result.get_final_result(); + assert_eq!( + final_result.len(), + 0, + "The simplified result should be empty because all transaction amounts are 0" + ); + + // Verify no transactions are included + assert!( + !final_result.contains_key(&("A".into(), "B".into())), + "Should not contain A->B zero amount transaction" + ); + assert!( + !final_result.contains_key(&("B".into(), "A".into())), + "Should not contain B->A zero amount transaction" + ); +} + +#[test] +fn test_items_count() { + let mut bills = Bills::default(); + + // Add 3 bill items + let id1 = bills.add_bill("A", "Lunch", 30., vec!["A", "B"]); + let id2 = bills.add_bill("B", "Coffee", 20., vec!["B", "C"]); + let id3 = bills.add_bill("C", "Snack", 15., vec!["A", "C"]); + + // Verify items count + assert_eq!(bills.get_all_items().len(), 3, "Should have 3 items"); + + // Verify each ID exists + assert!(bills.contains_item(&id1), "Item 1 should exist"); + assert!(bills.contains_item(&id2), "Item 2 should exist"); + assert!(bills.contains_item(&id3), "Item 3 should exist"); + + // Verify count after deleting one item + let removed = bills.delete_item(&id2); + assert!(removed.is_some(), "Should remove item 2"); + assert_eq!( + bills.get_all_items().len(), + 2, + "Should have 2 items after removal" + ); + + // Verify deleted item no longer exists + assert!( + !bills.contains_item(&id2), + "Item 2 should not exist after removal" + ); + + // Verify count after clearing + bills.clear_items(); + assert_eq!( + bills.get_all_items().len(), + 0, + "Should have 0 items after clear" + ); +} + +#[test] +fn test_result() { + let mut bills = Bills::default(); + + // Define data + bills.add_bill("A", "BBQ", 90., vec!["A", "B", "C"]); + bills.add_bill("B", "Water", 21., vec!["A", "B", "C"]); + + // Calculate + let result = calculate_from(bills); + + // Check result + assert!(result.is_ok(), "calculate should be success"); + + let result = result.unwrap(); + + // Verify split results + let c_to_a = result + .get_final_result_item("C".into(), "A".into()) + .expect("Item C to A should be exist"); + assert_eq!(c_to_a, 30.0, "C should pay A 30 for BBQ"); + + let c_to_b = result + .get_final_result_item("C".into(), "B".into()) + .expect("Item C to B should be exist"); + assert_eq!(c_to_b, 7.0, "C should pay B 7 for Water"); + + let b_to_a = result + .get_final_result_item("B".into(), "A".into()) + .expect("Item B to A should be exist"); + assert_eq!(b_to_a, 23.0, "B should pay A 23 (30 - 7)"); + + // Verify count + let final_result = result.get_final_result(); + assert_eq!(final_result.len(), 3, "Should have exactly 3 transactions"); +} + +#[test] +fn test_complex_bills() { + let mut bills = Bills::default(); + + // A prepays 50 for B and C, B and C should each pay A 25 + bills.add_bill("A", "Dinner", 50., vec!["B", "C"]); + + let result = calculate_from(bills); + assert!(result.is_ok(), "calculate should be success"); + let result = result.unwrap(); + + let b_to_a = result + .get_final_result_item("B".into(), "A".into()) + .expect("Item B to A should be exist"); + assert_eq!(b_to_a, 25.0, "B should pay A 25"); + + let c_to_a = result + .get_final_result_item("C".into(), "A".into()) + .expect("Item C to A should be exist"); + assert_eq!(c_to_a, 25.0, "C should pay A 25"); + + let final_result = result.get_final_result(); + assert_eq!(final_result.len(), 2, "Should have exactly 2 transactions"); +} + +#[test] +fn test_unrelated_bills() { + let mut bills = Bills::default(); + + // A prepays 30 split among A, B, C, each should pay A 10 + // B prepays 30 split among A, B, each should pay B 15 + // Final result: C only has transaction with A, not with B + bills.add_bill("A", "Lunch", 30., vec!["A", "B", "C"]); + bills.add_bill("B", "Coffee", 30., vec!["A", "B"]); + + let result = calculate_from(bills); + assert!(result.is_ok(), "calculate should be success"); + let result = result.unwrap(); + + // Verify C only has transaction with A, not with B + let c_to_a = result.get_final_result_item("C".into(), "A".into()); + assert!(c_to_a.is_some(), "C should pay A"); + assert_eq!(c_to_a.unwrap(), 10.0, "C should pay A 10"); + + let c_to_b = result.get_final_result_item("C".into(), "B".into()); + assert!(c_to_b.is_none(), "C should not have any transaction with B"); + + // Verify transaction between A and B + let a_to_b = result.get_final_result_item("A".into(), "B".into()); + assert!(a_to_b.is_some(), "A should pay B"); + assert_eq!(a_to_b.unwrap(), 5.0, "A should pay B 5 (15 - 10)"); + + // Verify total transaction count + let final_result = result.get_final_result(); + assert_eq!(final_result.len(), 2, "Should have exactly 2 transactions"); +} + +#[test] +fn test_duplicate_split_members() { + let mut bills = Bills::default(); + + // Duplicate members in split list, should return Error + bills.add_bill("Alice", "Lunch", 60., vec!["Bob", "Bob", "Charlie"]); + + let result = calculate_from(bills); + assert!( + result.is_err(), + "Should return error for duplicate split members" + ); +} + +#[test] +fn test_negative_paid_amount() { + let mut bills = Bills::default(); + + // Negative prepaid amount, should return Error + bills.add_bill("Alice", "Refund?", -30., vec!["Alice", "Bob"]); + + let result = calculate_from(bills); + assert!( + result.is_err(), + "Should return error for negative paid amount" + ); +} + +#[test] +fn test_rounding() { + let mut bills = Bills::default(); + + // Test rounding: 51.0333333333 => 51.00, 51.599999999999 => 52.00 + // 100 / 3 = 33.333..., each should pay 33.33, payer gets back 66.67 + bills.add_bill("Alice", "Concert", 100., vec!["Alice", "Bob", "Charlie"]); + + let result = calculate_from(bills); + assert!(result.is_ok(), "calculate should be success"); + let result = result.unwrap(); + + let bob_to_alice = result + .get_final_result_item("Bob".into(), "Alice".into()) + .expect("Item Bob to Alice should be exist"); + // 33.333... rounded to 2 decimal places => 33.33 + assert_eq!(bob_to_alice, 33.33, "Bob should pay Alice 33.33"); + + let charlie_to_alice = result + .get_final_result_item("Charlie".into(), "Alice".into()) + .expect("Item Charlie to Alice should be exist"); + assert_eq!(charlie_to_alice, 33.33, "Charlie should pay Alice 33.33"); + + // Another test: 51.599999999999 => 52.00 + let mut bills2 = Bills::default(); + bills2.add_bill("Bob", "Dinner", 51.6, vec!["Alice", "Bob"]); // 51.6 / 2 = 25.8 + + let result2 = calculate_from(bills2); + assert!(result2.is_ok(), "calculate should be success"); + let result2 = result2.unwrap(); + + let alice_to_bob = result2 + .get_final_result_item("Alice".into(), "Bob".into()) + .expect("Item Alice to Bob should be exist"); + // 25.8 rounded to 2 decimal places => 25.80 + assert_eq!(alice_to_bob, 25.8, "Alice should pay Bob 25.8"); +} + +#[test] +fn test_empty_bills() { + let bills = Bills::default(); + let result = calculate_from(bills); + assert!( + result.is_ok(), + "calculate should be success for empty bills" + ); + let result = result.unwrap(); + + assert_eq!( + result.get_all_bills().len(), + 0, + "Items should be empty for empty bills" + ); + assert_eq!( + result.get_final_result().len(), + 0, + "Final result should be empty for empty bills" + ); +} + +#[test] +fn test_single_person_bill() { + let mut bills = Bills::default(); + + // Single person bill: paying for oneself + bills.add_bill("Alice", "Personal", 50., vec!["Alice"]); + + let result = calculate_from(bills); + assert!( + result.is_ok(), + "calculate should be success for single person bill" + ); + let result = result.unwrap(); + + // Single person bill should not generate any transactions + assert_eq!( + result.get_final_result().len(), + 0, + "Should have no transactions for single person bill" + ); +} + +#[test] +fn test_split_not_include_payer() { + let mut bills = Bills::default(); + + // Payer not included in split list + bills.add_bill("Alice", "Gift", 100., vec!["Bob", "Charlie"]); + + let result = calculate_from(bills); + assert!( + result.is_ok(), + "calculate should be success when payer not in split" + ); + let result = result.unwrap(); + + // Bob and Charlie should each pay Alice 50 + let bob_to_alice = result + .get_final_result_item("Bob".into(), "Alice".into()) + .expect("Bob should pay Alice"); + assert_eq!(bob_to_alice, 50.0, "Bob should pay Alice 50"); + + let charlie_to_alice = result + .get_final_result_item("Charlie".into(), "Alice".into()) + .expect("Charlie should pay Alice"); + assert_eq!(charlie_to_alice, 50.0, "Charlie should pay Alice 50"); +} + +#[test] +fn test_large_number_of_people() { + let mut bills = Bills::default(); + + // Test large group scenario + let people = vec!["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"]; + bills.add_bill("A", "Group Dinner", 1000., people.clone()); + + let result = calculate_from(bills); + assert!( + result.is_ok(), + "calculate should be success for large group" + ); + let result = result.unwrap(); + + // Each person should pay A 100 (1000/10) + for person in &people { + if *person != "A" { + let amount = result + .get_final_result_item(person.to_string().into(), "A".into()) + .expect(&format!("{} should pay A", person)); + assert_eq!(amount, 100.0, "{} should pay A 100", person); + } + } + + assert_eq!( + result.get_final_result().len(), + 9, + "Should have 9 transactions" + ); +} diff --git a/src/who.rs b/src/who.rs new file mode 100644 index 0000000..9d6e5b5 --- /dev/null +++ b/src/who.rs @@ -0,0 +1,44 @@ +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Who { + name: String, +} + +impl std::ops::Deref for Who { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.name + } +} + +impl std::ops::DerefMut for Who { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.name + } +} + +impl From<String> for Who { + fn from(s: String) -> Self { + Who { name: s } + } +} + +impl From<&str> for Who { + fn from(s: &str) -> Self { + Who { + name: s.to_string(), + } + } +} + +impl Into<String> for Who { + fn into(self) -> String { + self.name + } +} + +impl std::fmt::Display for Who { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} |
