pactole-cli 0.2.0

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

use pactole_core::Entry;

use crate::config::{default_import_config_path, format_options};

pub(crate) fn run(
    file: PathBuf,
    config: Option<PathBuf>,
    into: Option<PathBuf>,
    write: bool,
) -> ExitCode {
    let format_options = match format_options() {
        Ok(options) => options,
        Err(err) => {
            eprintln!("error: {err}");
            return ExitCode::FAILURE;
        }
    };

    let config_path = match config.or_else(default_import_config_path) {
        Some(path) => path,
        None => {
            eprintln!(
                "error: cannot determine the Pactole configuration directory \
                 (set XDG_CONFIG_HOME or HOME, or pass --config)"
            );
            return ExitCode::FAILURE;
        }
    };

    let config_source = match fs::read_to_string(&config_path) {
        Ok(source) => source,
        Err(err) => {
            eprintln!(
                "error: cannot read configuration {}: {err}",
                config_path.display()
            );
            return ExitCode::FAILURE;
        }
    };

    let config: pactole_import::ImportConfig = match toml::from_str(&config_source) {
        Ok(config) => config,
        Err(err) => {
            eprintln!(
                "error: cannot parse configuration {}: {err}",
                config_path.display()
            );
            return ExitCode::FAILURE;
        }
    };

    let mut known_fitids = HashSet::new();
    let mut existing = None;
    if let Some(path) = &into {
        match fs::read_to_string(path) {
            Ok(source) => {
                known_fitids = collect_fitids(&source);
                existing = Some(source);
            }
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
            Err(err) => {
                eprintln!("error: cannot read {}: {err}", path.display());
                return ExitCode::FAILURE;
            }
        }
    }

    let source = match fs::read_to_string(&file) {
        Ok(source) => source,
        Err(err) => {
            eprintln!("error: cannot read {}: {err}", file.display());
            return ExitCode::FAILURE;
        }
    };

    let outcome = match pactole_import::import_ofx(&source, &config, &known_fitids) {
        Ok(outcome) => outcome,
        Err(err) => {
            eprintln!("error: {err}");
            return ExitCode::FAILURE;
        }
    };

    for warning in &outcome.warnings {
        eprintln!("warning: {}", describe_warning(warning));
    }

    let generated = pactole_storage_fs::format_transactions_with_options(
        &outcome.transactions,
        &format_options,
    );

    if write {
        let path = into.as_deref().unwrap_or_else(|| Path::new(""));
        let mut content = existing.unwrap_or_default();
        if !content.is_empty() {
            if !content.ends_with('\n') {
                content.push('\n');
            }
            content.push('\n');
        }
        content.push_str(&generated);

        if let Err(err) = fs::write(path, content) {
            eprintln!("error: cannot write {}: {err}", path.display());
            return ExitCode::FAILURE;
        }
    } else {
        print!("{generated}");
    }

    eprintln!(
        "imported {} transaction(s), skipped {}",
        outcome.transactions.len(),
        outcome.skipped
    );

    ExitCode::SUCCESS
}

fn collect_fitids(source: &str) -> HashSet<String> {
    let analyzed = pactole_storage_fs::analyze_file(source);

    analyzed
        .entries()
        .iter()
        .filter_map(|parsed_entry| match &parsed_entry.entry {
            Entry::Transaction(transaction) => transaction.meta.get("fitid").cloned(),
            _ => None,
        })
        .collect()
}

fn describe_warning(warning: &pactole_import::ImportWarning) -> String {
    match warning {
        pactole_import::ImportWarning::MissingPayee { fitid } => match fitid {
            Some(fitid) => {
                format!("transaction `{fitid}` has no payee; using the `Unknown` placeholder")
            }
            None => "a transaction has no payee; using the `Unknown` placeholder".to_string(),
        },
    }
}