Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .tarpaulin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ exclude-files = [
"crates/render/src/render_test_helpers.rs",
"crates/render/src/render.rs",
"crates/cli/src/init_logging.rs",
"crates/cli/src/input/tui.rs",
"crates/cli/src/input/tui/**",
"crates/cli/src/main.rs",
"crates/cli/src/run.rs",
"crates/cli/src/dispatch_command.rs",
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[package]
name = "klirr"
rust-version = "1.85.1"
version = "0.1.26"
edition = "2024"
description = "Zero-maintenance and smart FOSS generating beautiful invoices for services and expenses."
Expand All @@ -25,11 +26,12 @@ log.workspace = true
rand.workspace = true
rpassword.workspace = true
ron.workspace = true
serde.workspace = true
secrecy.workspace = true
strum.workspace = true
thiserror.workspace = true

[dev-dependencies]
serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true
test-log.workspace = true
33 changes: 29 additions & 4 deletions crates/cli/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
[![Build Status](https://github.com/Sajjon/klirr/actions/workflows/test.yml/badge.svg)](https://github.com/Sajjon/klirr/actions)
[![codecov](https://codecov.io/gh/Sajjon/klirr/graph/badge.svg?token=HG6N5QPYPH)](https://codecov.io/gh/Sajjon/klirr)
[![Latest Version](https://img.shields.io/crates/v/klirr.svg)](https://crates.io/crates/klirr)
[![Rust Documentation](https://docs.rs/klirr-render/badge.svg)](https://docs.rs/klirr-render)
[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/Sajjon/klirr/main/LICENSE.txt)
[![Rust 1.85.1](https://img.shields.io/badge/rustc-1.85.1-lightgray.svg)](https://blog.rust-lang.org/2025/03/18/Rust-1.85.1/)
[![Unsafe Forbidden](https://img.shields.io/badge/unsafe-forbidden-success.svg)](https://github.com/rust-secure-code/safety-dance)

**Tired of manual bumping invoice number, calculating number of working days and looking up exchange rates and converting expenses into your currency?**

Expand Down Expand Up @@ -29,7 +35,7 @@ Klirr is an **AMAZING** (**A**esthetic, **M**ulti-layouts/-language, **A**utomat
- [Edit Data](#edit-data)
- [Manually](#data-edit-manual)
- [Generate Invoice](#generate-invoice)
- [Out of office for some days?](#ooo)
- [Off for some days/hours?](#off)
- [Took vacation a whole month or parental leave?](#month-off)
- [Invoice for expenses](#expenses)
- [Add expenses](#expenses-add)
Expand Down Expand Up @@ -163,6 +169,11 @@ You can at any time validate the data by running:
klirr data validate
```

Or if you just wanna print the contents you can run:
```bash
klirr data dump
```

## Generate Invoice<a href="#generate-invoice" id="generate-invoice"/>[ ^](#thetoc)

```bash
Expand All @@ -185,14 +196,28 @@ klirr invoice -- --output $HOME/my/custom/path/my_custom_name_of_file.pdf
> If you don't specify `output` path the invoice will be saved in
> `$HOME/invoices`.

### Out of office for some days? <a href="#ooo" id="ooo"/> [ ^](#thetoc)
### Off (free) for some days/hours? <a href="#off" id="off"/> [ ^](#thetoc)

If you did not work for some days/hours, and you need to not invoice for those days, e.g. `6` days off, use:

#### Days off

If you did not work for some days, and you need to not invoice for those days, e.g. `6` days off, use:
```bash
klirr services-off --quantity 6 --unit days
```

#### Hours off

```bash
klirr invoice ooo 6
klirr services-off --quantity 16 --unit hours
```

> [!IMPORTANT]
> The unit MUST match the `rate` specified in the service_fees.ron, e.g.
> if you are invoicing with a **daily** rate, the value passed to `unit` must
> be `days` and analogoulsy if you are invoicing with an **hourly** rate you
> must pass `hours` to `unit`.

### Took vacation a whole month or parental leave? <a href="#month-off" id="month-off"/> [ ^](#thetoc)

You can ensure klirr uses correct invoice number calculations if you need to skip invoicing completely some months by marking said month(s) as "months off". You do it by:
Expand Down
50 changes: 42 additions & 8 deletions crates/cli/src/dispatch_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@ fn init_email_data(
init_email_data_at(data_dir(), provide_data)
}

fn init_data(provide_data: impl FnOnce(Data) -> Result<Data>) -> Result<()> {
fn init_data(
provide_data: impl FnOnce(Data<PeriodAnno>) -> Result<Data<PeriodAnno>>,
) -> Result<()> {
init_data_at(data_dir_create_if(true), provide_data)
}

fn edit_data(provide_data: impl FnOnce(Data) -> Result<Data>) -> Result<()> {
fn edit_data(
provide_data: impl FnOnce(Data<PeriodAnno>) -> Result<Data<PeriodAnno>>,
) -> Result<()> {
edit_data_at(data_dir(), provide_data)
}

Expand All @@ -22,12 +26,41 @@ fn edit_email_data(
edit_email_data_at(data_dir(), provide_data)
}

fn dump_data() -> Result<()> {
let base_path = data_dir();
info!("Dumping data directory at: {}", base_path.display());

read_data_from_disk_with_base_path(base_path)
.inspect(|model| {
let ron_str = ron::ser::to_string_pretty(model, ron::ser::PrettyConfig::default())
.expect("Failed to serialize data to RON");
info!("✅ Data: {ron_str}");
})
.inspect_err(|e| {
fn load_contents<F>(get_path: F) -> String where F: FnOnce(&Path) -> PathBuf {
let path = get_path(&data_dir());
std::fs::read_to_string(&path).unwrap_or_else(|_| {
panic!("Failed to read file at: {}", path.display())
})
}
let information = load_contents(|path| proto_invoice_info_path(path));
let vendor = load_contents(|path| vendor_path(path));
let client = load_contents(|path| client_path(path));
let payment_info = load_contents(|path| payment_info_path(path));
let service_fees = load_contents(|path| service_fees_path(path));
let expensed_periods = load_contents(|path| expensed_periods_path(path));
let str = format!("information: {information}\nvendor: {vendor}\nclient: {client}\npayment_info: {payment_info}\nservice_fees: {service_fees}\nexpensed_periods: {expensed_periods}\n");
error!("❌ Data directory is invalid: {}, is:\n\n{}", e, str);
})
.map_to_void()
}

fn validate_data() -> Result<()> {
let base_path = data_dir();
info!("Validating data directory at: {}", base_path.display());

read_data_from_disk_with_base_path(base_path)
.map(|_| ())
.map_to_void()
.inspect(|_| {
info!("✅ Data directory is valid");
})
Expand All @@ -41,12 +74,13 @@ fn record_expenses(month: &YearAndMonth, expenses: &[Item]) -> Result<()> {
}

fn record_month_off(month: &YearAndMonth) -> Result<()> {
record_month_off_with_base_path(month, data_dir())
record_period_off_with_base_path(month, data_dir())
}

pub fn run_data_command(command: &DataAdminInputCommand) -> Result<()> {
match command {
DataAdminInputCommand::Init => init_data(curry2(ask_for_data, None)),
DataAdminInputCommand::Dump => dump_data(),
DataAdminInputCommand::Validate => validate_data(),
DataAdminInputCommand::Edit(input) => edit_data(curry2(
ask_for_data,
Expand All @@ -73,7 +107,7 @@ pub fn render_sample_with_nonce(use_nonce: bool) -> Result<NamedPdf> {
let path = dirs_next::home_dir()
.expect("Expected to be able to find HOME dir")
.join("klirr_sample.pdf");
let mut data = Data::sample();
let mut data = Data::<YearAndMonth>::sample();
if use_nonce {
let vat = format!("VAT{} {}", rand::random::<u64>(), rand::random::<u64>());
data = data
Expand All @@ -84,7 +118,7 @@ pub fn render_sample_with_nonce(use_nonce: bool) -> Result<NamedPdf> {
data,
ValidInput::builder()
.maybe_output_path(path)
.month(YearAndMonth::last())
.period(YearMonthAndFortnight::last())
.build(),
render,
)
Expand All @@ -95,7 +129,7 @@ fn run_invoice_command_with_base_path(
data_path: impl AsRef<Path>,
) -> Result<NamedPdf> {
let input = input.parsed()?;
info!("🔮 Starting PDF creation, input: {}...", input);
info!("🔮 Starting PDF creation, input: {:?}...", input);
let email_settings = input.email().clone();
let named_pdf = create_pdf_with_data_base_path(data_path, input, render)?;
save_pdf_location_to_tmp_file(named_pdf.saved_at().clone())?;
Expand Down Expand Up @@ -155,7 +189,7 @@ mod tests {
fn test_run_invoice_command() {
let tempdir = tempfile::tempdir().expect("Failed to create temp dir");
let tempfile = tempdir.path().join("out.pdf");
save_data_with_base_path(Data::sample(), tempdir.path()).unwrap();
save_data_with_base_path(Data::<YearAndMonth>::sample(), tempdir.path()).unwrap();
let input = InvoiceInput::parse_from([
"invoice",
"--out",
Expand Down
73 changes: 52 additions & 21 deletions crates/cli/src/input/get_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ pub struct DataAdminInput {
/// validating the data, or recording expenses or month off.
#[derive(Debug, Subcommand, Unwrap, PartialEq)]
pub enum DataAdminInputCommand {
/// Prints the data in the data directory as a RON object.
Dump,
/// Initializes the data in the data directory, creating it if it does not exist.
/// Such as information about you as a vendor and your client, payment information
/// pricing etc
Expand Down Expand Up @@ -194,11 +196,11 @@ pub struct ExpensesInput {
#[command(name = "invoice")]
#[command(about = "Generate an invoice PDF", long_about = None)]
pub struct InvoiceInput {
/// The month for which the invoice is generated.
#[arg(long, short = 'm', default_value_t)]
/// The period for which the invoice is generated.
#[arg(long, short = 'p', default_value_t)]
#[builder(default)]
#[getset(get = "pub")]
month: TargetMonth,
period: TargetPeriod,

/// The language for which the invoice is generated.
#[arg(long, short = 'l', default_value_t)]
Expand Down Expand Up @@ -230,17 +232,16 @@ pub struct InvoiceInput {
}

impl InvoiceInput {
/// Maps `Option<TargetItems>` to `InvoicedItems`, e.g. for `TargetItems::Ooo { days }`
/// we map from `Option<u8>` to `Option<Day>`.
/// Maps `Option<TargetItems>` to `InvoicedItems`.
fn _invoiced_items(&self) -> Result<InvoicedItems> {
match self.items.clone().unwrap_or_default() {
TargetItems::Ooo { days } => Ok(InvoicedItems::Service {
days_off: if days == 0 {
None
} else {
Some(Day::try_from(days)?)
},
}),
TargetItems::ServicesOff(time_off) => {
let time_off = TimeOff::try_from(time_off)?;
Ok(InvoicedItems::Service {
time_off: Some(time_off),
})
}
TargetItems::Services => Ok(InvoicedItems::Service { time_off: None }),
TargetItems::Expenses => Ok(InvoicedItems::Expenses),
}
}
Expand Down Expand Up @@ -269,8 +270,9 @@ impl InvoiceInput {
Ok(None)
}?;
let items = self._invoiced_items()?;
let period = self.period.period();
let valid = ValidInput::builder()
.month(self.month.year_and_month())
.period(period)
.layout(*self.layout())
.items(items)
.language(*self.language())
Expand Down Expand Up @@ -345,9 +347,9 @@ mod tests {
use test_log::test;

#[test]
fn test_input_parsing_month() {
let input = CliArgs::parse_from([BINARY_NAME, "invoice", "--month", "last"]);
assert_eq!(input.command.unwrap_invoice().month, TargetMonth::Last);
fn test_input_parsing_period() {
let input = CliArgs::parse_from([BINARY_NAME, "invoice", "--period", "last"]);
assert_eq!(input.command.unwrap_invoice().period, TargetPeriod::Last);
}

#[test]
Expand All @@ -363,11 +365,33 @@ mod tests {
}

#[test]
fn test_input_parsing_items_specified_ooo() {
let input = CliArgs::parse_from([BINARY_NAME, "invoice", "ooo", "3"]);
fn test_input_parsing_items_specified_services_free() {
let input = CliArgs::parse_from([
BINARY_NAME,
"invoice",
"services-off",
"--quantity",
"3",
"--unit",
"days",
]);
assert_eq!(
input.command.unwrap_invoice().items,
Some(TargetItems::ServicesOff(
TimeOffInput::builder()
.quantity(3.0)
.unit(TimeUnitInput::Days)
.build()
))
);
}

#[test]
fn test_input_parsing_items_specified_services_not_off() {
let input = CliArgs::parse_from([BINARY_NAME, "invoice", "services"]);
assert_eq!(
input.command.unwrap_invoice().items,
Some(TargetItems::Ooo { days: 3 })
Some(TargetItems::Services)
);
}

Expand Down Expand Up @@ -410,13 +434,20 @@ mod tests {
#[test]
fn test_input_parsing_items_services() {
let input = InvoiceInput::builder()
.items(TargetItems::Ooo { days: 25 })
.items(TargetItems::ServicesOff(
TimeOffInput::builder()
.quantity(25.0)
.unit(TimeUnitInput::Days)
.build(),
))
.build();
let input = input.parsed().unwrap();
let expected_decimal = Decimal::try_from(25.0).unwrap();
let expected_quantity = Quantity::from(expected_decimal);
assert_eq!(
*input.items(),
InvoicedItems::Service {
days_off: Some(Day::try_from(25).unwrap())
time_off: Some(TimeOff::Days(expected_quantity))
}
);
}
Expand Down
8 changes: 6 additions & 2 deletions crates/cli/src/input/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
mod get_input;
mod target_items;
mod target_month;
mod target_period;
mod time_off_input;
mod time_off_unit_input;
mod tui;

pub use get_input::*;
pub use target_items::*;
pub use target_month::*;
pub use target_period::*;
pub use time_off_input::*;
pub use time_off_unit_input::*;
pub use tui::*;
Loading
Loading