Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ log.workspace = true
rand.workspace = true
rpassword.workspace = true
ron.workspace = true
serde.workspace = true
secrecy.workspace = true
thiserror.workspace = true

[dev-dependencies]
serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true
test-log.workspace = true
21 changes: 13 additions & 8 deletions crates/cli/src/dispatch_command.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
use crate::prelude::*;
use klirr_render::prelude::render;
use secrecy::SecretString;
use serde::de::DeserializeOwned;

fn init_email_data(
provide_data: impl FnOnce(EncryptedEmailSettings) -> Result<EncryptedEmailSettings>,
) -> Result<()> {
init_email_data_at(data_dir(), provide_data)
}

fn init_data(provide_data: impl FnOnce(Data) -> Result<Data>) -> Result<()> {
fn init_data<Period: IsPeriod + Serialize + DeserializeOwned + HasSample>(
provide_data: impl FnOnce(Data<Period>) -> Result<Data<Period>>,
) -> 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<Period: IsPeriod + Serialize + DeserializeOwned>(
provide_data: impl FnOnce(Data<Period>) -> Result<Data<Period>>,
) -> Result<()> {
edit_data_at(data_dir(), provide_data)
}

Expand All @@ -26,7 +31,7 @@ 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)
read_data_from_disk_with_base_path::<YearAndMonth>(base_path)
.map(|_| ())
.inspect(|_| {
info!("✅ Data directory is valid");
Expand All @@ -41,12 +46,12 @@ 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::Init => init_data::<PeriodAnno>(curry2(ask_for_data, None)),
DataAdminInputCommand::Validate => validate_data(),
DataAdminInputCommand::Edit(input) => edit_data(curry2(
ask_for_data,
Expand Down Expand Up @@ -84,7 +89,7 @@ pub fn render_sample_with_nonce(use_nonce: bool) -> Result<NamedPdf> {
data,
ValidInput::builder()
.maybe_output_path(path)
.month(YearAndMonth::last())
.period(YearAndMonth::last())
.build(),
render,
)
Expand All @@ -95,7 +100,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 +160,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
4 changes: 2 additions & 2 deletions crates/cli/src/input/get_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ impl InvoiceInput {
/// # Errors
/// Returns an error if the input is invalid, e.g. if the output path does not
/// exist or if the items are not specified correctly.
pub fn parsed(self) -> Result<ValidInput> {
pub fn parsed(self) -> Result<ValidInput<PeriodAnno>> {
if let Some(path) = &self.out {
let parent = path
.parent()
Expand All @@ -270,7 +270,7 @@ impl InvoiceInput {
}?;
let items = self._invoiced_items()?;
let valid = ValidInput::builder()
.month(self.month.year_and_month())
.period(PeriodAnno::from(self.month.year_and_month()))
.layout(*self.layout())
.items(items)
.language(*self.language())
Expand Down
137 changes: 102 additions & 35 deletions crates/cli/src/input/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,25 +164,87 @@ fn format_help_skippable(help: impl Into<Option<String>>) -> String {
)
}

fn build_period(
help: impl Into<Option<String>>,
default: Option<PeriodAnno>,
cadence: Cadence,
) -> InquireResult<Option<PeriodAnno>> {
let help = help.into();
// match cadence {
// Cadence::Monthly => if let Some(default) = default {
// match default {
// PeriodAnno::YearAndMonth(ym) => {
// build_year_month_inner(help, Some(*ym.year()), Some(*ym.month()))
// }
// PeriodAnno::YearMonthAndFortnight(_) => {
// panic!("Expected YearAndMonth since Cadence is Monthly")
// }
// }
// } else {
// build_year_month_inner(help, None, None)
// }
// .map(|ok| ok.map(PeriodAnno::from)),
// Cadence::BiWeekly => build_year_month_inner(),
// }
Copy link

Copilot AI Jul 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Consider removing or refactoring the large commented-out block to improve readability and maintainability of the period-building logic.

Suggested change
// match cadence {
// Cadence::Monthly => if let Some(default) = default {
// match default {
// PeriodAnno::YearAndMonth(ym) => {
// build_year_month_inner(help, Some(*ym.year()), Some(*ym.month()))
// }
// PeriodAnno::YearMonthAndFortnight(_) => {
// panic!("Expected YearAndMonth since Cadence is Monthly")
// }
// }
// } else {
// build_year_month_inner(help, None, None)
// }
// .map(|ok| ok.map(PeriodAnno::from)),
// Cadence::BiWeekly => build_year_month_inner(),
// }
// Removed redundant commented-out block for improved readability and maintainability.

Copilot uses AI. Check for mistakes.
let Some(ym) = build_year_month_inner(
help.clone(),
default.as_ref().map(|d| d.year()),
default.as_ref().map(|d| d.month()),
)?
else {
return Ok(None);
};
match cadence {
Cadence::Monthly => Ok(Some(ym.into())),
Cadence::BiWeekly => {
let help_message = format_help_skippable(help);

let Some(half) = CustomType::<MonthHalf>::new("Half of month?")
.with_help_message(&help_message)
.with_optional_default(&default.and_then(|d| {
d.try_unwrap_year_month_and_fortnight()
.ok()
.map(|yam| *yam.half())
}))
.prompt_skippable()?
else {
return Ok(None);
};

Ok(Some(
YearMonthAndFortnight::builder()
.year(*ym.year())
.month(*ym.month())
.half(half)
.build()
.into(),
))
}
}
}

fn build_year_month_inner(
help: impl Into<Option<String>>,
default: Option<YearAndMonth>,
default_year: Option<&Year>,
default_month: Option<&Month>,
) -> InquireResult<Option<YearAndMonth>> {
let default_value = default.unwrap_or(YearAndMonth::last());
let default = YearAndMonth::last();
let default_year = default_year.unwrap_or(default.year());
let default_month = default_month.unwrap_or(default.month());

let help_message = format_help_skippable(help);

let Some(year) = CustomType::<Year>::new("Year?")
.with_help_message(&help_message)
.with_default(*default_value.year())
.with_default(*default_year)
.prompt_skippable()?
else {
return Ok(None);
};

let Some(month) = CustomType::<Month>::new("Month?")
.with_help_message(&help_message)
.with_default(*default_value.month())
.with_default(*default_month)
.prompt_skippable()?
else {
return Ok(None);
Expand All @@ -193,18 +255,14 @@ fn build_year_month_inner(
))
}

#[allow(unused)]
fn build_year_month(
help: impl Into<Option<String>>,
default: Option<YearAndMonth>,
) -> Result<Option<YearAndMonth>> {
build_year_month_inner(help, default).map_err(|e| Error::InvalidYearAndMonth {
underlying: e.to_string(),
})
}

fn build_invoice_info(default: &ProtoInvoiceInfo) -> Result<ProtoInvoiceInfo> {
fn inner(default: &ProtoInvoiceInfo) -> InquireResult<ProtoInvoiceInfo> {
fn build_invoice_info(
default: &ProtoInvoiceInfo<PeriodAnno>,
cadence: Cadence,
) -> Result<ProtoInvoiceInfo<PeriodAnno>> {
fn inner(
default: &ProtoInvoiceInfo<PeriodAnno>,
cadence: Cadence,
) -> InquireResult<ProtoInvoiceInfo<PeriodAnno>> {
let invoice_number_offset = CustomType::<InvoiceNumber>::new(
"What is the last invoice number you issued? We call this the 'offset'",
)
Expand All @@ -215,16 +273,17 @@ fn build_invoice_info(default: &ProtoInvoiceInfo) -> Result<ProtoInvoiceInfo> {
.prompt_skippable()?
.unwrap_or_default();

let invoice_number_offset_month = build_year_month_inner(
let invoice_number_offset_period = build_period(
"When was that invoice issued? (Used to calculate future invoice numbers)".to_owned(),
Some(*default.offset().month()),
Some(default.offset().period().clone()),
cadence,
)?
// if we use `0` as offset and set month to last month, then the next invoice number will be `1` for this month, which is correct.
.unwrap_or(YearAndMonth::last());
.unwrap_or(YearAndMonth::last().into());

let offset = TimestampedInvoiceNumber::builder()
let offset = TimestampedInvoiceNumber::<PeriodAnno>::builder()
.offset(invoice_number_offset)
.month(invoice_number_offset_month)
.period(invoice_number_offset_period)
.build();

let purchase_order = CustomType::<PurchaseOrder>::new("Purchase order number (optional)")
Expand Down Expand Up @@ -254,12 +313,12 @@ fn build_invoice_info(default: &ProtoInvoiceInfo) -> Result<ProtoInvoiceInfo> {
.maybe_purchase_order(purchase_order)
.maybe_footer_text(footer_text)
.maybe_emphasize_color_hex(emphasize_color_hex)
.months_off_record(default.months_off_record().clone())
.record_of_periods_off(default.record_of_periods_off().clone())
.build();

Ok(info)
}
inner(default).map_err(|e| Error::InvalidInvoiceInfo {
inner(default, cadence).map_err(|e| Error::InvalidInvoiceInfo {
reason: format!("{:?}", e),
})
}
Expand Down Expand Up @@ -551,10 +610,15 @@ fn config_render() {
);
}

fn select_or_default<S, T, F>(selector: Option<S>, target: S, default: &T, builder: F) -> Result<T>
fn select_or_default<'a, 'b: 'a, S, T, F>(
selector: Option<S>,
target: S,
default: &'b T,
builder: F,
) -> Result<T>
where
S: Select,
F: FnOnce(&T) -> Result<T>,
F: FnOnce(&'a T) -> Result<T>,
T: Clone,
{
if selector
Expand Down Expand Up @@ -680,7 +744,10 @@ pub fn ask_for_email(
Ok(email_settings)
}

pub fn ask_for_data(default: Data, data_selector: Option<DataSelector>) -> Result<Data> {
pub fn ask_for_data(
default: Data<PeriodAnno>,
data_selector: Option<DataSelector>,
) -> Result<Data<PeriodAnno>> {
config_render();

let vendor = select_or_default(data_selector, DataSelector::Vendor, default.vendor(), |d| {
Expand All @@ -691,11 +758,18 @@ pub fn ask_for_data(default: Data, data_selector: Option<DataSelector>) -> Resul
build_company("Your client", d)
})?;

let service_fees = select_or_default(
data_selector,
DataSelector::ServiceFees,
default.service_fees(),
build_service_fees,
)?;

let invoice_info = select_or_default(
data_selector,
DataSelector::Information,
default.information(),
build_invoice_info,
curry2(build_invoice_info, *service_fees.cadence()),
)?;

let payment_info = select_or_default(
Expand All @@ -705,20 +779,13 @@ pub fn ask_for_data(default: Data, data_selector: Option<DataSelector>) -> Resul
build_payment_info,
)?;

let service_fees = select_or_default(
data_selector,
DataSelector::ServiceFees,
default.service_fees(),
build_service_fees,
)?;

let data = Data::builder()
.client(client)
.vendor(vendor)
.payment_info(payment_info)
.service_fees(service_fees)
.information(invoice_info)
.expensed_months(default.expensed_months().clone())
.expensed_periods(default.expensed_periods().clone())
.build();

Ok(data)
Expand Down
Loading
Loading