use crate::{
BINARY_NAME, Cadence, DataAdminInput, EmailInput, Error, InvoicedItems, Language, PathBuf,
Result, TargetItems, TargetPeriod, TimeOff, ValidInput, period_end_from_relative_time,
validate_email_data,
};
use bon::Builder;
use clap::Subcommand;
use getset::Getters;
use clap::Parser;
use derive_more::{Debug, Unwrap};
use klirr_core_invoice::Layout as InvoiceLayout;
#[derive(Debug, Parser)]
#[command(name = BINARY_NAME, about = "Generate invoices for services and expenses, with support for emailing them.")]
#[command(version = env!("CARGO_PKG_VERSION"))]
pub struct CliArgs {
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand, Unwrap)]
pub enum Command {
Sample,
Email(EmailInput),
Invoice(InvoiceInput),
Data(DataAdminInput),
}
#[derive(Debug, Clone, Builder, Getters, Parser)]
#[command(name = "invoice")]
#[command(about = "Generate an invoice PDF", long_about = None)]
pub struct InvoiceInput {
#[arg(long, short = 'p', default_value_t)]
#[builder(default)]
#[getset(get = "pub")]
period: TargetPeriod,
#[arg(long, short = 'l', default_value_t)]
#[builder(default)]
#[getset(get = "pub")]
language: Language,
#[arg(long, short = 't', default_value_t)]
#[builder(default)]
#[getset(get = "pub")]
layout: InvoiceLayout,
#[command(subcommand)]
#[getset(get = "pub")]
items: Option<TargetItems>,
#[arg(long, short = 'o')]
out: Option<PathBuf>,
#[arg(long, short = 'e')]
#[builder(default = false)]
email: bool,
}
impl InvoiceInput {
fn _invoiced_items(&self) -> Result<InvoicedItems> {
match self.items.clone().unwrap_or_default() {
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),
}
}
pub fn parsed(self, cadence: Cadence) -> Result<ValidInput> {
if let Some(path) = &self.out {
let parent = path
.parent()
.expect("Invalid path specified, no parent found, don't specify an empty path, a root or a prefix.");
if !parent.exists() {
Err(Error::SpecifiedOutputPathDoesNotExist {
path: path.display().to_string(),
})?;
}
}
let email_config = if self.email {
validate_email_data().map(Some)
} else {
Ok(None)
}?;
let items = self._invoiced_items()?;
let relative_time = self.period.relative_time_for_cadence(cadence);
let date = period_end_from_relative_time(relative_time)?;
let valid = ValidInput::builder()
.date(date)
.layout(*self.layout())
.items(items)
.language(*self.language())
.maybe_maybe_output_path(self.out)
.maybe_email(email_config)
.build();
Ok(valid)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::input::{
DataAdminInputCommand, EditDataInputSelector, EditEmailInputSelector, ExpensesInput,
TimeOffInput, TimeUnitInput,
};
use klirr_core_invoice::{
DataSelector, Decimal, EmailSettingsSelector, FromStr, Item, Quantity,
};
mod data_admin_input {
use super::*;
#[test]
fn test_data_admin_init() {
let input = CliArgs::parse_from([BINARY_NAME, "data", "init"]);
assert!(matches!(input.command, Command::Data(_)));
}
#[test]
fn test_data_admin_validate() {
let input = CliArgs::parse_from([BINARY_NAME, "data", "validate"]);
assert!(matches!(input.command, Command::Data(_)));
}
#[test]
fn test_data_admin_expense() {
let item_1_str = "Coffee,2.5,EUR,3.0,2025-05-31";
let item_1 = Item::from_str(item_1_str).unwrap();
let item_2_str = "Lunch,10.0,USD,1.0,2025-05-31";
let item_2 = Item::from_str(item_2_str).unwrap();
let input = CliArgs::parse_from([
BINARY_NAME,
"data",
"expenses",
"--period",
"2025-05",
"-e",
item_1_str,
"-e",
item_2_str,
]);
assert_eq!(
*input.command.unwrap_data().command(),
DataAdminInputCommand::Expenses(
ExpensesInput::builder()
.period("2025-05".to_owned())
.expenses(vec![item_1, item_2])
.build()
)
);
}
}
mod invoice_input {
use super::*;
mod tests_input {
use super::*;
use test_log::test;
#[test]
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]
fn test_input_parsing_language_specified() {
let input = CliArgs::parse_from([BINARY_NAME, "invoice", "--language", "swedish"]);
assert_eq!(input.command.unwrap_invoice().language, Language::SV);
}
#[test]
fn test_input_parsing_language_default() {
let input = CliArgs::parse_from([BINARY_NAME, "invoice"]);
assert_eq!(input.command.unwrap_invoice().language, Language::EN);
}
#[test]
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::Services)
);
}
#[test]
fn test_input_parsing_items_specified_expenses() {
let input = CliArgs::parse_from([BINARY_NAME, "invoice", "expenses"]);
assert_eq!(
input.command.unwrap_invoice().items,
Some(TargetItems::Expenses)
);
}
#[test]
fn test_input_parsing_items_default() {
let input = CliArgs::parse_from([BINARY_NAME, "invoice"]);
assert_eq!(input.command.unwrap_invoice().items, None);
}
#[test]
fn test_input_parsing_out_specified() {
let input =
CliArgs::parse_from([BINARY_NAME, "invoice", "--out", "/tmp/invoice.pdf"]);
assert_eq!(
input.command.unwrap_invoice().out,
Some(PathBuf::from("/tmp/invoice.pdf"))
);
}
#[test]
fn test_input_parsing_out_default() {
let input = CliArgs::parse_from([BINARY_NAME, "invoice"]);
assert_eq!(input.command.unwrap_invoice().out, None);
}
}
mod tests_parsed_input {
use super::*;
use test_log::test;
#[test]
fn test_input_parsing_items_services() {
let input = InvoiceInput::builder()
.items(TargetItems::ServicesOff(
TimeOffInput::builder()
.quantity(25.0)
.unit(TimeUnitInput::Days)
.build(),
))
.build();
let input = input.parsed(Cadence::Monthly).unwrap();
let expected_decimal = Decimal::try_from(25.0).unwrap();
let expected_quantity = Quantity::from(expected_decimal);
assert_eq!(
*input.items(),
InvoicedItems::Service {
time_off: Some(TimeOff::Days(expected_quantity))
}
);
}
#[test]
fn test_input_parsing_items_expenses() {
let input = InvoiceInput::builder().items(TargetItems::Expenses).build();
let input = input.parsed(Cadence::Monthly).unwrap();
assert_eq!(*input.items(), InvoicedItems::Expenses);
}
#[test]
fn test_input_parsing_out() {
let input = InvoiceInput::builder()
.out(PathBuf::from("/tmp/invoice.pdf"))
.build();
let input = input.parsed(Cadence::Monthly).unwrap();
assert_eq!(
*input.maybe_output_path(),
Some(PathBuf::from("/tmp/invoice.pdf"))
);
}
#[test]
#[should_panic]
fn test_input_parsing_out_at_root_crashes() {
let input = InvoiceInput::builder().out(PathBuf::from("/")).build();
let _ = input.parsed(Cadence::Monthly);
}
}
}
#[test]
fn test_data_selector_from_edit_data_input_selector() {
let selector = EditDataInputSelector::Vendor;
let data_selector: DataSelector = selector.into();
assert_eq!(data_selector, DataSelector::Vendor);
let selector = EditDataInputSelector::All;
let data_selector: DataSelector = selector.into();
assert_eq!(data_selector, DataSelector::All);
let selector = EditDataInputSelector::Information;
let data_selector: DataSelector = selector.into();
assert_eq!(data_selector, DataSelector::Information);
let selector = EditDataInputSelector::PaymentInfo;
let data_selector: DataSelector = selector.into();
assert_eq!(data_selector, DataSelector::PaymentInfo);
let selector = EditDataInputSelector::ServiceFees;
let data_selector: DataSelector = selector.into();
assert_eq!(data_selector, DataSelector::ServiceFees);
let selector = EditDataInputSelector::Client;
let data_selector: DataSelector = selector.into();
assert_eq!(data_selector, DataSelector::Client);
}
#[test]
fn test_email_settings_selector_from_edit_email_input_selector() {
let selector = EditEmailInputSelector::All;
let email_settings_selector: EmailSettingsSelector = selector.into();
assert_eq!(email_settings_selector, EmailSettingsSelector::All);
let selector = EditEmailInputSelector::AppPassword;
let email_settings_selector: EmailSettingsSelector = selector.into();
assert_eq!(email_settings_selector, EmailSettingsSelector::AppPassword);
let selector = EditEmailInputSelector::EncryptionPassword;
let email_settings_selector: EmailSettingsSelector = selector.into();
assert_eq!(
email_settings_selector,
EmailSettingsSelector::EncryptionPassword
);
let selector = EditEmailInputSelector::Template;
let email_settings_selector: EmailSettingsSelector = selector.into();
assert_eq!(email_settings_selector, EmailSettingsSelector::Template);
let selector = EditEmailInputSelector::Smtp;
let email_settings_selector: EmailSettingsSelector = selector.into();
assert_eq!(email_settings_selector, EmailSettingsSelector::SmtpServer);
let selector = EditEmailInputSelector::ReplyTo;
let email_settings_selector: EmailSettingsSelector = selector.into();
assert_eq!(email_settings_selector, EmailSettingsSelector::ReplyTo);
let selector = EditEmailInputSelector::Sender;
let email_settings_selector: EmailSettingsSelector = selector.into();
assert_eq!(email_settings_selector, EmailSettingsSelector::Sender);
let selector = EditEmailInputSelector::Recipients;
let email_settings_selector: EmailSettingsSelector = selector.into();
assert_eq!(email_settings_selector, EmailSettingsSelector::Recipients);
let selector = EditEmailInputSelector::Cc;
let email_settings_selector: EmailSettingsSelector = selector.into();
assert_eq!(email_settings_selector, EmailSettingsSelector::CcRecipients);
let selector = EditEmailInputSelector::Bcc;
let email_settings_selector: EmailSettingsSelector = selector.into();
assert_eq!(
email_settings_selector,
EmailSettingsSelector::BccRecipients
);
}
}