kontochronik 0.2.0

Long-Term archive for account transactions
Documentation
use std::path::{Path, PathBuf};

use clap::{Parser, Subcommand, ValueEnum};

use kontochronik::{Importer, gls::GlsImporter, import, read_archive, verify_archive};

#[derive(Debug, Parser)]
#[command(version, about)]
struct Cli {
    /// Increase log output (-v: info, -vv: debug, -vvv: trace)
    #[arg(short, long, action = clap::ArgAction::Count, global = true)]
    verbose: u8,

    #[command(subcommand)]
    command: Command,
}

#[derive(Debug, Subcommand)]
enum Command {
    /// Import bank CSV exports into an archive
    ///
    /// The archive is created if it doesn't exist yet.
    /// Entries that are already archived are skipped,
    /// so files with overlapping date ranges can be imported
    /// safely and repeatedly.
    Import {
        /// The archive file
        #[arg(short, long)]
        archive: PathBuf,

        /// The format of the export files
        #[arg(short, long, value_enum, default_value_t = Format::Gls)]
        format: Format,

        /// The export files to import
        #[arg(required = true)]
        files: Vec<PathBuf>,
    },

    /// Show a summary of an archive
    Info {
        /// The archive file
        archive: PathBuf,
    },

    /// Verify the consistency of an archive without modifying it
    ///
    /// Checks that every stored fingerprint matches its entry's
    /// content and that the archive holds a single account and
    /// currency.
    Verify {
        /// The archive file
        archive: PathBuf,
    },
}

/// The supported export formats.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum Format {
    /// CSV export of the GLS Bank online banking
    Gls,
}

impl Format {
    const fn importer(self) -> &'static dyn Importer {
        match self {
            Self::Gls => &GlsImporter,
        }
    }
}

fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();
    logger::init(cli.verbose);
    match cli.command {
        Command::Import {
            archive,
            format,
            files,
        } => run_import(&archive, format, &files),
        Command::Info { archive } => run_info(&archive),
        Command::Verify { archive } => run_verify(&archive),
    }
}

fn run_import(archive: &Path, format: Format, files: &[PathBuf]) -> anyhow::Result<()> {
    let importer = format.importer();
    for file in files {
        let summary = import(importer, file, archive)?;
        println!(
            "{}: {} imported, {} duplicates skipped, archive now holds {} entries",
            file.display(),
            summary.imported,
            summary.duplicates,
            summary.total
        );
    }
    Ok(())
}

fn run_info(archive: &Path) -> anyhow::Result<()> {
    let records = read_archive(archive)?;
    let Some(first) = records.first() else {
        println!("{}: empty archive", archive.display());
        return Ok(());
    };
    let oldest = records
        .iter()
        .map(|r| r.booking_date)
        .min()
        .expect("non-empty");
    let newest = records
        .iter()
        .map(|r| r.booking_date)
        .max()
        .expect("non-empty");
    // The archive is ordered newest first, so among the entries of the
    // newest day the first one carries the most recent balance.
    let latest = records
        .iter()
        .find(|r| r.booking_date == newest)
        .unwrap_or(first);
    println!(
        "Account:  {} ({})",
        first.account_iban, first.account_description
    );
    println!(
        "Bank:     {} ({})",
        first.account_bank_name, first.account_bic
    );
    println!("Entries:  {}", records.len());
    println!("Period:   {oldest} to {newest}");
    println!(
        "Balance:  {} {} (after booking on {newest})",
        latest.balance_after_booking, latest.currency
    );
    Ok(())
}

fn run_verify(archive: &Path) -> anyhow::Result<()> {
    let summary = verify_archive(archive)?;
    println!("{}: {} entries verified", archive.display(), summary.total);
    if summary.missing_fingerprints > 0 {
        println!(
            "{} entries have no fingerprint yet; the next import will add it",
            summary.missing_fingerprints
        );
    }
    Ok(())
}

mod logger {
    //! A minimal logger printing to stderr;
    //! deliberately hand-rolled to keep the dependency tree small.

    use log::{LevelFilter, Log, Metadata, Record};

    struct StderrLogger;

    static LOGGER: StderrLogger = StderrLogger;

    impl Log for StderrLogger {
        fn enabled(&self, metadata: &Metadata<'_>) -> bool {
            metadata.level() <= log::max_level()
        }

        fn log(&self, record: &Record<'_>) {
            if self.enabled(record.metadata()) {
                eprintln!("[{}] {}", record.level(), record.args());
            }
        }

        fn flush(&self) {}
    }

    pub fn init(verbosity: u8) {
        let level = match verbosity {
            0 => LevelFilter::Warn,
            1 => LevelFilter::Info,
            2 => LevelFilter::Debug,
            _ => LevelFilter::Trace,
        };
        log::set_logger(&LOGGER).expect("no other logger has been initialized");
        log::set_max_level(level);
    }
}