From 615d44041d35ee1a08ddc66423f67a9d48f53ae9 Mon Sep 17 00:00:00 2001 From: Kevin Hoerr Date: Thu, 16 Apr 2026 11:37:21 -0400 Subject: Switch to distrobox as base for development --- src/app/mod.rs | 224 ++++++++++++++++++++++ src/app/store/mod.rs | 58 ++++++ src/app/store/sqlite.rs | 199 +++++++++++++++++++ src/db/20251114-0-init-schema.sql | 53 +++++ src/db/20251117-0-summary-view.sql | 65 +++++++ src/main.rs | 188 ++++++++++++++++++ src/main/resources/db/20251114-0-init-schema.sql | 53 ----- src/main/resources/db/20251117-0-summary-view.sql | 65 ------- src/main/rust/app.rs | 220 --------------------- src/main/rust/main.rs | 189 ------------------ src/main/rust/store/mod.rs | 58 ------ src/main/rust/store/sqlite.rs | 199 ------------------- src/main/rust/ui.rs | 134 ------------- src/ui.rs | 134 +++++++++++++ 14 files changed, 921 insertions(+), 918 deletions(-) create mode 100644 src/app/mod.rs create mode 100644 src/app/store/mod.rs create mode 100644 src/app/store/sqlite.rs create mode 100644 src/db/20251114-0-init-schema.sql create mode 100644 src/db/20251117-0-summary-view.sql create mode 100644 src/main.rs delete mode 100644 src/main/resources/db/20251114-0-init-schema.sql delete mode 100644 src/main/resources/db/20251117-0-summary-view.sql delete mode 100644 src/main/rust/app.rs delete mode 100644 src/main/rust/main.rs delete mode 100644 src/main/rust/store/mod.rs delete mode 100644 src/main/rust/store/sqlite.rs delete mode 100644 src/main/rust/ui.rs create mode 100644 src/ui.rs (limited to 'src') diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..6003640 --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,224 @@ +mod store; + +use store::{Context, Store}; +use miette::{Diagnostic, ErrReport, Result}; +use thiserror::Error; + +#[derive(Default)] +pub struct App { + context: Context, + error: Option, + exit: bool, +} + +#[derive(Diagnostic, Debug, Error, PartialEq)] +#[error("An overflow error occurred")] +#[diagnostic()] +pub struct OverflowError { + #[help] + pub _msg: String, +} + +impl App { + + /// Build context in App and pass it forward + pub fn init_context(&mut self) -> Result<&mut Self> { + let store = self.context.unwrap_store(); + + store.init()?; + Ok(self) + } + + pub fn get_exit_status(&self) -> bool { + self.exit + } + + pub fn exit(&mut self) -> Result<()> { + self.exit = true; + Ok(()) + } + + pub fn decrement_counter(&mut self) -> Result<()> { + let store = self.context.unwrap_store(); + let count = store.get_counter()?; + + if count == 0 { + Err(OverflowError { + _msg: "Cannot count below 0".into(), + } + .into()) + } else { + store.set_counter(count - 1)?; + Ok(()) + } + } + + pub fn increment_counter(&mut self) -> Result<()> { + let store = self.context.unwrap_store(); + let count = store.get_counter()?; + + if count == u8::MAX { + Err(OverflowError { + _msg: "Cannot count above u8::MAX".into(), + } + .into()) + } else { + store.set_counter(count + 1)?; + Ok(()) + } + } + + pub fn display_counter(&self) -> String { + self.context + .get_store() + .get_counter() + .map(|u| u.to_string()) + .unwrap_or("Error".into()) + } + + pub fn set_error(&mut self, error: ErrReport) -> Result<()> { + self.error = Some(error); + Ok(()) + } + + pub fn get_error(&self) -> &Option { + &self.error + } + + /// Clears any existing input error in the app state + pub fn clear_error(&mut self) -> Result<()> { + self.error = None; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use store::ContextError; + + fn get_count(app: &App) -> Result { + app.context.get_store().get_counter() + } + + #[test] + fn display_counter_inited() { + let mut app = App::default(); + assert!(app.init_context().is_ok()); + + let count = app.display_counter(); + + assert_eq!(count, "0"); + } + + #[test] + fn display_counter_noninited() { + let app = App::default(); + + let count = app.display_counter(); + + assert_eq!(count, "Error"); + } + + #[test] + fn increment_decrement() { + let mut app = App::default(); + assert!(app.init_context().is_ok()); + + let res = app.increment_counter(); + assert!(res.is_ok()); + assert_eq!(get_count(&app).unwrap(), 1); + + let res = app.decrement_counter(); + assert!(res.is_ok()); + assert_eq!(get_count(&app).unwrap(), 0); + } + + #[test] + fn increment_n() { + let mut app = App::default(); + assert!(app.init_context().is_ok()); + let n = 8; + + for i in 1..=n { + let res = app.increment_counter(); + assert!(res.is_ok()); + assert_eq!(get_count(&app).unwrap(), i); + } + assert_eq!(get_count(&app).unwrap(), n); + } + + #[test] + fn increment_n_decrement() { + let mut app = App::default(); + assert!(app.init_context().is_ok()); + let n = 8; + + for i in 1..=n { + let res = app.increment_counter(); + assert!(res.is_ok()); + assert_eq!(get_count(&app).unwrap(), i); + } + assert_eq!(get_count(&app).unwrap(), n); + + let res = app.decrement_counter(); + assert!(res.is_ok()); + assert_eq!(get_count(&app).unwrap(), n - 1); + } + + #[test] + fn exit() { + let mut app = App::default(); + assert!(app.init_context().is_ok()); + + let res = app.exit(); + + assert!(res.is_ok()); + assert!(app.exit); + } + + #[test] + fn decrement_counter_error() { + let mut app = App::default(); + assert!(app.init_context().is_ok()); + let res = app.decrement_counter(); + + assert!(res.is_err()); + let res_err = res.unwrap_err(); + let casted_val = res_err + .downcast_ref::() + .expect("something bad occurred"); + + let expected_err_msg: String = "Cannot count below 0".into(); + assert_eq!(casted_val._msg, expected_err_msg); + assert_eq!( + *casted_val, + OverflowError { + _msg: expected_err_msg, + }, + ); + } + + #[test] + fn increment_counter_error() { + let mut app = App::default(); + assert!(app.init_context().is_ok()); + assert!(app.context.unwrap_store().set_counter(u8::MAX).is_ok()); + let res = app.increment_counter(); + + assert!(res.is_err()); + let res_err = res.unwrap_err(); + let casted_val = res_err + .downcast_ref::() + .expect("something bad occurred"); + + let expected_err_msg: String = "Cannot count above u8::MAX".into(); + assert_eq!(casted_val._msg, expected_err_msg); + assert_eq!( + *casted_val, + OverflowError { + _msg: expected_err_msg, + }, + ); + } +} diff --git a/src/app/store/mod.rs b/src/app/store/mod.rs new file mode 100644 index 0000000..d038167 --- /dev/null +++ b/src/app/store/mod.rs @@ -0,0 +1,58 @@ +mod sqlite; + +use sqlite::{SQLiteStore}; +use miette::{Diagnostic, Result}; +use thiserror::Error; + +pub enum Context { + SQLite(SQLiteStore), +} + +#[derive(Debug, Diagnostic, Error)] +pub enum ContextError { + #[error("Data storage error")] + DataError(#[from] ::sqlite::Error), + #[error("Int conversion error")] + ConversionError(#[from] std::num::TryFromIntError), +} + +impl Default for Context { + fn default() -> Self { + Context::SQLite(SQLiteStore::default()) + } +} + +pub trait Store { + fn init(&mut self) -> Result<(), ContextError>; + fn get_counter(&self) -> Result; + fn set_counter(&mut self, new_count: u8) -> Result; +} + +impl Context { + pub fn unwrap_store(&mut self) -> &mut impl Store { + match self { + Context::SQLite(store) => store, + } + } + + pub fn get_store(&self) -> &impl Store { + match self { + Context::SQLite(store) => store, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn context_default() { + let context = Context::default(); + + let res = match context { + Context::SQLite(_) => true, + }; + assert!(res); + } +} diff --git a/src/app/store/sqlite.rs b/src/app/store/sqlite.rs new file mode 100644 index 0000000..4f5f8f2 --- /dev/null +++ b/src/app/store/sqlite.rs @@ -0,0 +1,199 @@ +use super::{ContextError, Store}; +use miette::Result; +use sqlite::Connection; + +pub struct SQLiteStore { + location: String, + connection: Option, +} + +impl SQLiteStore { + /// Wrap connection in a Result to make it easier to unwrap later + fn get_connection(&self) -> Result<&Connection, sqlite::Error> { + match &self.connection { + Some(conn) => Ok(conn), + None => Err(sqlite::Error { + code: None, + message: Some("Uninitialized".into()), + }), + } + } + + fn create_table(&mut self) -> Result<(), sqlite::Error> { + let conn = self.get_connection()?; + let mut statement = conn.prepare("CREATE TABLE RATAT ( COUNTER SMALLINT )")?; + statement.next()?; + Ok(()) + } + + fn insert_counter(&mut self, counter: i64) -> Result<(), sqlite::Error> { + let conn = self.get_connection()?; + let mut statement = conn.prepare("INSERT INTO RATAT ( COUNTER ) VALUES ( :counter )")?; + statement.bind((":counter", counter))?; + statement.next()?; + Ok(()) + } + + fn select_counter(&self) -> Result { + let conn = self.get_connection()?; + let mut statement = conn.prepare("SELECT COUNTER FROM RATAT")?; + statement.next()?; + statement.read("COUNTER") + } + + fn update_counter(&mut self, counter: i64) -> Result<(), sqlite::Error> { + let conn = self.get_connection()?; + let mut statement = conn.prepare("UPDATE RATAT SET COUNTER = :counter")?; + statement.bind((":counter", counter))?; + statement.next()?; + Ok(()) + } +} + +impl Default for SQLiteStore { + fn default() -> Self { + SQLiteStore { + location: ":memory:".into(), + connection: Option::None, + } + } +} + +impl Store for SQLiteStore { + /// Init SQLite for counting. Creates `RATAT` table with single row, with + /// counter set to `0` + fn init(&mut self) -> Result<(), ContextError> { + let conn_path = self.location.clone(); + let conn = sqlite::open(conn_path)?; + self.connection = Option::Some(conn); + + self.create_table()?; + self.insert_counter(0)?; + + Ok(()) + } + + /// Selects counter from `RATAT` table. Since sqlite doesn't scrutinize the + /// value and we have a more restricted datatype (i64 -> u8), we use + /// `try_from` which may produce an error result. + fn get_counter(&self) -> Result { + let result = self.select_counter()?; + + let count = u8::try_from(result)?; + Ok(count) + } + + /// Updates row with counter value on `RATAT` table + fn set_counter(&mut self, new_count: u8) -> Result { + self.update_counter(i64::from(new_count))?; + + Ok(new_count) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Practically, the `Store` trait is what we interface with externally, and + // provides the more general `Result` interface we want to work with so + // that's what we test here + + #[test] + fn default() { + let context = SQLiteStore::default(); + + assert_eq!(context.location, ":memory:"); + assert!(context.connection.is_none()); + } + + #[test] + fn init() { + let mut context = SQLiteStore::default(); + + let res = context.init(); + + assert!(res.is_ok()); + assert!(context.connection.is_some()); + } + + #[test] + fn get_counter() { + let mut context = SQLiteStore::default(); + assert!(context.init().is_ok()); + + let res = context.get_counter(); + + assert!(res.is_ok()); + assert_eq!(res.unwrap(), 0); + } + + #[test] + fn get_counter_error() { + let context = SQLiteStore::default(); + // don't initialize + + let res = context.get_counter(); + + assert!(res.is_err()); + } + + #[test] + fn get_counter_outside_range() { + let mut context = SQLiteStore::default(); + assert!(context.init().is_ok()); + + let new_value = -1; + if let Some(conn) = &context.connection { + let mut statement = conn.prepare("UPDATE RATAT SET COUNTER = ?").unwrap(); + statement.bind((1, new_value)).unwrap(); + statement.next().unwrap(); + } + let res = context.get_counter(); + + assert!(res.is_err()); + } + + #[test] + fn set_counter() { + let mut context = SQLiteStore::default(); + assert!(context.init().is_ok()); + + let expected = 240; + let res = context.set_counter(expected); + + assert!(res.is_ok()); + assert_eq!(res.unwrap(), expected); + } + + #[test] + fn set_counter_error() { + let mut context = SQLiteStore::default(); + // don't initialize + + let res = context.set_counter(1); + + assert!(res.is_err()); + } + + #[test] + fn set_and_get() { + let mut context = SQLiteStore::default(); + assert!(context.init().is_ok()); + + let count_0 = context.get_counter().unwrap(); + + assert_eq!(count_0, 0); + + let new_num = 244; + let res = context.set_counter(new_num); + + assert!(res.is_ok()); + assert_eq!(res.unwrap(), new_num); + + let count_1 = context.get_counter().unwrap(); + + assert_eq!(count_1, new_num); + assert_ne!(count_0, count_1); + } +} diff --git a/src/db/20251114-0-init-schema.sql b/src/db/20251114-0-init-schema.sql new file mode 100644 index 0000000..886a961 --- /dev/null +++ b/src/db/20251114-0-init-schema.sql @@ -0,0 +1,53 @@ +-- Equity tracker schema +-- SQLite does not control types so directly; but it makes it more portable to a different SQL server with specificity + +-- enum as table - there may be other equity types, does not affect views directly +create table equity_type +( + equity_type_name VARCHAR(20) PRIMARY KEY NOT NULL +); + +INSERT INTO equity_type (equity_type_name) VALUES + ('STOCK'), + ('ETF'), + ('CRYPTO'); + +-- deletion of equity_type does not cascade - equity_type is required for equity_symbol, which is required for equity_change_event, which is required for fiduciary_account. +-- equity_symbol, equity_type would never be deleted, but they could be cleaned up if they are orphaned records. +create table equity_symbol +( + equity_symbol_id SMALLINT PRIMARY KEY NOT NULL, + equity_symbol_name VARCHAR(16) NOT NULL, + equity_symbol_type VARCHAR(20) NOT NULL + REFERENCES equity_type(equity_type_name), + equity_symbol_managing_company VARCHAR(96) NOT NULL, + equity_symbol_created_timestamp TIMESTAMP NOT NULL +); + +create table fiduciary_account +( + fiduciary_account_id TINYINT PRIMARY KEY NOT NULL, + fiduciary_account_number UNSIGNED BIG INT NOT NULL, + fiduciary_account_name NVARCHAR(96) NOT NULL, + fiduciary_account_description NVARCHAR(512), + fiduciary_account_created_timestamp TIMESTAMP NOT NULL +); + +-- deletion of fiduciary_account row cascades - an account precludes change events. +-- deletion of equity_symbol row does not cascade - all events should be recorded for an account, and all events must have symbols +create table equity_change_event +( + equity_change_event_id UNSIGNED BIG INT PRIMARY KEY NOT NULL, + fiduciary_account_id TINYINT NOT NULL + REFERENCES fiduciary_account(fiduciary_account_id) + ON DELETE CASCADE, + equity_symbol_id SMALLINT NOT NULL + REFERENCES equity_symbol(equity_symbol_id), + equity_change_quantity DECIMAL(20, 8) NOT NULL, + equity_change_cost_basis_usd DECIMAL(14, 2) NOT NULL, + equity_change_type VARCHAR(20) NOT NULL + CHECK (equity_change_type IN ('BUY', 'SELL')), -- This is essentially '+' or '-' + equity_change_timestamp TIMESTAMP NOT NULL, + equity_change_type_score NUMERIC AS (CASE WHEN equity_change_type = 'BUY' THEN 1.0 ELSE -1.0 END) STORED, + equity_change_est_price DECIMAL(14, 2) AS (equity_change_cost_basis_usd / equity_change_quantity) STORED +); diff --git a/src/db/20251117-0-summary-view.sql b/src/db/20251117-0-summary-view.sql new file mode 100644 index 0000000..a4e8004 --- /dev/null +++ b/src/db/20251117-0-summary-view.sql @@ -0,0 +1,65 @@ +-- Yucky slow view, since it's sub-querying for each of the summation fields +-- AND has a subquery on the inner join to find the right symbols. Anyways it +-- should preview relative strength of treating equity transaction data as +-- immutable. Any application on top of this should get the full list of +-- records so it can construct helpful views of summations based on the history +-- of changes, and/or show price data over time +CREATE VIEW account_symbol_summary_view +AS +SELECT + acc.fiduciary_account_id, + acc.fiduciary_account_name, + acc.fiduciary_account_description, + acc.fiduciary_account_number, + acc.fiduciary_account_created_timestamp, + sym.equity_symbol_id, + sym.equity_symbol_type, + sym.equity_symbol_name, + sym.equity_symbol_managing_company, + (SELECT + SUM(ev.equity_change_quantity * ev.equity_change_type_score) + FROM equity_change_event ev + WHERE ev.fiduciary_account_id = acc.fiduciary_account_id + AND ev.equity_symbol_id = sym.equity_symbol_id + ) AS account_symbol_quantity, + (SELECT + printf( '%.2f', SUM(ev.equity_change_cost_basis_usd * ev.equity_change_type_score) / SUM(ev.equity_change_quantity * ev.equity_change_type_score)) + FROM equity_change_event ev + WHERE ev.fiduciary_account_id = acc.fiduciary_account_id + AND ev.equity_symbol_id = sym.equity_symbol_id + ) AS account_symbol_cost_basis_price, + (SELECT + printf( '%.2f', SUM(ev.equity_change_cost_basis_usd * ev.equity_change_type_score)) + FROM equity_change_event ev + WHERE ev.fiduciary_account_id = acc.fiduciary_account_id + AND ev.equity_symbol_id = sym.equity_symbol_id + ) AS account_symbol_cost_basis_total_usd, + (SELECT + printf( '%.2f', SUM(ev.equity_change_quantity) * ( + SELECT + iev.equity_change_est_price + FROM equity_change_event iev + WHERE iev.fiduciary_account_id = acc.fiduciary_account_id + AND iev.equity_symbol_id = sym.equity_symbol_id + ORDER BY iev.equity_change_timestamp DESC + LIMIT 1 + )) + FROM equity_change_event ev + WHERE ev.fiduciary_account_id = acc.fiduciary_account_id + AND ev.equity_symbol_id = sym.equity_symbol_id + ) AS account_symbol_last_effective_price, + (SELECT + ev.equity_change_timestamp + FROM equity_change_event ev + WHERE ev.fiduciary_account_id = acc.fiduciary_account_id + AND ev.equity_symbol_id = sym.equity_symbol_id + ORDER BY ev.equity_change_timestamp DESC + LIMIT 1 + ) AS account_symbol_last_update_timestamp +FROM fiduciary_account acc +INNER JOIN equity_symbol sym ON EXISTS( + SELECT * + FROM equity_change_event e + WHERE e.fiduciary_account_id = acc.fiduciary_account_id + AND e.equity_symbol_id = sym.equity_symbol_id +); \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..bb63c86 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,188 @@ +mod app; +mod ui; + +use app::App; +use miette::{IntoDiagnostic, Result, miette}; +use ratatui::{Terminal, backend::Backend}; + +use ratatui::crossterm::{ + event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, + }, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::prelude::CrosstermBackend; + +#[cfg(not(tarpaulin_include))] +fn main() -> Result<()> { + enable_raw_mode().into_diagnostic()?; + let mut stdout = std::io::stdout(); + + execute!(stdout, EnterAlternateScreen, EnableMouseCapture).into_diagnostic()?; + + let backend = CrosstermBackend::new(stdout); + let mut term = Terminal::new(backend).into_diagnostic()?; + + let mut app = App::default(); + app.init_context()?; + + // could be error, so don't unwrap; let term restore first + let run = run_app(&mut term, &mut app); + + // restore terminal + disable_raw_mode().into_diagnostic()?; + execute!( + term.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + ) + .into_diagnostic()?; + term.show_cursor().into_diagnostic()?; + run?; + Ok(()) +} + +#[cfg(not(tarpaulin_include))] +fn run_app(term: &mut Terminal, app: &mut App) -> Result<()> { + while !app.get_exit_status() { + term.draw(|frame| ui::draw(frame, app)) + .map_err(|_| miette!("Unrecoverable error occurred"))?; + + match event::read().into_diagnostic()? { + // it's important to check that the event is a key press event as + // crossterm also emits key release and repeat events on Windows. + Event::Key(key_event) if key_event.kind == KeyEventKind::Press => { + // if error occurred during input processing, set error in the app state + match handle_key_event(app, key_event) { + Err(e) => app.set_error(e)?, + _ => {} + } + } + _ => {} + }; + } + Ok(()) +} + +/// Process user input and run associated app functions +fn handle_key_event(app: &mut App, key_event: KeyEvent) -> Result<()> { + match key_event.code { + KeyCode::Char('q') => app.exit()?, + KeyCode::Left => app.decrement_counter()?, + KeyCode::Right => app.increment_counter()?, + _ => {} + }; + // any new key event, we should clear the existing error + app.clear_error()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + + use super::*; + use app::OverflowError; + + #[test] + fn handle_key_event_inc_dec() { + let mut app = App::default(); + assert!(app.init_context().is_ok()); + let res = handle_key_event(&mut app, KeyCode::Right.into()); + assert!(res.is_ok()); + assert_eq!(app.display_counter(), "1"); + + let res = handle_key_event(&mut app, KeyCode::Left.into()); + assert!(res.is_ok()); + assert_eq!(app.display_counter(), "0"); + } + + #[test] + fn handle_key_event_inc_n() { + let mut app = App::default(); + assert!(app.init_context().is_ok()); + let n = 8; + + for i in 1..=n { + let res = handle_key_event(&mut app, KeyCode::Right.into()); + assert!(res.is_ok()); + assert_eq!(app.display_counter(), i.to_string()); + } + assert_eq!(app.display_counter(), n.to_string()); + } + + #[test] + fn handle_key_event_inc_n_dec() { + let mut app = App::default(); + assert!(app.init_context().is_ok()); + let n = 8; + + for i in 1..=n { + let res = handle_key_event(&mut app, KeyCode::Right.into()); + assert!(res.is_ok()); + assert_eq!(app.display_counter(), i.to_string()); + } + assert_eq!(app.display_counter(), n.to_string()); + + let res = handle_key_event(&mut app, KeyCode::Left.into()); + assert!(res.is_ok()); + assert_eq!(app.display_counter(), (n - 1).to_string()); + } + + #[test] + fn handle_key_event_exit() { + let mut app = App::default(); + assert!(app.init_context().is_ok()); + let res = handle_key_event(&mut app, KeyCode::Char('q').into()); + assert!(res.is_ok()); + assert!(app.get_exit_status()); + } + + #[test] + fn handle_key_event_dec_error() { + let mut app = App::default(); + assert!(app.init_context().is_ok()); + let res = handle_key_event(&mut app, KeyCode::Left.into()); + + assert!(res.is_err()); + let res_err = res.unwrap_err(); + let casted_val = res_err + .downcast_ref::() + .expect("something bad occurred"); + + let expected_err_msg: String = "Cannot count below 0".into(); + assert_eq!(casted_val._msg, expected_err_msg); + assert_eq!( + *casted_val, + OverflowError { + _msg: expected_err_msg, + }, + ); + } + + #[test] + fn handle_key_event_inc_error() { + let mut app = App::default(); + assert!(app.init_context().is_ok()); + // no pub set_counter exposed, so we manually set this + for _ in 0..u8::MAX { + assert!(app.increment_counter().is_ok()); + } + let res = handle_key_event(&mut app, KeyCode::Right.into()); + + assert!(res.is_err()); + let res_err = res.unwrap_err(); + let casted_val = res_err + .downcast_ref::() + .expect("something bad occurred"); + + let expected_err_msg: String = "Cannot count above u8::MAX".into(); + assert_eq!(casted_val._msg, expected_err_msg); + assert_eq!( + *casted_val, + OverflowError { + _msg: expected_err_msg, + }, + ); + } +} diff --git a/src/main/resources/db/20251114-0-init-schema.sql b/src/main/resources/db/20251114-0-init-schema.sql deleted file mode 100644 index 886a961..0000000 --- a/src/main/resources/db/20251114-0-init-schema.sql +++ /dev/null @@ -1,53 +0,0 @@ --- Equity tracker schema --- SQLite does not control types so directly; but it makes it more portable to a different SQL server with specificity - --- enum as table - there may be other equity types, does not affect views directly -create table equity_type -( - equity_type_name VARCHAR(20) PRIMARY KEY NOT NULL -); - -INSERT INTO equity_type (equity_type_name) VALUES - ('STOCK'), - ('ETF'), - ('CRYPTO'); - --- deletion of equity_type does not cascade - equity_type is required for equity_symbol, which is required for equity_change_event, which is required for fiduciary_account. --- equity_symbol, equity_type would never be deleted, but they could be cleaned up if they are orphaned records. -create table equity_symbol -( - equity_symbol_id SMALLINT PRIMARY KEY NOT NULL, - equity_symbol_name VARCHAR(16) NOT NULL, - equity_symbol_type VARCHAR(20) NOT NULL - REFERENCES equity_type(equity_type_name), - equity_symbol_managing_company VARCHAR(96) NOT NULL, - equity_symbol_created_timestamp TIMESTAMP NOT NULL -); - -create table fiduciary_account -( - fiduciary_account_id TINYINT PRIMARY KEY NOT NULL, - fiduciary_account_number UNSIGNED BIG INT NOT NULL, - fiduciary_account_name NVARCHAR(96) NOT NULL, - fiduciary_account_description NVARCHAR(512), - fiduciary_account_created_timestamp TIMESTAMP NOT NULL -); - --- deletion of fiduciary_account row cascades - an account precludes change events. --- deletion of equity_symbol row does not cascade - all events should be recorded for an account, and all events must have symbols -create table equity_change_event -( - equity_change_event_id UNSIGNED BIG INT PRIMARY KEY NOT NULL, - fiduciary_account_id TINYINT NOT NULL - REFERENCES fiduciary_account(fiduciary_account_id) - ON DELETE CASCADE, - equity_symbol_id SMALLINT NOT NULL - REFERENCES equity_symbol(equity_symbol_id), - equity_change_quantity DECIMAL(20, 8) NOT NULL, - equity_change_cost_basis_usd DECIMAL(14, 2) NOT NULL, - equity_change_type VARCHAR(20) NOT NULL - CHECK (equity_change_type IN ('BUY', 'SELL')), -- This is essentially '+' or '-' - equity_change_timestamp TIMESTAMP NOT NULL, - equity_change_type_score NUMERIC AS (CASE WHEN equity_change_type = 'BUY' THEN 1.0 ELSE -1.0 END) STORED, - equity_change_est_price DECIMAL(14, 2) AS (equity_change_cost_basis_usd / equity_change_quantity) STORED -); diff --git a/src/main/resources/db/20251117-0-summary-view.sql b/src/main/resources/db/20251117-0-summary-view.sql deleted file mode 100644 index a4e8004..0000000 --- a/src/main/resources/db/20251117-0-summary-view.sql +++ /dev/null @@ -1,65 +0,0 @@ --- Yucky slow view, since it's sub-querying for each of the summation fields --- AND has a subquery on the inner join to find the right symbols. Anyways it --- should preview relative strength of treating equity transaction data as --- immutable. Any application on top of this should get the full list of --- records so it can construct helpful views of summations based on the history --- of changes, and/or show price data over time -CREATE VIEW account_symbol_summary_view -AS -SELECT - acc.fiduciary_account_id, - acc.fiduciary_account_name, - acc.fiduciary_account_description, - acc.fiduciary_account_number, - acc.fiduciary_account_created_timestamp, - sym.equity_symbol_id, - sym.equity_symbol_type, - sym.equity_symbol_name, - sym.equity_symbol_managing_company, - (SELECT - SUM(ev.equity_change_quantity * ev.equity_change_type_score) - FROM equity_change_event ev - WHERE ev.fiduciary_account_id = acc.fiduciary_account_id - AND ev.equity_symbol_id = sym.equity_symbol_id - ) AS account_symbol_quantity, - (SELECT - printf( '%.2f', SUM(ev.equity_change_cost_basis_usd * ev.equity_change_type_score) / SUM(ev.equity_change_quantity * ev.equity_change_type_score)) - FROM equity_change_event ev - WHERE ev.fiduciary_account_id = acc.fiduciary_account_id - AND ev.equity_symbol_id = sym.equity_symbol_id - ) AS account_symbol_cost_basis_price, - (SELECT - printf( '%.2f', SUM(ev.equity_change_cost_basis_usd * ev.equity_change_type_score)) - FROM equity_change_event ev - WHERE ev.fiduciary_account_id = acc.fiduciary_account_id - AND ev.equity_symbol_id = sym.equity_symbol_id - ) AS account_symbol_cost_basis_total_usd, - (SELECT - printf( '%.2f', SUM(ev.equity_change_quantity) * ( - SELECT - iev.equity_change_est_price - FROM equity_change_event iev - WHERE iev.fiduciary_account_id = acc.fiduciary_account_id - AND iev.equity_symbol_id = sym.equity_symbol_id - ORDER BY iev.equity_change_timestamp DESC - LIMIT 1 - )) - FROM equity_change_event ev - WHERE ev.fiduciary_account_id = acc.fiduciary_account_id - AND ev.equity_symbol_id = sym.equity_symbol_id - ) AS account_symbol_last_effective_price, - (SELECT - ev.equity_change_timestamp - FROM equity_change_event ev - WHERE ev.fiduciary_account_id = acc.fiduciary_account_id - AND ev.equity_symbol_id = sym.equity_symbol_id - ORDER BY ev.equity_change_timestamp DESC - LIMIT 1 - ) AS account_symbol_last_update_timestamp -FROM fiduciary_account acc -INNER JOIN equity_symbol sym ON EXISTS( - SELECT * - FROM equity_change_event e - WHERE e.fiduciary_account_id = acc.fiduciary_account_id - AND e.equity_symbol_id = sym.equity_symbol_id -); \ No newline at end of file diff --git a/src/main/rust/app.rs b/src/main/rust/app.rs deleted file mode 100644 index 2ed7c28..0000000 --- a/src/main/rust/app.rs +++ /dev/null @@ -1,220 +0,0 @@ -use crate::store::{Context, Store}; -use miette::{Diagnostic, ErrReport, Result}; -use thiserror::Error; - -#[derive(Default)] -pub struct App { - context: Context, - error: Option, - exit: bool, -} - -#[derive(Diagnostic, Debug, Error, PartialEq)] -#[error("An overflow error occurred")] -#[diagnostic()] -pub struct OverflowError { - #[help] - pub _msg: String, -} - -impl App { - /// Build context in App and pass it forward - pub fn init_context(&mut self) -> Result<&mut Self> { - let store = self.context.unwrap_store(); - - store.init()?; - Ok(self) - } - - pub fn get_exit_status(&self) -> bool { - self.exit - } - - pub fn exit(&mut self) -> Result<()> { - self.exit = true; - Ok(()) - } - - pub fn decrement_counter(&mut self) -> Result<()> { - let store = self.context.unwrap_store(); - let count = store.get_counter()?; - - if count == 0 { - Err(OverflowError { - _msg: "Cannot count below 0".into(), - } - .into()) - } else { - store.set_counter(count - 1)?; - Ok(()) - } - } - - pub fn increment_counter(&mut self) -> Result<()> { - let store = self.context.unwrap_store(); - let count = store.get_counter()?; - - if count == u8::MAX { - Err(OverflowError { - _msg: "Cannot count above u8::MAX".into(), - } - .into()) - } else { - store.set_counter(count + 1)?; - Ok(()) - } - } - - pub fn display_counter(&self) -> String { - self.context - .get_store() - .get_counter() - .map(|u| u.to_string()) - .unwrap_or("Error".into()) - } - - pub fn set_error(&mut self, error: ErrReport) -> Result<()> { - self.error = Some(error); - Ok(()) - } - - pub fn get_error(&self) -> &Option { - &self.error - } - - pub fn clear_error(&mut self) -> Result<()> { - self.error = None; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::store::ContextError; - - fn get_count(app: &App) -> Result { - app.context.get_store().get_counter() - } - - #[test] - fn display_counter_inited() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - - let count = app.display_counter(); - - assert_eq!(count, "0"); - } - - #[test] - fn display_counter_noninited() { - let app = App::default(); - - let count = app.display_counter(); - - assert_eq!(count, "Error"); - } - - #[test] - fn increment_decrement() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - - let res = app.increment_counter(); - assert!(res.is_ok()); - assert_eq!(get_count(&app).unwrap(), 1); - - let res = app.decrement_counter(); - assert!(res.is_ok()); - assert_eq!(get_count(&app).unwrap(), 0); - } - - #[test] - fn increment_n() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - let n = 8; - - for i in 1..=n { - let res = app.increment_counter(); - assert!(res.is_ok()); - assert_eq!(get_count(&app).unwrap(), i); - } - assert_eq!(get_count(&app).unwrap(), n); - } - - #[test] - fn increment_n_decrement() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - let n = 8; - - for i in 1..=n { - let res = app.increment_counter(); - assert!(res.is_ok()); - assert_eq!(get_count(&app).unwrap(), i); - } - assert_eq!(get_count(&app).unwrap(), n); - - let res = app.decrement_counter(); - assert!(res.is_ok()); - assert_eq!(get_count(&app).unwrap(), n - 1); - } - - #[test] - fn exit() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - - let res = app.exit(); - - assert!(res.is_ok()); - assert!(app.exit); - } - - #[test] - fn decrement_counter_error() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - let res = app.decrement_counter(); - - assert!(res.is_err()); - let res_err = res.unwrap_err(); - let casted_val = res_err - .downcast_ref::() - .expect("something bad occurred"); - - let expected_err_msg: String = "Cannot count below 0".into(); - assert_eq!(casted_val._msg, expected_err_msg); - assert_eq!( - *casted_val, - OverflowError { - _msg: expected_err_msg, - }, - ); - } - - #[test] - fn increment_counter_error() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - assert!(app.context.unwrap_store().set_counter(u8::MAX).is_ok()); - let res = app.increment_counter(); - - assert!(res.is_err()); - let res_err = res.unwrap_err(); - let casted_val = res_err - .downcast_ref::() - .expect("something bad occurred"); - - let expected_err_msg: String = "Cannot count above u8::MAX".into(); - assert_eq!(casted_val._msg, expected_err_msg); - assert_eq!( - *casted_val, - OverflowError { - _msg: expected_err_msg, - }, - ); - } -} diff --git a/src/main/rust/main.rs b/src/main/rust/main.rs deleted file mode 100644 index 124ce81..0000000 --- a/src/main/rust/main.rs +++ /dev/null @@ -1,189 +0,0 @@ -mod app; -pub mod store; -mod ui; - -use app::App; -use miette::{IntoDiagnostic, Result, miette}; -use ratatui::{Terminal, backend::Backend}; - -use ratatui::crossterm::{ - event::{ - self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, - }, - execute, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, -}; -use ratatui::prelude::CrosstermBackend; - -#[cfg(not(tarpaulin_include))] -fn main() -> Result<()> { - enable_raw_mode().into_diagnostic()?; - let mut stdout = std::io::stdout(); - - execute!(stdout, EnterAlternateScreen, EnableMouseCapture).into_diagnostic()?; - - let backend = CrosstermBackend::new(stdout); - let mut term = Terminal::new(backend).into_diagnostic()?; - - let mut app = App::default(); - app.init_context()?; - - // could be error, so don't unwrap; let term restore first - let run = run_app(&mut term, &mut app); - - // restore terminal - disable_raw_mode().into_diagnostic()?; - execute!( - term.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - ) - .into_diagnostic()?; - term.show_cursor().into_diagnostic()?; - run?; - Ok(()) -} - -#[cfg(not(tarpaulin_include))] -fn run_app(term: &mut Terminal, app: &mut App) -> Result<()> { - while !app.get_exit_status() { - term.draw(|frame| ui::draw(frame, app)) - .map_err(|_| miette!("Unrecoverable error occurred"))?; - - match event::read().into_diagnostic()? { - // it's important to check that the event is a key press event as - // crossterm also emits key release and repeat events on Windows. - Event::Key(key_event) if key_event.kind == KeyEventKind::Press => { - // if error occurred during input processing, set error in the app state - match handle_key_event(app, key_event) { - Err(e) => app.set_error(e)?, - _ => {} - } - } - _ => {} - }; - } - Ok(()) -} - -/// Process user input and run associated app functions -fn handle_key_event(app: &mut App, key_event: KeyEvent) -> Result<()> { - match key_event.code { - KeyCode::Char('q') => app.exit()?, - KeyCode::Left => app.decrement_counter()?, - KeyCode::Right => app.increment_counter()?, - _ => {} - }; - // any new key event, we should clear the existing error - app.clear_error()?; - Ok(()) -} - -#[cfg(test)] -mod tests { - - use super::*; - use app::OverflowError; - - #[test] - fn handle_key_event_inc_dec() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - let res = handle_key_event(&mut app, KeyCode::Right.into()); - assert!(res.is_ok()); - assert_eq!(app.display_counter(), "1"); - - let res = handle_key_event(&mut app, KeyCode::Left.into()); - assert!(res.is_ok()); - assert_eq!(app.display_counter(), "0"); - } - - #[test] - fn handle_key_event_inc_n() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - let n = 8; - - for i in 1..=n { - let res = handle_key_event(&mut app, KeyCode::Right.into()); - assert!(res.is_ok()); - assert_eq!(app.display_counter(), i.to_string()); - } - assert_eq!(app.display_counter(), n.to_string()); - } - - #[test] - fn handle_key_event_inc_n_dec() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - let n = 8; - - for i in 1..=n { - let res = handle_key_event(&mut app, KeyCode::Right.into()); - assert!(res.is_ok()); - assert_eq!(app.display_counter(), i.to_string()); - } - assert_eq!(app.display_counter(), n.to_string()); - - let res = handle_key_event(&mut app, KeyCode::Left.into()); - assert!(res.is_ok()); - assert_eq!(app.display_counter(), (n - 1).to_string()); - } - - #[test] - fn handle_key_event_exit() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - let res = handle_key_event(&mut app, KeyCode::Char('q').into()); - assert!(res.is_ok()); - assert!(app.get_exit_status()); - } - - #[test] - fn handle_key_event_dec_error() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - let res = handle_key_event(&mut app, KeyCode::Left.into()); - - assert!(res.is_err()); - let res_err = res.unwrap_err(); - let casted_val = res_err - .downcast_ref::() - .expect("something bad occurred"); - - let expected_err_msg: String = "Cannot count below 0".into(); - assert_eq!(casted_val._msg, expected_err_msg); - assert_eq!( - *casted_val, - OverflowError { - _msg: expected_err_msg, - }, - ); - } - - #[test] - fn handle_key_event_inc_error() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - // no pub set_counter exposed, so we manually set this - for _ in 0..u8::MAX { - assert!(app.increment_counter().is_ok()); - } - let res = handle_key_event(&mut app, KeyCode::Right.into()); - - assert!(res.is_err()); - let res_err = res.unwrap_err(); - let casted_val = res_err - .downcast_ref::() - .expect("something bad occurred"); - - let expected_err_msg: String = "Cannot count above u8::MAX".into(); - assert_eq!(casted_val._msg, expected_err_msg); - assert_eq!( - *casted_val, - OverflowError { - _msg: expected_err_msg, - }, - ); - } -} diff --git a/src/main/rust/store/mod.rs b/src/main/rust/store/mod.rs deleted file mode 100644 index 2cce3e9..0000000 --- a/src/main/rust/store/mod.rs +++ /dev/null @@ -1,58 +0,0 @@ -mod sqlite; - -use crate::store::sqlite::SQLiteStore; -use miette::{Diagnostic, Result}; -use thiserror::Error; - -pub enum Context { - SQLite(SQLiteStore), -} - -#[derive(Debug, Diagnostic, Error)] -pub enum ContextError { - #[error("Data storage error")] - DataError(#[from] ::sqlite::Error), - #[error("Int conversion error")] - ConversionError(#[from] std::num::TryFromIntError), -} - -impl Default for Context { - fn default() -> Self { - Context::SQLite(SQLiteStore::default()) - } -} - -pub trait Store { - fn init(&mut self) -> Result<(), ContextError>; - fn get_counter(&self) -> Result; - fn set_counter(&mut self, new_count: u8) -> Result; -} - -impl Context { - pub fn unwrap_store(&mut self) -> &mut impl Store { - match self { - Context::SQLite(store) => store, - } - } - - pub fn get_store(&self) -> &impl Store { - match self { - Context::SQLite(store) => store, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn context_default() { - let context = Context::default(); - - let res = match context { - Context::SQLite(_) => true, - }; - assert!(res); - } -} diff --git a/src/main/rust/store/sqlite.rs b/src/main/rust/store/sqlite.rs deleted file mode 100644 index 6647556..0000000 --- a/src/main/rust/store/sqlite.rs +++ /dev/null @@ -1,199 +0,0 @@ -use crate::store::{ContextError, Store}; -use miette::Result; -use sqlite::Connection; - -pub struct SQLiteStore { - location: String, - connection: Option, -} - -impl SQLiteStore { - /// Wrap connection in a Result to make it easier to unwrap later - fn get_connection(&self) -> Result<&Connection, sqlite::Error> { - match &self.connection { - Some(conn) => Ok(conn), - None => Err(sqlite::Error { - code: None, - message: Some("Uninitialized".into()), - }), - } - } - - fn create_table(&mut self) -> Result<(), sqlite::Error> { - let conn = self.get_connection()?; - let mut statement = conn.prepare("CREATE TABLE RATAT ( COUNTER SMALLINT )")?; - statement.next()?; - Ok(()) - } - - fn insert_counter(&mut self, counter: i64) -> Result<(), sqlite::Error> { - let conn = self.get_connection()?; - let mut statement = conn.prepare("INSERT INTO RATAT ( COUNTER ) VALUES ( :counter )")?; - statement.bind((":counter", counter))?; - statement.next()?; - Ok(()) - } - - fn select_counter(&self) -> Result { - let conn = self.get_connection()?; - let mut statement = conn.prepare("SELECT COUNTER FROM RATAT")?; - statement.next()?; - statement.read("COUNTER") - } - - fn update_counter(&mut self, counter: i64) -> Result<(), sqlite::Error> { - let conn = self.get_connection()?; - let mut statement = conn.prepare("UPDATE RATAT SET COUNTER = :counter")?; - statement.bind((":counter", counter))?; - statement.next()?; - Ok(()) - } -} - -impl Default for SQLiteStore { - fn default() -> Self { - SQLiteStore { - location: ":memory:".into(), - connection: Option::None, - } - } -} - -impl Store for SQLiteStore { - /// Init SQLite for counting. Creates `RATAT` table with single row, with - /// counter set to `0` - fn init(&mut self) -> Result<(), ContextError> { - let conn_path = self.location.clone(); - let conn = sqlite::open(conn_path)?; - self.connection = Option::Some(conn); - - self.create_table()?; - self.insert_counter(0)?; - - Ok(()) - } - - /// Selects counter from `RATAT` table. Since sqlite doesn't scrutinize the - /// value and we have a more restricted datatype (i64 -> u8), we use - /// `try_from` which may produce an error result. - fn get_counter(&self) -> Result { - let result = self.select_counter()?; - - let count = u8::try_from(result)?; - Ok(count) - } - - /// Updates row with counter value on `RATAT` table - fn set_counter(&mut self, new_count: u8) -> Result { - self.update_counter(i64::from(new_count))?; - - Ok(new_count) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // Practically, the `Store` trait is what we interface with externally, and - // provides the more general `Result` interface we want to work with so - // that's what we test here - - #[test] - fn default() { - let context = SQLiteStore::default(); - - assert_eq!(context.location, ":memory:"); - assert!(context.connection.is_none()); - } - - #[test] - fn init() { - let mut context = SQLiteStore::default(); - - let res = context.init(); - - assert!(res.is_ok()); - assert!(context.connection.is_some()); - } - - #[test] - fn get_counter() { - let mut context = SQLiteStore::default(); - assert!(context.init().is_ok()); - - let res = context.get_counter(); - - assert!(res.is_ok()); - assert_eq!(res.unwrap(), 0); - } - - #[test] - fn get_counter_error() { - let context = SQLiteStore::default(); - // don't initialize - - let res = context.get_counter(); - - assert!(res.is_err()); - } - - #[test] - fn get_counter_outside_range() { - let mut context = SQLiteStore::default(); - assert!(context.init().is_ok()); - - let new_value = -1; - if let Some(conn) = &context.connection { - let mut statement = conn.prepare("UPDATE RATAT SET COUNTER = ?").unwrap(); - statement.bind((1, new_value)).unwrap(); - statement.next().unwrap(); - } - let res = context.get_counter(); - - assert!(res.is_err()); - } - - #[test] - fn set_counter() { - let mut context = SQLiteStore::default(); - assert!(context.init().is_ok()); - - let expected = 240; - let res = context.set_counter(expected); - - assert!(res.is_ok()); - assert_eq!(res.unwrap(), expected); - } - - #[test] - fn set_counter_error() { - let mut context = SQLiteStore::default(); - // don't initialize - - let res = context.set_counter(1); - - assert!(res.is_err()); - } - - #[test] - fn set_and_get() { - let mut context = SQLiteStore::default(); - assert!(context.init().is_ok()); - - let count_0 = context.get_counter().unwrap(); - - assert_eq!(count_0, 0); - - let new_num = 244; - let res = context.set_counter(new_num); - - assert!(res.is_ok()); - assert_eq!(res.unwrap(), new_num); - - let count_1 = context.get_counter().unwrap(); - - assert_eq!(count_1, new_num); - assert_ne!(count_0, count_1); - } -} diff --git a/src/main/rust/ui.rs b/src/main/rust/ui.rs deleted file mode 100644 index 1632623..0000000 --- a/src/main/rust/ui.rs +++ /dev/null @@ -1,134 +0,0 @@ -use crate::app::App; -use miette::ErrReport; -use ratatui::{ - Frame, - layout::{Constraint, Direction, Layout, Rect}, - style::{Style, Stylize}, - symbols::border, - text::{Line, Span, Text}, - widgets::{Block, Clear, Paragraph, Widget}, -}; - -pub fn draw(frame: &mut Frame, app: &App) { - let rect = draw_layout(app); - frame.render_widget(rect, frame.area()); - - if let Some(error) = app.get_error() { - let popup = error_popup(error); - - let area = centered_rect(40, 25, frame.area()); - frame.render_widget(Clear, area); - frame.render_widget(popup, area); - } -} - -fn draw_layout(app: &App) -> impl Widget { - let title = Line::from(" Counter App Tutorial ".bold()); - let instructions = Line::from(vec![ - " Decrement ".into(), - "".blue().bold(), - " Increment ".into(), - "".blue().bold(), - " Quit ".into(), - " ".blue().bold(), - ]); - let block = Block::bordered() - .title(title.centered()) - .title_bottom(instructions.centered()) - .border_set(border::THICK); - - let counter_text = Text::from(vec![Line::from(vec![ - "Value: ".into(), - app.display_counter().yellow(), - ])]); - - Paragraph::new(counter_text).centered().block(block) -} - -/// helper function to create a centered rect using up certain percentage of the available rect `r` -fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { - // Cut the given rectangle into three vertical pieces - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(r); - // Then cut the middle vertical piece into three width-wise pieces - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(popup_layout[1])[1] // Return the middle chunk -} - -fn error_popup(error: &ErrReport) -> impl Widget { - let title = Line::from("Error").bold(); - let popup = Block::bordered() - .title(title.centered()) - .border_set(border::DOUBLE); - - let error_text = error_text(error); - - Paragraph::new(error_text).block(popup) -} - -fn error_text(error: &ErrReport) -> Text<'_> { - let errname_spans = vec![ - Span::raw("Error "), - Span::styled("x", Style::new().red()), - Span::raw(" "), - Span::raw(error.to_string()), - ]; - - let line1 = Line::default().spans(errname_spans); - let help_spans = vec![ - Span::styled(" help: ", Style::new().cyan()), - Span::raw(error.help().map_or("".into(), |x| x.to_string())), - ]; - let line2 = Line::default().spans(help_spans); - - Text::from(vec![line1, line2]) -} - -#[cfg(test)] -mod tests { - - use super::*; - use ratatui::{buffer::Buffer, layout::Rect, style::Style}; - - #[test] - fn render_success() { - let mut app = App::default(); - assert!(app.init_context().is_ok()); - - let rect = Rect::new(0, 0, 50, 4); - let mut buf = Buffer::empty(rect); - - let res = draw_layout(&app); - res.render(rect, &mut buf); - - let mut expected = Buffer::with_lines(vec![ - "┏━━━━━━━━━━━━━ Counter App Tutorial ━━━━━━━━━━━━━┓", - "┃ Value: 0 ┃", - "┃ ┃", - "┗━ Decrement Increment Quit ━━┛", - ]); - let title_style = Style::new().bold(); - let counter_style = Style::new().yellow(); - let key_style = Style::new().blue().bold(); - - expected.set_style(Rect::new(14, 0, 22, 1), title_style); - expected.set_style(Rect::new(28, 1, 1, 1), counter_style); - expected.set_style(Rect::new(13, 3, 6, 1), key_style); - expected.set_style(Rect::new(30, 3, 7, 1), key_style); - expected.set_style(Rect::new(43, 3, 4, 1), key_style); - - assert_eq!(buf, expected); - } -} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..1632623 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,134 @@ +use crate::app::App; +use miette::ErrReport; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Style, Stylize}, + symbols::border, + text::{Line, Span, Text}, + widgets::{Block, Clear, Paragraph, Widget}, +}; + +pub fn draw(frame: &mut Frame, app: &App) { + let rect = draw_layout(app); + frame.render_widget(rect, frame.area()); + + if let Some(error) = app.get_error() { + let popup = error_popup(error); + + let area = centered_rect(40, 25, frame.area()); + frame.render_widget(Clear, area); + frame.render_widget(popup, area); + } +} + +fn draw_layout(app: &App) -> impl Widget { + let title = Line::from(" Counter App Tutorial ".bold()); + let instructions = Line::from(vec![ + " Decrement ".into(), + "".blue().bold(), + " Increment ".into(), + "".blue().bold(), + " Quit ".into(), + " ".blue().bold(), + ]); + let block = Block::bordered() + .title(title.centered()) + .title_bottom(instructions.centered()) + .border_set(border::THICK); + + let counter_text = Text::from(vec![Line::from(vec![ + "Value: ".into(), + app.display_counter().yellow(), + ])]); + + Paragraph::new(counter_text).centered().block(block) +} + +/// helper function to create a centered rect using up certain percentage of the available rect `r` +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + // Cut the given rectangle into three vertical pieces + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + // Then cut the middle vertical piece into three width-wise pieces + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] // Return the middle chunk +} + +fn error_popup(error: &ErrReport) -> impl Widget { + let title = Line::from("Error").bold(); + let popup = Block::bordered() + .title(title.centered()) + .border_set(border::DOUBLE); + + let error_text = error_text(error); + + Paragraph::new(error_text).block(popup) +} + +fn error_text(error: &ErrReport) -> Text<'_> { + let errname_spans = vec![ + Span::raw("Error "), + Span::styled("x", Style::new().red()), + Span::raw(" "), + Span::raw(error.to_string()), + ]; + + let line1 = Line::default().spans(errname_spans); + let help_spans = vec![ + Span::styled(" help: ", Style::new().cyan()), + Span::raw(error.help().map_or("".into(), |x| x.to_string())), + ]; + let line2 = Line::default().spans(help_spans); + + Text::from(vec![line1, line2]) +} + +#[cfg(test)] +mod tests { + + use super::*; + use ratatui::{buffer::Buffer, layout::Rect, style::Style}; + + #[test] + fn render_success() { + let mut app = App::default(); + assert!(app.init_context().is_ok()); + + let rect = Rect::new(0, 0, 50, 4); + let mut buf = Buffer::empty(rect); + + let res = draw_layout(&app); + res.render(rect, &mut buf); + + let mut expected = Buffer::with_lines(vec![ + "┏━━━━━━━━━━━━━ Counter App Tutorial ━━━━━━━━━━━━━┓", + "┃ Value: 0 ┃", + "┃ ┃", + "┗━ Decrement Increment Quit ━━┛", + ]); + let title_style = Style::new().bold(); + let counter_style = Style::new().yellow(); + let key_style = Style::new().blue().bold(); + + expected.set_style(Rect::new(14, 0, 22, 1), title_style); + expected.set_style(Rect::new(28, 1, 1, 1), counter_style); + expected.set_style(Rect::new(13, 3, 6, 1), key_style); + expected.set_style(Rect::new(30, 3, 7, 1), key_style); + expected.set_style(Rect::new(43, 3, 4, 1), key_style); + + assert_eq!(buf, expected); + } +} -- cgit v1.3