aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author魏曹先生 <1992414357@qq.com>2026-04-16 21:31:57 +0800
committer魏曹先生 <1992414357@qq.com>2026-04-16 21:31:57 +0800
commit363fbc6e98f832471a17a10ec18e8823df6a2ed5 (patch)
tree98f71ab1796c1a9c1df411eee5174dd92001ef94
Initialize Rust project with billing calculation functionality
-rw-r--r--.gitignore1
-rw-r--r--Cargo.lock695
-rw-r--r--Cargo.toml22
-rw-r--r--build.rs3
-rw-r--r--src/bill.rs167
-rw-r--r--src/calc.rs163
-rw-r--r--src/cli.rs6
-rw-r--r--src/cli/calc_cmd.rs9
-rw-r--r--src/cli/consts.rs1
-rw-r--r--src/cli/dispatchers.rs12
-rw-r--r--src/cli/entry.rs21
-rw-r--r--src/cli/io_error.rs42
-rw-r--r--src/cli/ops_cmd.rs80
-rw-r--r--src/error.rs8
-rw-r--r--src/main.rs23
-rw-r--r--src/test.rs409
-rw-r--r--src/who.rs44
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)
+ }
+}