pactole-cli 0.2.0

Command-line interface for Pactole, a plain-text double-entry accounting ledger.
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;

use chrono::{Local, NaiveDate};
use pactole_core::{AccountName, CommodityName};

use crate::config::default_import_config_path;

pub(crate) struct Options {
    pub(crate) ledger: PathBuf,
    pub(crate) config: Option<PathBuf>,
    pub(crate) account: String,
    pub(crate) currency: String,
    pub(crate) counter_account: String,
    pub(crate) date: Option<NaiveDate>,
    pub(crate) stdout: bool,
    pub(crate) no_config: bool,
    pub(crate) no_ledger: bool,
}

pub(crate) fn run(options: Options) -> ExitCode {
    let Options {
        ledger,
        config,
        account,
        currency,
        counter_account,
        date,
        stdout,
        no_config,
        no_ledger,
    } = options;
    if no_config && no_ledger {
        eprintln!("error: --no-config and --no-ledger cannot be used together");
        return ExitCode::FAILURE;
    }

    if let Err(err) = AccountName::new(account.clone()) {
        return failure(format!("invalid bank account `{account}`: {err}"));
    }
    if let Err(err) = AccountName::new(counter_account.clone()) {
        return failure(format!(
            "invalid counter account `{counter_account}`: {err}"
        ));
    }
    if let Err(err) = CommodityName::new(currency.clone()) {
        return failure(format!("invalid currency `{currency}`: {err}"));
    }

    let date = date.unwrap_or_else(|| Local::now().date_naive());
    let ledger_source = render_ledger(&date, &account, &currency, &counter_account);
    let config_source = render_config(&account, &currency, &counter_account);

    if !no_ledger && stdout {
        print!("{ledger_source}");
    }

    if !no_ledger && !stdout {
        match create_file_if_missing(&ledger, &ledger_source) {
            Ok(true) => println!("created ledger: {}", ledger.display()),
            Ok(false) => println!("ledger already exists: {}", ledger.display()),
            Err(err) => return failure(format!("cannot initialize {}: {err}", ledger.display())),
        }
    }

    if !no_config {
        let config_path = match config.or_else(default_import_config_path) {
            Some(path) => path,
            None => {
                return failure(
                    "cannot determine the Pactole configuration directory (set XDG_CONFIG_HOME or HOME, or pass --config)",
                );
            }
        };

        if let Some(parent) = config_path.parent()
            && let Err(err) = fs::create_dir_all(parent)
        {
            return failure(format!("cannot create {}: {err}", parent.display()));
        }

        match create_file_if_missing(&config_path, &config_source) {
            Ok(true) => println!("created configuration: {}", config_path.display()),
            Ok(false) => println!("configuration already exists: {}", config_path.display()),
            Err(err) => {
                return failure(format!(
                    "cannot initialize {}: {err}",
                    config_path.display()
                ));
            }
        }
    }

    ExitCode::SUCCESS
}

fn render_ledger(date: &NaiveDate, account: &str, currency: &str, counter_account: &str) -> String {
    format!(
        "; Pactole ledger\n\
         ; Generated by `pactole init`.\n\
         ; Add transactions below and run `pactole check ledger.pactole`.\n\n\
         {date} commodity {currency}\n\
         {date} open {account}\n\
         {date} open {counter_account}\n"
    )
}

fn render_config(account: &str, currency: &str, counter_account: &str) -> String {
    format!(
        "# Pactole OFX import configuration\n\
         # Generated by `pactole init`.\n\n\
         bank_account = \"{account}\"\n\
         currency = \"{currency}\"\n\
         default_counter_account = \"{counter_account}\"\n\n\
         # Add categorization rules below.\n\
         #\n\
         # [[rules]]\n\
         # payee_contains = \"supermarket\"\n\
         # counter_account = \"Expenses:Groceries\"\n\
         # payee = \"Supermarket\"\n"
    )
}

fn create_file_if_missing(path: &Path, contents: &str) -> io::Result<bool> {
    if let Some(parent) = path.parent()
        && !parent.as_os_str().is_empty()
    {
        fs::create_dir_all(parent)?;
    }

    let mut file = match OpenOptions::new().write(true).create_new(true).open(path) {
        Ok(file) => file,
        Err(err) if err.kind() == io::ErrorKind::AlreadyExists => return Ok(false),
        Err(err) => return Err(err),
    };

    file.write_all(contents.as_bytes())?;
    file.flush()?;
    Ok(true)
}

fn failure(error: impl std::fmt::Display) -> ExitCode {
    eprintln!("error: {error}");
    ExitCode::FAILURE
}