Initial commit

This commit is contained in:
Avinash Mallya
2025-07-20 17:41:11 -05:00
commit 9f3e71a5fd
27 changed files with 28941 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target
.DS_Store
# These are backup files generated by rustfmt
**/*.rs.bk

5775
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

36
Cargo.toml Normal file
View File

@@ -0,0 +1,36 @@
[package]
name = "borrow_checker"
version = "0.1.0"
authors = ["Avinash Mallya <avimallu.github.io>"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
dioxus = { version = "0.6.0", features = ["router"]}
dioxus-free-icons = { version = "0.9", features = ["lucide"] }
rust_decimal = { version = "1.37.2", features = ["macros"] }
serde = { version = "1.0.219", features = ["derive"] }
#[cfg(target_arch = "wasm32")]
gloo-storage = "0.3.0"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
comfy-table = "7.1.4"
[features]
default = ["web"]
web = ["dioxus/web"]
desktop = ["dioxus/desktop"]
mobile = ["dioxus/mobile"]
[profile.wasm-dev]
inherits = "dev"
opt-level = 1
[profile.server-dev]
inherits = "dev"
[profile.android-dev]
inherits = "dev"

21
Dioxus.toml Normal file
View File

@@ -0,0 +1,21 @@
[application]
[web.app]
# HTML title tag content
title = "iron-abacus"
# include `assets` in web platform
[web.resource]
# Additional CSS style files
style = []
# Additional JavaScript files
script = []
[web.resource.dev]
# Javascript code file
# serve: [dev-server] only
script = []

29
README.md Normal file
View File

@@ -0,0 +1,29 @@
# BorrowChecker
A simple app to help you split your bills and receipts with multiple people fairy.
## Screenshots
<p float="left">
<img src="/assets/screenshots/screen_1.jpeg" width=15%>
<img src="/assets/screenshots/screen_2.jpeg" width=15%>
<img src="/assets/screenshots/screen_3.jpeg" width=15%>
</p>
# Development
Run the following command in the root of your project to start developing with the default platform:
```bash
dx serve --port 8123
```
If you want to make it available to the local network, run:
```bash
dx serve --port 8123 --addr 0.0.0.0
```
The choice of the `port` argument is up to you. Currently, this app is configured to only be compilable on the `web` platform.

21572
assets/bulma.css vendored Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

8
clippy.toml Normal file
View File

@@ -0,0 +1,8 @@
await-holding-invalid-types = [
"generational_box::GenerationalRef",
{ path = "generational_box::GenerationalRef", reason = "Reads should not be held over an await point. This will cause any writes to fail while the await is pending since the read borrow is still active." },
"generational_box::GenerationalRefMut",
{ path = "generational_box::GenerationalRefMut", reason = "Write should not be held over an await point. This will cause any reads or writes to fail while the await is pending since the write borrow is still active." },
"dioxus_signals::Write",
{ path = "dioxus_signals::Write", reason = "Write should not be held over an await point. This will cause any reads or writes to fail while the await is pending since the write borrow is still active." },
]

76
src/app/display.rs Normal file
View File

@@ -0,0 +1,76 @@
use crate::app::{Route, RECEIPT_STATE};
use dioxus::prelude::*;
#[component]
pub fn DisplaySplits() -> Element {
let nav = navigator();
if let Some(receipt) = RECEIPT_STATE.read().as_ref() {
let mut header = receipt.shared_by.clone();
header.insert(0, "Item Name".into());
header.push("Total".into());
let (item_names, item_splits) = receipt.calculate_splits()?;
let rows: Vec<Vec<String>> = item_names
.into_iter()
.zip(item_splits.into_iter())
.map(|(item_name, splits)| {
let mut splits_as_str: Vec<String> = splits.iter().map(|x| x.to_string()).collect();
splits_as_str.insert(0, item_name.into());
splits_as_str
})
.collect();
rsx! {
document::Title { "BorrowChecker | View" }
header { class: "hero is-small is-primary",
div { class: "hero-body has-text-centered",
p { class: "title", "Here's your split!" }
p { class: "subtitle is-size-6",
"Balance leftover is distributed proportionally."
}
}
}
div { class: "is-flex is-justify-content-center",
div { class: "table-container ",
table { class: "table",
thead {
tr {
for val in header.iter() {
th { scope: "col", "{val}" }
}
}
}
tbody {
for row in rows.iter() {
tr {
for (idx , val) in row.iter().enumerate() {
if idx == 0 {
th { scope: "row", "{val}" }
} else {
td { "{val}" }
}
}
}
}
}
}
}
}
footer { class: "hero is-small is-primary",
div { class: "hero-body has-text-centered is-flex is-justify-content-center",
p { class: "subtitle is-size-7 mr-1", "Built with Rust & Dioxus | " }
p { class: "subtitle is-size-7 mr-1", "👾🤖👻 |" }
a {
class: "subtitle is-size-7 mr-1",
href: "https://avimallu.github.io",
"avimallu.github.io "
}
}
}
}
} else {
nav.push(Route::CreateReceiptSplash);
rsx! {}
}
}

28
src/app/frontend.rs Normal file
View File

@@ -0,0 +1,28 @@
use crate::app::display::DisplaySplits;
use crate::app::splash::CreateReceiptSplash;
use crate::app::split::SplitUI;
use crate::core::receipt::Receipt;
use dioxus::prelude::*;
static CSS: Asset = asset!("/assets/bulma.css");
pub static RECEIPT_STATE: GlobalSignal<Option<Receipt>> = Signal::global(|| None);
#[derive(Routable, Clone, Debug)]
#[rustfmt::skip]
pub enum Route {
#[route("/create")]
#[redirect("/", || Route::CreateReceiptSplash {})]
CreateReceiptSplash,
#[route("/split")]
SplitUI,
#[route("/display")]
DisplaySplits,
}
#[component]
pub fn App() -> Element {
rsx! {
document::Stylesheet { href: CSS }
Router::<Route> {}
}
}

7
src/app/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod display;
pub mod frontend;
pub mod splash;
pub mod split;
pub mod storage;
pub use frontend::*;

244
src/app/splash.rs Normal file
View File

@@ -0,0 +1,244 @@
use crate::app::storage::use_persistent;
use crate::app::{Route, RECEIPT_STATE};
use crate::core::receipt::Receipt;
use dioxus::prelude::*;
use dioxus_free_icons::icons::ld_icons;
use dioxus_free_icons::Icon;
use rust_decimal::prelude::*;
#[component]
pub fn CreateReceiptSplash() -> Element {
let receipt_value: Signal<Option<Decimal>> = use_signal(|| None);
let people_input: Signal<Vec<String>> = use_signal(|| vec!["".to_string()]);
let people_list: Memo<Vec<String>> = use_memo(move || {
people_input
.read()
.iter()
.filter(|&name| !name.is_empty())
.cloned()
.collect()
});
rsx! {
document::Title { "BorrowChecker | Create" }
header { class: "hero is-small is-primary",
div { class: "hero-body has-text-centered",
p { class: "title", "BorrowChecker" }
p { class: "subtitle is-size-6", "A utility to determine how who owes what" }
}
}
div { class: "section is-small",
ReceiptValue { receipt_value }
hr {}
ReceiptPeopleList { people_input }
}
div { class: "section",
SubmitReceipt { receipt_value, people_list }
RetrieveCache { people_input }
}
footer { class: "hero is-small is-primary",
div { class: "hero-body has-text-centered is-flex is-justify-content-center",
p { class: "subtitle is-size-7 mr-1", "Built with Rust & Dioxus | " }
p { class: "subtitle is-size-7 mr-1", "👾🤖👻 |" }
a {
class: "subtitle is-size-7 mr-1",
href: "https://avimallu.github.io",
"avimallu.github.io "
}
}
}
}
}
#[component]
fn ReceiptValue(mut receipt_value: Signal<Option<Decimal>>) -> Element {
rsx! {
div { class: "container is-fluid",
input {
class: "input is-primary",
min: 0.01,
placeholder: "Enter receipt total",
step: "0.01",
required: "true",
r#type: "number",
inputmode: "decimal",
value: "{receipt_value.read().map_or_else(String::new, |d| d.to_string())}",
oninput: move |event| {
if let Ok(value) = event.value().parse::<Decimal>() {
receipt_value.set(Some(value));
} else {
receipt_value.set(None);
}
},
}
}
}
}
#[component]
fn ReceiptPeopleList(mut people_input: Signal<Vec<String>>) -> Element {
rsx! {
div { class: "container is-fluid",
for (idx , person) in people_input().iter().enumerate() {
div { key: "people_input_div_{idx}", class: "columns is-mobile",
div { class: "column is-9",
input {
class: "input is-primary",
key: "people_input_text_{idx}",
r#type: "text",
minlength: 0,
placeholder: "Enter person {idx+1} name",
value: "{person}",
oninput: move |evt| { people_input.with_mut(|list| list[idx] = evt.value()) },
}
}
div { class: "column is-1 is-narrow",
// Render ❌ only one of the two conditions is true:
// 1) The current item is not the last item in the list and
// 2) There are more than 2 items in the list
//
// Otherwise, always render unless the input is an empty string.
if people_input.len() > 1 && (idx + 1) != people_input.len() {
button {
class: "button is-danger is-dark is-rounded",
key: "people_input_remove_button_{idx}",
onclick: move |_| {
people_input.with_mut(|list| list.remove(idx));
},
Icon {
width: 24,
height: 24,
fill: "white",
icon: ld_icons::LdCircleX,
}
}
} else if person != "" {
button {
class: "button is-primary is-dark is-rounded",
key: "people_input_add_button_{idx}",
onclick: move |_| {
people_input.push("".to_string());
},
Icon {
width: 24,
height: 24,
fill: "white",
icon: ld_icons::LdPlus,
}
}
} else {
}
}
}
}
}
}
}
#[component]
fn SubmitReceipt(
receipt_value: Signal<Option<Decimal>>,
people_list: Memo<Vec<String>>,
) -> Element {
let nav = navigator();
if !receipt_value().is_none() && people_list.read().len() > 0 {
let generated_receipt = Receipt::new(
receipt_value().unwrap(),
people_list().iter().map(|x| x.as_str()).collect(),
);
match generated_receipt {
Ok(valid_receipt) => {
rsx! {
div { class: "container is-fluid",
button {
class: "button is-success is-dark is-large is-fullwidth",
key: "submit_receipt",
onclick: move |_| {
set_people(people_list().clone());
*RECEIPT_STATE.write() = Some(valid_receipt.clone());
nav.push(Route::SplitUI);
},
"Submit"
}
}
}
}
Err(error) => rsx! {
div { class: "panel-heading has-text-centered", "{error.to_string()}" }
},
}
} else {
rsx! {
div { class: "panel-heading has-text-centered",
"Provide a total amount and at least two people to start splitting!"
}
}
}
}
fn retrieve_people() -> Vec<Vec<String>> {
let init_people_list: Vec<Vec<String>> = vec![]; // temp;
use_persistent("people_list", || init_people_list).get()
}
fn set_people(new_people_list: Vec<String>) {
let mut cached_people_list = retrieve_people();
let empty: Vec<Vec<String>> = vec![];
if cached_people_list.len() > 1 {
cached_people_list.remove(0);
}
// Very inefficient, but shouldn't make a difference for such a small cache:
let mut is_present = false;
for people_list in cached_people_list.iter() {
if *people_list == new_people_list {
is_present = true;
}
}
if !is_present {
cached_people_list.push(new_people_list.clone());
use_persistent("people_list", || empty).set(cached_people_list)
};
}
#[component]
fn RetrieveCache(people_input: Signal<Vec<String>>) -> Element {
let cache_people_list = retrieve_people();
rsx! {
if cache_people_list.len() > 0 {
hr {}
div { class: "panel-heading", "Or pick from recently used groups:" }
for (idx , people) in cache_people_list.clone().into_iter().rev().enumerate() {
if idx < 3 {
div { class: "columns",
div { class: "column",
div { class: "buttons",
for person in people.iter() {
div {
button {
class: "button is-link",
disabled: "true",
"{person}"
}
}
}
}
}
div { class: "column is-2 is-narrow",
button {
class: "button is-primary",
onclick: move |_| {
people_input.set(people.clone());
},
"Select this group"
}
}
hr {}
}
}
}
}
}
}

212
src/app/split.rs Normal file
View File

@@ -0,0 +1,212 @@
use crate::app::{Route, RECEIPT_STATE};
use dioxus::prelude::*;
use dioxus_free_icons::icons::ld_icons;
use dioxus_free_icons::Icon;
use rust_decimal::prelude::*;
#[component]
pub fn SplitUI() -> Element {
let nav = navigator();
if let Some(receipt) = RECEIPT_STATE.read().as_ref() {
let (_, balance) = receipt.get_itemized_total_and_leftover();
let item_count = receipt.items.len();
rsx! {
document::Title { "BorrowChecker | Split" }
header { class: "hero is-small is-primary",
div { class: "hero-body has-text-centered",
ColorBalanceTitle { balance }
}
}
div { class: "section",
div { class: "container is-fluid",
for item_idx in 0..item_count {
SplitItemUI { item_idx }
}
}
div { class: "is-flex is-justify-content-center",
div { class: "buttons",
div {
button {
class: "button is-primary is-dark",
key: "item_add_button",
onclick: move |_| {
if let Some(r) = RECEIPT_STATE.write().as_mut() {
let people_list = r.shared_by.clone();
r.add_item_split_by_ratio(
Decimal::ZERO,
format!("Item {}", item_count + 1),
people_list,
None,
)
.unwrap();
}
},
Icon {
width: 24,
height: 24,
fill: "white",
icon: ld_icons::LdBookPlus,
}
span { class: "ml-2", "Add Item" }
}
}
div {
if receipt.items.len() > 0 && receipt.items.iter().all(|x| x.value > Decimal::ZERO)
&& receipt.calculate_splits().is_ok()
{
button {
class: "button is-link is-dark",
key: "show_calculated_table",
onclick: move |_| {
nav.push(Route::DisplaySplits);
},
Icon {
width: 24,
height: 24,
fill: "white",
icon: ld_icons::LdScale,
}
span { class: "ml-2", "Show Splits" }
}
}
}
}
}
}
footer { class: "hero is-small is-primary",
div { class: "hero-body has-text-centered is-flex is-justify-content-center",
p { class: "subtitle is-size-7 mr-1", "Built with Rust & Dioxus | " }
p { class: "subtitle is-size-7 mr-1", "👾🤖👻 |" }
a {
class: "subtitle is-size-7 mr-1",
href: "https://avimallu.github.io",
"avimallu.github.io "
}
}
}
}
} else {
nav.push(Route::CreateReceiptSplash);
rsx! {}
}
}
#[component]
fn ColorBalanceTitle(balance: Decimal) -> Element {
rsx! {
if balance > Decimal::ZERO {
p { class: "title has-text-dark is-size-4", "+{balance}" }
p { class: "subtitle is-size-5", "left to balance" }
} else if balance < Decimal::ZERO {
p { class: "title has-text-danger is-size-4", "Remaining: {balance}" }
p { class: "subtitle is-size-6", "Item total exceeds receipt total." }
} else {
p { class: "title has-text-link is-size-4", "0" }
p { class: "subtitle is-size-6", "Perfectly balanced, as all things should be." }
}
}
}
#[component]
fn SplitItemUI(item_idx: usize) -> Element {
let people_list = (*RECEIPT_STATE.read()).as_ref().unwrap().shared_by.clone();
let (item_name, item_value, item_shared_by) = &RECEIPT_STATE
.read()
.as_ref()
.and_then(|r| r.items.get(item_idx))
.map(|item| {
(
item.name.clone(),
item.value.clone(),
item.shared_by.clone(),
)
})
.unwrap_or_default();
let item_value = if item_value > &Decimal::ZERO {
item_value.to_string()
} else {
"-".to_string()
};
if true {
rsx! {
div { class: "columns is-mobile",
div { class: "column is-two-thirds",
input {
class: "input is-primary",
key: "item_input_name_{item_idx}",
r#type: "text",
value: "{item_name}",
oninput: move |evt| {
if let Some(r) = RECEIPT_STATE.write().as_mut() {
if let Some(item) = r.items.get_mut(item_idx) {
item.name = evt.value();
}
}
},
placeholder: "item name",
}
}
div { class: "column is-one-third",
input {
class: "input is-primary",
key: "item_input_value_{item_idx}",
min: "0.00",
step: "0.01",
inputmode: "decimal",
required: "true",
r#type: "number",
value: "{item_value}",
oninput: move |evt| {
if let Some(r) = RECEIPT_STATE.write().as_mut() {
if let Some(item) = r.items.get_mut(item_idx) {
if let Ok(valid_decimal) = evt.value().parse::<Decimal>() {
item.value = valid_decimal;
} else {
item.value = Decimal::ZERO;
}
}
}
},
placeholder: "amount",
}
}
}
div { class: "buttons",
for (person_idx , person) in people_list.clone().into_iter().enumerate() {
div {
button {
class: if item_shared_by.contains(&person) { "button is-primary is-dark is-fullwidth" } else { "button is-primary is-outlined is-dark is-fullwidth" },
key: "item_{item_idx}_person_{person_idx}",
onclick: move |_| {
if let Some(r) = RECEIPT_STATE.write().as_mut() {
if let Some(item) = r.items.get_mut(item_idx) {
if item.shared_by.contains(&person) && item.shared_by.len() > 1 {
let shared_by_idx = item
.shared_by
.iter()
.position(|name| *name == *person)
.unwrap();
item.shared_by.remove(shared_by_idx);
item.share_ratio.remove(shared_by_idx);
} else {
item.shared_by.push(person.clone());
item.share_ratio.push(Decimal::ONE);
}
}
}
},
"{person}"
}
}
}
}
hr {}
}
} else {
rsx! { "Unhandled error 2" }
}
}

58
src/app/storage.rs Normal file
View File

@@ -0,0 +1,58 @@
use dioxus::prelude::*;
use gloo_storage::{LocalStorage, Storage};
use serde::{de::DeserializeOwned, Serialize};
// Copied directly from https://dioxuslabs.com/learn/0.6/cookbook/state/custom_hooks/
/// A persistent storage hook that can be used to store data across application reloads.
#[allow(clippy::needless_return)]
pub fn use_persistent<T: Serialize + DeserializeOwned + Default + 'static>(
// A unique key for the storage entry
key: impl ToString,
// A function that returns the initial value if the storage entry is empty
init: impl FnOnce() -> T,
) -> UsePersistent<T> {
// Use the use_signal hook to create a mutable state for the storage entry
let state = use_signal(move || {
// This closure will run when the hook is created
let key = key.to_string();
let value = LocalStorage::get(key.as_str()).ok().unwrap_or_else(init);
StorageEntry { key, value }
});
// Wrap the state in a new struct with a custom API
UsePersistent { inner: state }
}
struct StorageEntry<T> {
key: String,
value: T,
}
/// Storage that persists across application reloads
pub struct UsePersistent<T: 'static> {
inner: Signal<StorageEntry<T>>,
}
impl<T> Clone for UsePersistent<T> {
fn clone(&self) -> Self {
*self
}
}
impl<T> Copy for UsePersistent<T> {}
impl<T: Serialize + DeserializeOwned + Clone + 'static> UsePersistent<T> {
/// Returns a reference to the value
pub fn get(&self) -> T {
self.inner.read().value.clone()
}
/// Sets the value
pub fn set(&mut self, value: T) {
let mut inner = self.inner.write();
// Write the new value to local storage
let _ = LocalStorage::set(inner.key.as_str(), &value);
inner.value = value;
}
}

110
src/app/ui.rs Normal file
View File

@@ -0,0 +1,110 @@
use crate::core::receipt::Receipt;
use dioxus::prelude::*;
use rust_decimal::prelude::*;
static CSS: Asset = asset!("/assets/bulma.css");
#[derive(Clone, PartialEq, Eq)]
enum AppState {
CreatingReceipt,
SplittingItems,
DisplayingSplits,
}
#[component]
pub fn App() -> Element {
let app_state = use_signal(|| AppState::CreatingReceipt);
// Variables to create the essential values of the receipt
let receipt_value: Signal<Option<Decimal>> = use_signal(|| None);
let people_input: Signal<Vec<String>> = use_signal(|| vec!["".to_string()]);
let people_list: Memo<Vec<String>> = use_memo(move || {
people_input
.read()
.iter()
.filter(|name| !name.is_empty())
.cloned()
.collect()
});
// Variables to input and handle receipt items
let receipt: Signal<Option<Receipt>> = use_signal(|| None);
rsx! {
document::Stylesheet { href: CSS }
section { class: "hero is-primary",
div { class: "hero-body",
p { class: "title", "Iron Abacus" }
p { class: "subtitle", "A splitting helper" }
}
}
if app_state() == AppState::CreatingReceipt {
CreateReceipt {
app_state,
receipt_value,
people_input,
people_list,
receipt,
}
} else if app_state() == AppState::SplittingItems {
SplitReceipt {
receipt_value,
people_list,
app_state,
receipt,
}
} else {
DisplayTable { app_state, receipt }
}
}
}
#[component]
fn DisplayTable(mut app_state: Signal<AppState>, mut receipt: Signal<Option<Receipt>>) -> Element {
if let Some(valid_receipt) = receipt().as_ref() {
let mut header = valid_receipt.shared_by.clone();
header.insert(0, "Item Name".into());
header.push("Total".into());
let (item_names, item_splits) = valid_receipt.calculate_splits()?;
let rows: Vec<Vec<String>> = item_names
.into_iter()
.zip(item_splits.into_iter())
.map(|(item_name, splits)| {
let mut splits_as_str: Vec<String> = splits.iter().map(|x| x.to_string()).collect();
splits_as_str.insert(0, item_name.into());
splits_as_str
})
.collect();
rsx! {
div { class: "table-container",
table { class: "table",
thead {
tr {
for val in header.iter() {
th { scope: "col", "{val}" }
}
}
}
tbody {
for row in rows.iter() {
tr {
for (idx , val) in row.iter().enumerate() {
if idx == 0 {
th { scope: "row", "{val}" }
} else {
td { "{val}" }
}
}
}
}
}
}
}
}
} else {
rsx! { "No table to show yet, bitches!" }
}
}

41
src/cli/arg_parser.rs Normal file
View File

@@ -0,0 +1,41 @@
use crate::core::receipt::{Receipt, SplittingError};
use std::env;
// Super-basic parsing, advanced parsing packages are not needed
pub fn parse_args() -> Result<Receipt, SplittingError> {
let args: Vec<String> = env::args().collect();
// dbg!(&args);
if args.len() < 2 {
return Err(SplittingError::InvalidArgument(format!(
"You have specified only the receipt's total value and people sharing it \
but not any item within it to split. Please do so"
)));
} else {
let mut receipt = Receipt::parse_create_receipt(&args[1])?;
let mut curr_arg: Option<&str> = None;
for (arg_idx, arg) in args[2..].iter().enumerate() {
if curr_arg.is_none() {
if arg.starts_with("--") {
curr_arg = Some(&arg[2..]);
} else if arg.starts_with("-") {
curr_arg = Some(&arg[1..]);
continue;
} else {
return Err(SplittingError::InvalidArgument(format!(
"Argument {} is expected (in this case) to be an item name, \
and must be prefixed with a dash (-) or a double dash (--). Currently, \
it is {}",
arg_idx + 1,
arg
)));
}
} else {
receipt.parse_add_named_item(curr_arg.unwrap(), arg)?;
curr_arg = None
}
}
Ok(receipt)
}
}

113
src/cli/display.rs Normal file
View File

@@ -0,0 +1,113 @@
use crate::core::receipt::{Receipt, SplittingError};
use comfy_table::{Cell, Table, modifiers::UTF8_ROUND_CORNERS, presets::UTF8_FULL};
impl Receipt {
fn create_table(&self) -> Result<Table, SplittingError> {
let mut header = self.shared_by.clone();
header.insert(0, "Item".into());
header.push("Total".into());
let (item_names, item_splits) = self.calculate_splits()?;
let rows: Vec<Vec<String>> = item_names
.into_iter()
.zip(item_splits.into_iter())
.map(|(item_name, splits)| {
let mut splits_as_str: Vec<String> = splits.iter().map(|x| x.to_string()).collect();
splits_as_str.insert(0, item_name.into());
splits_as_str
})
.collect();
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.apply_modifier(UTF8_ROUND_CORNERS)
.set_header(header);
for (idx, column) in table.column_iter_mut().enumerate() {
if idx != 0 {
column.set_cell_alignment(comfy_table::CellAlignment::Right);
}
}
for row in rows.iter() {
if row[0] == "<total>" || row[0] == "<leftover>" {
let fg_col = if row[0] == "<total>" {
comfy_table::Color::Green
} else {
comfy_table::Color::DarkGrey
};
let row: Vec<Cell> = row.iter().map(|x| Cell::new(x).fg(fg_col)).collect();
table.add_row(row);
} else {
table.add_row(row);
}
}
Ok(table)
}
pub fn display_splits(&self) -> Result<(), SplittingError> {
let table = self.create_table()?;
print!("\n{table}\n");
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::core::receipt::Receipt;
use crate::utils;
use rust_decimal::prelude::*;
#[test]
fn test_create_table() {
let mut receipt = Receipt::new(
Decimal::from_str("300").unwrap(),
vec!["Alice", "Bob", "Marshall"],
)
.unwrap();
receipt
.add_item_split_by_ratio(
dec![200],
"Food".into(),
utils::strs_to_strings(vec!["Alice", "Marshall", "Bob"]),
None,
)
.unwrap();
receipt
.add_item_split_by_ratio(
dec![50],
"Drinks".into(),
utils::strs_to_strings(vec!["Alice", "Bob"]),
None,
)
.unwrap();
println!(
"Receipt Item Status Before Calculating Splits:\n\n{:#?}\n\n",
receipt.items
);
let mut table = receipt.create_table().unwrap();
table.force_no_tty();
let expected = "
╭────────────┬────────┬────────┬──────────┬───────╮
│ Item ┆ Alice ┆ Bob ┆ Marshall ┆ Total │
╞════════════╪════════╪════════╪══════════╪═══════╡
│ Food ┆ 66.67 ┆ 66.67 ┆ 66.67 ┆ 200 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ Drinks ┆ 25 ┆ 25 ┆ 0 ┆ 50 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ <leftover> ┆ 18.33 ┆ 18.33 ┆ 13.33 ┆ 50 │
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
│ <total> ┆ 110.00 ┆ 110.00 ┆ 80.00 ┆ 300 │
╰────────────┴────────┴────────┴──────────┴───────╯";
let actual = "\n".to_string() + &table.to_string();
assert_eq!(expected, actual)
}
}

4
src/cli/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod arg_parser;
pub mod display;
pub mod pattern_parser;
pub mod utils;

201
src/cli/pattern_parser.rs Normal file
View File

@@ -0,0 +1,201 @@
use crate::cli::utils as parse_utils;
use crate::core::receipt::{Receipt, SplittingError};
use crate::utils;
use rust_decimal::Decimal;
// Contains any pattern based parsing of inputs for the package.
impl Receipt {
pub fn parse_create_receipt(amount_shared_by: &str) -> Result<Receipt, SplittingError> {
let (total, shared_by) = parse_utils::split_by_comma(
amount_shared_by,
"Input must have pattern 'Total,Person_1[,Person_2,...]', but you have not provided the starting comma.",
)?;
let total = total.parse()?;
let shared_by: Vec<&str> = shared_by.split(",").collect();
Receipt::new(total, shared_by)
}
fn align_to_shared_by(&mut self, abbrevs: &str) -> Result<Vec<String>, SplittingError> {
let abbrevs: Vec<&str> = abbrevs.split(",").collect();
utils::is_string_vec_unique(
&abbrevs,
SplittingError::InvalidAbbreviation(format!(
"The abbreviation string: {} has duplicates.",
abbrevs.join(",")
)),
)?;
let mut matched_names: Vec<String> = Vec::new();
// Case is important - Don vs. don can be considered different people.
// Minimal disruption to user, less code to peruse.
for abbrev in abbrevs {
// If the abbreviation is already mapped to an existing name:
if let Some(existing_name) = self.mapped_abbreviations.get(abbrev) {
// If it doesn't map to another one, add this as a mapped name.
if matched_names.contains(existing_name) {
return Err(SplittingError::DuplicatePeopleError(format!(
"{} maps to {}, which has already been specified once.",
abbrev, existing_name
)));
} else {
matched_names.push(existing_name.clone());
}
} else {
// If the abbreviation is not mapped, try to find a map.
let mut found = false;
for name in &self.shared_by {
if utils::is_abbrev_match_to_string(abbrev, name)
& !matched_names.contains(name)
{
self.mapped_abbreviations
.insert(abbrev.to_string(), name.clone());
found = true;
matched_names.push(name.clone());
break;
}
}
// Not finding a match is an error.
if !found {
return Err(SplittingError::InvalidAbbreviation(format!(
"{} does not match to a provided person name.",
abbrev
)));
}
}
}
Ok(matched_names)
}
pub fn parse_add_named_item(
&mut self,
item_name: &str,
item_pattern: &str,
) -> Result<(), SplittingError> {
let (value, abbrevs) = parse_utils::split_by_comma(
item_pattern,
&format!(
"The first argument must have pattern 'Value,Person_1[,Person_2,...]', but you have {}",
item_pattern
),
)?;
let value: Decimal = value.parse()?;
let shared_by = self.align_to_shared_by(&abbrevs)?;
// Todo: Add parsing of ratios specified in item names
self.add_item_split_by_ratio(value, item_name.to_string(), shared_by, None)?;
Ok(())
}
}
#[cfg(test)]
mod test {
use crate::core::receipt::{Receipt, SplittingError};
use rust_decimal::prelude::*;
#[test]
fn test_no_people_to_share_with() {
let receipt = Receipt::parse_create_receipt("300,");
assert!(matches!(
receipt,
Err(SplittingError::NotEnoughPeopleError(_))
))
}
#[test]
fn test_only_one_person_to_share_with() {
let receipt = Receipt::parse_create_receipt("300,Alice");
assert!(matches!(
receipt,
Err(SplittingError::NotEnoughPeopleError(_))
))
}
#[test]
fn test_non_decimal_total() {
let receipt = Receipt::parse_create_receipt("wowza,Alice");
assert!(matches!(
receipt,
Err(SplittingError::DecimalParsingError(_))
))
}
#[test]
fn test_two_people() {
let receipt = Receipt::parse_create_receipt("300,Alice,Sam").unwrap();
assert_eq!(receipt.value, "300".parse::<Decimal>().unwrap());
assert_eq!(receipt.shared_by, vec!["Alice", "Sam"]);
}
#[test]
fn test_duplicate_people() {
let receipt = Receipt::parse_create_receipt("300,Alice,Sam,Alice");
let _ = String::from("The provided names are duplicate.");
assert!(matches!(
receipt,
Err(SplittingError::DuplicatePeopleError(_))
));
}
#[test]
fn test_duplicate_people_cased() {
let receipt = Receipt::parse_create_receipt("300,Alice,Sam,alice").unwrap();
assert_eq!(receipt.value, dec![300]);
assert_eq!(receipt.shared_by, vec!["Alice", "Sam", "alice"]);
}
#[test]
fn test_aligning_to_extant_shared_people_fail() {
let mut receipt = Receipt::parse_create_receipt("300,Alice,Sam,Samuel").unwrap();
let val = receipt.align_to_shared_by("Al,S,S");
let _ = "The abbreviation string: Al,S,S has duplicates.".to_string();
assert!(matches!(val, Err(SplittingError::InvalidAbbreviation(_))));
}
#[test]
fn test_aligning_to_extant_shared_people_pass() {
let mut receipt = Receipt::parse_create_receipt("300,Alice,Sam,Samuel").unwrap();
let val = receipt.align_to_shared_by("Al,S,Su").unwrap();
assert_eq!(val, vec!["Alice", "Sam", "Samuel"]);
}
#[test]
fn test_aligning_to_extant_shared_people_different_order_pass() {
let mut receipt = Receipt::parse_create_receipt("300,Alice,Sam,Samuel").unwrap();
let val = receipt.align_to_shared_by("Su,Al,S").unwrap();
assert_eq!(val, vec!["Samuel", "Alice", "Sam"]);
}
#[test]
fn match_person_by_abbreviation() {
let mut receipt = Receipt::parse_create_receipt("300,Alice,Sam,Marshall").unwrap();
receipt
.parse_add_named_item("Caviar", "150,Al,S,M")
.unwrap();
receipt.parse_add_named_item("Drinks", "90,S,A").unwrap();
assert_eq!(receipt.items[0].shared_by, vec!["Alice", "Sam", "Marshall"]);
assert_eq!(receipt.items[1].shared_by, vec!["Sam", "Alice"]);
let val = receipt.parse_add_named_item("More Drinks", "10,S,Sa,Al");
let _ = format!("Sa maps to Sam, which has already been specified once.");
assert!(matches!(val, Err(SplittingError::InvalidAbbreviation(_))));
}
// #[test]
// fn add_tip_and_tax() {
// let mut receipt = Receipt::parse_create_receipt("300,Alice,Sam,Marshall").unwrap();
// receipt.parse_tip_or_tax(ItemType::Tip, "25").unwrap();
// receipt.parse_tip_or_tax(ItemType::Tax, "35").unwrap();
// assert_eq!(receipt.items[0].shared_by, vec!["Alice", "Sam", "Marshall"]);
// assert_eq!(receipt.items[0].value, ItemType::Tip);
// assert_eq!(receipt.items[0].value, Decimal::from_str("25").unwrap());
// assert_eq!(receipt.items[1].shared_by, vec!["Alice", "Sam", "Marshall"]);
// assert_eq!(receipt.items[1].item, ItemType::Tax);
// assert_eq!(receipt.items[1].value, Decimal::from_str("35").unwrap());
// }
}

11
src/cli/utils.rs Normal file
View File

@@ -0,0 +1,11 @@
use crate::core::receipt::SplittingError;
pub fn split_by_comma(
input_str: &str,
error_message: &str,
) -> Result<(String, String), SplittingError> {
input_str
.split_once(",")
.map(|(value, other)| (value.to_string(), other.to_string()))
.ok_or_else(|| SplittingError::DecimalParsingError(error_message.to_string()))
}

1
src/core/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod receipt;

335
src/core/receipt.rs Normal file
View File

@@ -0,0 +1,335 @@
use crate::utils;
use rust_decimal::prelude::*;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::error::Error;
use std::fmt;
type Person = String;
const LEFTOVER_ITEM_NAME: &'static str = "<leftover>";
const TOTAL_ITEM_NAME: &'static str = "<total>";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Receipt {
pub value: Decimal,
pub shared_by: Vec<Person>,
pub mapped_abbreviations: HashMap<Person, String>,
pub items: Vec<ReceiptItem>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReceiptItem {
pub value: Decimal,
pub name: String,
pub shared_by: Vec<Person>,
pub share_ratio: Vec<Decimal>,
// is_proportionally_distributed
pub is_prop_dist: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SplittingError {
DuplicatePeopleError(String),
NotEnoughPeopleError(String),
InvalidShareConfiguration(String),
InvalidFieldError(String),
InvalidAbbreviation(String),
InternalError(String),
ItemTotalExceedsReceiptTotal(String),
DecimalParsingError(String),
InvalidArgument(String),
}
impl From<rust_decimal::Error> for SplittingError {
fn from(e: rust_decimal::Error) -> SplittingError {
SplittingError::DecimalParsingError(e.to_string())
}
}
// Required for main and Box<dyn std::error::Error>> returns to not complain
impl Error for SplittingError {}
impl fmt::Display for SplittingError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DuplicatePeopleError(msg) => write!(f, "{}", msg),
Self::NotEnoughPeopleError(msg) => write!(f, "{}", msg),
Self::InvalidShareConfiguration(msg) => write!(f, "{}", msg),
Self::InvalidFieldError(msg) => write!(f, "{}", msg),
Self::InvalidAbbreviation(msg) => write!(f, "{}", msg),
Self::InternalError(msg) => write!(f, "{}", msg),
Self::ItemTotalExceedsReceiptTotal(msg) => write!(f, "{}", msg),
Self::DecimalParsingError(msg) => write!(f, "{}", msg),
Self::InvalidArgument(msg) => write!(f, "{}", msg),
}
}
}
impl Receipt {
// Creates a new Receipt with just the (total) value and the people sharing it.
// Mapping is defaulted to a new, empty HashMap.
// Items is an empty vector.
pub fn new(value: Decimal, shared_by: Vec<&str>) -> Result<Receipt, SplittingError> {
utils::is_string_vec_unique(
&shared_by,
SplittingError::DuplicatePeopleError(
"The list of people sharing the receipt is duplicated. Please disambiguate.".into(),
),
)?;
utils::is_vec_len_gt_1(
&shared_by,
SplittingError::NotEnoughPeopleError(
"A receipt has to be shared by at least 2 people.".into(),
),
)?;
Ok(Receipt {
value,
shared_by: shared_by.iter().map(|&x| x.to_string()).collect(),
mapped_abbreviations: HashMap::new(),
items: vec![],
})
}
pub fn add_item_split_by_ratio(
&mut self,
value: Decimal,
name: String,
shared_by: Vec<String>,
share_ratio: Option<Vec<Decimal>>,
) -> Result<&mut Self, SplittingError> {
let share_ratio = share_ratio.unwrap_or(vec![Decimal::ONE; shared_by.len()]);
if shared_by.len() != share_ratio.len() {
return Err(SplittingError::InvalidShareConfiguration(format!(
"Length mismatch: people sharing {} and the ratios of the shares {} have differing lengths.",
shared_by.len(),
share_ratio.len()
)));
} else if shared_by.len() == 0 {
return Err(SplittingError::NotEnoughPeopleError(format!(
"The number of people sharing the item {} is {}. It must be shared by at least 1 person.",
name,
shared_by.len()
)));
}
if name.is_empty() {
return Err(SplittingError::InvalidFieldError(
"Item name cannot be empty".into(),
));
}
self.items.push(ReceiptItem {
value,
name,
shared_by,
share_ratio,
is_prop_dist: false,
});
Ok(self)
}
// Obtain a single vector with the exact splits, with or without items with
// the is_prop_dist attribute as true
fn calculate_overall_proportion(&self, remove_proportional_items: bool) -> Vec<Decimal> {
let items = self
.items
.iter()
.filter(|&x| !remove_proportional_items || !x.is_prop_dist);
let mut receipt_split: Vec<Decimal> = vec![Decimal::ZERO; self.shared_by.len()];
for item in items {
let denominator: Decimal = item.share_ratio.iter().sum();
// Split each item.value proportional to the share ratios of the people sharing
// the item, in the order in which these people appear in self.shared_by
let item_split: Vec<Decimal> = self
.shared_by
.iter()
.map(|person| {
item.shared_by
.iter()
.zip(item.share_ratio.iter())
// The first match is all that is required because other operations guarantee
// that duplicate names do not exist in either self.shared_by or item.shared_by
.find(|&(sharer, _)| *person == *sharer)
.map(|(_, &numerator)| (numerator / denominator * item.value))
.unwrap_or_else(|| Decimal::ZERO)
})
.collect();
for (idx, split) in item_split.iter().enumerate() {
receipt_split[idx] += split
}
}
receipt_split
}
pub fn add_item_split_by_proportion(
&mut self,
value: Decimal,
name: String,
shared_by: Vec<String>,
) -> Result<&mut Self, SplittingError> {
if shared_by.len() == 0 {
return Err(SplittingError::NotEnoughPeopleError(format!(
"The number of people sharing the item {} is currently {}. It must be shared by at least 1 person.",
name,
shared_by.len()
)));
} else if name.is_empty() {
return Err(SplittingError::InvalidFieldError(
"Item name cannot be empty".into(),
));
}
// These vectors are aligned with the order of self.shared_by
let pre_prop_splits = self.calculate_overall_proportion(true);
let pre_prop_split_total: Decimal = pre_prop_splits.iter().sum();
let pre_prop_ratios: Vec<Decimal> = pre_prop_splits
.iter()
.map(|x| x / pre_prop_split_total)
.collect();
// This vector's length will be identical to shared_by
let share_ratio: Vec<Decimal> = shared_by
.iter()
.map(|sharer| {
pre_prop_ratios
.iter()
.zip(self.shared_by.iter())
.find(|&(_, person)| *sharer == *person)
.map(|(&ratio, _)| ratio * value)
.ok_or_else(|| {
SplittingError::InternalError(format!(
"The sharer {} was not found among the original sharers.",
sharer,
))
})
})
.collect::<Result<_, _>>()?;
self.items.push(ReceiptItem {
value,
name,
shared_by,
share_ratio,
is_prop_dist: true,
});
Ok(self)
}
pub fn get_itemized_total_and_leftover(&self) -> (Decimal, Decimal) {
let itemized_total: Decimal = self.items.iter().map(|x| x.value).sum();
let leftover_amount: Decimal = self.value - itemized_total;
(itemized_total, leftover_amount)
}
// Get a vector of item names (including leftovers and totals), as well as the splits
// by each item so that they can be eventually displayed in a table easily, or used
// for any other purpose.
pub fn calculate_splits(&self) -> Result<(Vec<&str>, Vec<Vec<Decimal>>), SplittingError> {
// let itemized_total: Decimal = self.items.iter().map(|x| x.value).sum();
// let leftover_amount: Decimal = self.value - itemized_total;
let (itemized_total, leftover_amount) = self.get_itemized_total_and_leftover();
match leftover_amount.cmp(&Decimal::ZERO) {
// There is a problem only if the leftover amount is negative
Ordering::Greater | Ordering::Equal => {}
Ordering::Less => {
return Err(SplittingError::ItemTotalExceedsReceiptTotal(format!(
"The itemized total amount {} exceeds the receipt's total amount {} by {}",
itemized_total, self.value, leftover_amount
)));
}
};
let mut all_splits: Vec<Vec<Decimal>> = Vec::new();
// Refactor needed - Receipts are short lived, so there is no point in
// converting between ReceiptItem.shared_by and Receipt.shared_by - just
// store shared_by in the same order as the receipt and display to the
// user all the shared_by values that don't have 0 share ratio.
for item in self.items.iter() {
let mut splits: Vec<Decimal> = self
.shared_by
.iter()
.map(|x| match item.shared_by.iter().position(|name| name == x) {
Some(pos) => (item.value * item.share_ratio[pos]
/ item.share_ratio.iter().sum::<Decimal>())
.round_dp(2),
None => Decimal::ZERO.round_dp(2),
})
.collect();
splits.push(item.value);
all_splits.push(splits);
}
let mut item_names: Vec<&str> = self.items.iter().map(|x| x.name.as_str()).collect();
// Add unaccounted item, if present
if leftover_amount > Decimal::ZERO {
let overall_prop = self.calculate_overall_proportion(true);
let overall_prop_sum: Decimal = overall_prop.iter().sum();
let mut splits: Vec<Decimal> = overall_prop
.iter()
.map(|x| (x * leftover_amount / overall_prop_sum).round_dp(2))
.collect();
splits.push(leftover_amount);
all_splits.push(splits);
item_names.push(LEFTOVER_ITEM_NAME);
}
// Range from 0 to len + 1 to account for total added at the end of each item's share
let total_split: Vec<Decimal> = (0..(self.shared_by.len() + 1))
.map(|i| all_splits.iter().map(|v| v[i]).sum::<Decimal>().round_dp(2))
.collect();
all_splits.push(total_split);
item_names.push(TOTAL_ITEM_NAME);
Ok((item_names, all_splits))
}
}
#[cfg(test)]
mod tests {
use crate::core::receipt::Receipt;
use crate::utils;
use rust_decimal::prelude::*;
fn f64s_to_decimals(values: &[f64]) -> Vec<Decimal> {
values
.iter()
.map(|x| Decimal::from_f64(*x).unwrap())
.collect()
}
#[test]
fn test_calculate_splits() {
let mut receipt = Receipt::new(dec![300], vec!["Alice", "Bob", "Marshall"]).unwrap();
let _ = receipt
.add_item_split_by_ratio(
dec![200],
"Food".into(),
utils::strs_to_strings(vec!["Alice", "Bob", "Marshall"]),
None,
)
.unwrap();
let _ = receipt
.add_item_split_by_ratio(
dec![50],
"Drinks".into(),
utils::strs_to_strings(vec!["Alice", "Bob"]),
None,
)
.unwrap();
let (_, expected_splits) = receipt.calculate_splits().unwrap();
let actual_splits: Vec<Vec<Decimal>> = vec![
f64s_to_decimals(&[66.67, 66.67, 66.67, 200.0]),
f64s_to_decimals(&[25.0, 25.0, 0.0, 50.0]),
f64s_to_decimals(&[18.33, 18.33, 13.33, 50.0]),
f64s_to_decimals(&[110.0, 110.0, 80.0, 300.0]),
];
assert_eq!(expected_splits, actual_splits);
}
}

6
src/lib.rs Normal file
View File

@@ -0,0 +1,6 @@
pub mod app;
pub mod core;
pub mod utils;
#[cfg(not(target_arch = "wasm32"))]
mod cli;

6
src/main.rs Normal file
View File

@@ -0,0 +1,6 @@
use borrow_checker::app::frontend::App;
use dioxus::prelude::*;
fn main() {
dioxus::launch(App);
}

40
src/utils/mod.rs Normal file
View File

@@ -0,0 +1,40 @@
use std::collections::HashSet;
pub fn is_string_vec_unique<E>(vec: &[&str], error: E) -> Result<bool, E> {
let mut seen = HashSet::new();
if vec.iter().all(|&s| seen.insert(s)) {
Ok(true)
} else {
Err(error)
}
}
pub fn is_vec_len_gt_1<E>(vec: &[&str], error: E) -> Result<bool, E> {
if vec.is_empty() || vec.len() == 1 {
Err(error)
} else {
Ok(true)
}
}
pub fn is_abbrev_match_to_string(abbrev: &str, name: &str) -> bool {
// Check if at least one or more characters provided as an "abbreviation"
// are present in a string i.e. it is a valid abbreviation.
abbrev.chars().all(|c| name.chars().any(|nc| nc == c))
}
pub fn strs_to_strings(values: Vec<&str>) -> Vec<String> {
values.iter().map(|x| x.to_string()).collect()
}
#[cfg(test)]
mod tests {
use super::is_abbrev_match_to_string;
#[test]
fn match_person_to_name() {
assert_eq!(is_abbrev_match_to_string("Hn", "Hannah"), true);
assert_eq!(is_abbrev_match_to_string("Hh", "Hannah"), true);
assert_eq!(is_abbrev_match_to_string("Hb", "Hannah"), false);
}
}