Browse Source

Introduce io module, improve import and export.

Kestrel 2 weeks ago
parent
commit
2ce18cab06
11 changed files with 258 additions and 155 deletions
  1. 1 0
      Cargo.toml
  2. 20 8
      src/check.rs
  3. 33 9
      src/cmd.rs
  4. 24 70
      src/data.rs
  5. 58 28
      src/data/ledger/print.rs
  6. 5 5
      src/data/spec.rs
  7. 28 30
      src/import.rs
  8. 83 0
      src/io.rs
  9. 1 0
      src/main.rs
  10. 1 4
      src/show.rs
  11. 4 1
      testdata/root.toml

+ 1 - 0
Cargo.toml

@@ -18,6 +18,7 @@ chumsky = { version = "0.10", features = ["lexical-numbers"] }
 ariadne = { version = "0.5" }
 csv = { version = "1.3" }
 strptime = { version = "1.1" }
+tempfile = { version = "3.20" }
 
 # cli dependencies
 pretty_env_logger = { version = "0.5.0" }

+ 20 - 8
src/check.rs

@@ -1,8 +1,9 @@
 use std::collections::BTreeMap;
 
-use chumsky::span::Span;
+use chumsky::span::Span as CSpan;
+use ariadne::Span as ASpan;
 
-use crate::data::{DataError, Decimal, Root, UnitName};
+use crate::data::{DataError, Decimal, Root, Span, Spanned, UnitName};
 
 fn check_equal_sum(root: &Root) -> Result<(), DataError> {
     log::debug!("Checking for equal sums in monounit ledger entries...");
@@ -100,20 +101,31 @@ fn check_balances(root: &Root) -> Result<(), DataError> {
             continue;
         };
 
-        let mut running_balance = BTreeMap::<UnitName, Decimal>::new();
+        let mut running_balance = BTreeMap::<UnitName, Spanned<Decimal>>::new();
         for txn in ledger {
             let change = txn.change_for(account).unwrap();
-            let bal = running_balance.entry(*change.unit).or_default();
-            *bal = bal.checked_add(*change.amount).unwrap();
+            let bal = running_balance.entry(*change.unit).or_insert_with(|| Spanned::new(Decimal::default(), Span::default()));
+            let last_span = bal.span();
+            bal.0 = bal.checked_add(*change.amount).unwrap();
+            bal.1 = change.1;
 
             if let Some(sbal) = change.balance.as_ref() {
-                if **sbal != *bal {
-                    return Err(
+                if **sbal != bal.0 {
+                    let report = 
                         ariadne::Report::build(ariadne::ReportKind::Error, txn.span())
                             .with_label(ariadne::Label::new(sbal.span()).with_message(format!(
                                 "Calculated balance is {} {}, specified balance is {} {}",
                                 bal, change.unit, sbal, change.unit
-                            )))
+                            )));
+                    let report =
+                        if !last_span.is_empty() {
+                            report.with_label(ariadne::Label::new(last_span).with_message(format!("Last balance is from here")))
+                        } else {
+                            report
+                        };
+
+                    return Err(
+                        report
                             .finish()
                             .into(),
                     );

+ 33 - 9
src/cmd.rs

@@ -1,6 +1,6 @@
 use itertools::Itertools;
 
-use crate::{data, import::import_from, show};
+use crate::{data, import::import_from, io, show};
 
 #[derive(clap::Parser)]
 pub struct Invocation {
@@ -17,10 +17,11 @@ pub struct Invocation {
 }
 
 fn load_data(
-    fsdata: &mut data::FilesystemData,
+    fsdata: &mut io::FilesystemData,
     invocation: &Invocation,
 ) -> anyhow::Result<data::Root> {
-    match data::Root::load(fsdata, &invocation.file) {
+    let path = std::fs::canonicalize(&invocation.file)?;
+    match data::Root::load(fsdata, &path) {
         Ok(data) => Ok(data),
         Err(data::DataError::IOError(ioerror)) => Err(ioerror.into()),
         Err(data::DataError::Report(report)) => {
@@ -41,6 +42,7 @@ pub enum Command {
     Import {
         account: String,
         from: std::path::PathBuf,
+        target: Option<std::path::PathBuf>,
     },
 }
 
@@ -85,7 +87,7 @@ fn summarize(data: &data::Root) {
 
 impl Command {
     pub fn run(&self, inv: &Invocation) -> anyhow::Result<()> {
-        let mut fsdata = data::FilesystemData::default();
+        let mut fsdata = io::FilesystemData::default();
 
         match self {
             Self::Summarize => {
@@ -107,9 +109,13 @@ impl Command {
             Self::Reformat => {
                 let data = load_data(&mut fsdata, inv)?;
 
-                data::ledger::print_ledger(&data, data.all_ledger_data().iter());
+                data::ledger::print_ledger(&mut fsdata, &data, data.all_ledger_data().iter())?;
             }
-            Self::Import { account, from } => {
+            Self::Import {
+                account,
+                from,
+                target,
+            } => {
                 let data = load_data(&mut fsdata, inv)?;
 
                 let aname = account.into();
@@ -118,9 +124,27 @@ impl Command {
                 };
 
                 let imported = import_from(aspec, aname, from.as_path()).unwrap();
-
-                let tt = show::TransactionTable::default();
-                tt.show(&data, aname, imported.iter());
+                log::info!("Imported {} transactions", imported.len());
+
+                if let Some(target) = target {
+                    let new_source = std::fs::canonicalize(target.as_os_str())?.into_os_string().into();
+                    let span = data::Span::null_for_file(new_source);
+
+                    let new_data = imported
+                        .into_iter()
+                        .map(|txn| data::ledger::LedgerEntry::Transaction(data::Spanned(txn, span)))
+                        .collect_vec();
+
+                    data::ledger::print_ledger(
+                        &mut fsdata,
+                        &data,
+                        data.ledger_data_from(new_source).chain(new_data.iter()),
+                    )?;
+                } else {
+                    log::info!("No target specified, showing new data on stdout.");
+                    let tt = show::TransactionTable::default();
+                    tt.show(&data, aname, imported.iter());
+                }
             }
         }
         Ok(())

+ 24 - 70
src/data.rs

@@ -5,15 +5,11 @@ use chumsky::span::Span as _;
 
 pub use rust_decimal::Decimal;
 
+use crate::io::{FilesystemData, SourceFile};
+
 pub mod ledger;
 pub mod spec;
 
-pub struct LocationTag;
-impl stringstore::NamespaceTag for LocationTag {
-    const PREFIX: &'static str = "loc";
-}
-pub type SourceFile = stringstore::StoredOsString<LocationTag>;
-
 pub struct UnitTag;
 impl stringstore::NamespaceTag for UnitTag {
     const PREFIX: &'static str = "unit";
@@ -26,48 +22,6 @@ impl stringstore::NamespaceTag for AccountTag {
 }
 pub type AccountName = stringstore::StoredString<AccountTag>;
 
-#[derive(Clone, Debug, PartialEq, Hash)]
-pub struct DataSource {
-    file: SourceFile,
-    range: std::ops::Range<usize>,
-}
-
-/// Helper for accessing data on the filesystem
-#[derive(Default)]
-pub struct FilesystemData {
-    file_data: HashMap<SourceFile, ariadne::Source<std::rc::Rc<str>>>,
-}
-
-impl std::fmt::Debug for FilesystemData {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        f.debug_struct("FilesystemData").finish()
-    }
-}
-
-impl ariadne::Cache<SourceFile> for FilesystemData {
-    type Storage = std::rc::Rc<str>;
-    fn fetch(
-        &mut self,
-        id: &SourceFile,
-    ) -> Result<&ariadne::Source<Self::Storage>, impl std::fmt::Debug> {
-        if !self.file_data.contains_key(id) {
-            match std::fs::read_to_string(id.as_str()) {
-                Ok(data) => {
-                    let data: std::rc::Rc<_> = std::rc::Rc::from(data.into_boxed_str());
-                    self.file_data.insert(*id, ariadne::Source::from(data));
-                }
-                Err(e) => return Err(e),
-            }
-        }
-
-        Ok(self.file_data.get(id).unwrap())
-    }
-
-    fn display<'a>(&self, id: &'a SourceFile) -> Option<impl std::fmt::Display + 'a> {
-        Some(id.as_str().to_string_lossy())
-    }
-}
-
 #[derive(Debug)]
 pub enum DataError {
     IOError(std::io::Error),
@@ -101,6 +55,15 @@ pub struct Span {
     context: Option<SourceFile>,
 }
 
+impl Span {
+    pub fn null_for_file(source: SourceFile) -> Self {
+        Self {
+            range: (usize::MAX, usize::MAX),
+            context: Some(source),
+        }
+    }
+}
+
 impl Default for Span {
     fn default() -> Self {
         Self {
@@ -253,24 +216,7 @@ impl Root {
         let root_data = fsdata.fetch(&sf).unwrap();
 
         match toml::from_str::<spec::SpecRoot>(root_data.text()) {
-            Ok(mut spec_root) => {
-                let initial_name = AccountName::from("initial");
-                if let std::collections::hash_map::Entry::Vacant(ve) =
-                    spec_root.accounts.entry(initial_name)
-                {
-                    ve.insert(spec::AccountSpec {
-                        title: Some(String::from("initial balances")),
-                        description: None,
-                        annotations: None,
-                        unit: None,
-                        import: None,
-                    });
-                } else {
-                    return Err(DataError::Validation(String::from(
-                        "cannot define 'initial' account, as it is a built-in",
-                    )));
-                }
-
+            Ok(spec_root) => {
                 let mut r = Self {
                     path: path.into(),
                     spec_root,
@@ -323,7 +269,7 @@ impl Root {
                 path.pop();
             }
         } else {
-            let s = SourceFile::new(path.as_os_str());
+            let s = SourceFile::new(std::fs::canonicalize(path)?.as_os_str());
             let data = fsdata.fetch(&s).unwrap();
             self.ledger_data
                 .extend(ledger::parse_ledger(s, &self.spec_root, data.text())?);
@@ -333,14 +279,13 @@ impl Root {
     }
 
     fn load_ledgers(&mut self, fsdata: &mut FilesystemData) -> Result<(), DataError> {
-        let mut ledger_path = self.path.to_owned();
+        let mut ledger_path = std::fs::canonicalize(self.path.as_path())?;
         ledger_path.pop();
         ledger_path.push(&self.spec_root.ledger_path);
+        let mut ledger_path = std::fs::canonicalize(ledger_path)?;
 
         self.load_ledger(fsdata, &mut ledger_path)?;
 
-        self.ledger_data.sort();
-
         Ok(())
     }
 
@@ -370,6 +315,15 @@ impl Root {
         self.account_ledger_data.get(&aname).map(Vec::as_slice)
     }
 
+    pub fn ledger_data_from(
+        &self,
+        source: SourceFile,
+    ) -> impl Iterator<Item = &ledger::LedgerEntry> {
+        self.all_ledger_data()
+            .iter()
+            .filter(move |le| le.span().context == Some(source))
+    }
+
     pub fn account_names(&self) -> impl Iterator<Item = AccountName> {
         self.spec_root.accounts.keys().cloned()
     }

+ 58 - 28
src/data/ledger/print.rs

@@ -6,14 +6,25 @@ use crate::data::{Root, SourceFile, Span, Spanned};
 
 use super::{LedgerEntry, Transaction};
 
-fn print_transaction(root: &Root, tx: &Transaction, padding: usize) {
-    println!("{}: {}", tx.datestamp, tx.title.as_deref().unwrap_or(""));
+fn print_transaction(
+    target: &mut dyn std::io::Write,
+    root: &Root,
+    tx: &Transaction,
+    padding: usize,
+) -> std::io::Result<()> {
+    writeln!(
+        target,
+        "{}: {}",
+        tx.datestamp,
+        tx.title.as_deref().unwrap_or("")
+    )?;
 
     if !tx.annotations.is_empty() {
-        println!(
+        writeln!(
+            target,
             "    {}",
             tx.annotations.iter().map(|a| format!("[{a}]")).join(" ")
-        );
+        )?;
     }
 
     let force_show = tx.changes.iter().unique_by(|b| *b.account).count() > 1;
@@ -31,57 +42,76 @@ fn print_transaction(root: &Root, tx: &Transaction, padding: usize) {
             String::new()
         };
 
-        if Some(change.unit.as_ref()) == root.account_spec(*change.account).unwrap().unit.as_ref()
+        if Some(*change.unit) == root.account_spec(*change.account).and_then(|s| s.unit)
             && !force_show
         {
-            println!(
+            writeln!(
+                target,
                 " - {:padding$}: {spacer}{}{balance}",
                 change.account, change.amount,
-            );
+            )?;
         } else {
-            println!(
+            writeln!(
+                target,
                 " - {:padding$}: {spacer}{}{balance} {}",
                 change.account, change.amount, change.unit,
-            );
+            )?;
         }
     }
     // empty line afterwards
-    println!();
+    writeln!(target)
 }
 
-fn print_comment(c: &Spanned<String>) {
-    println!("{c}");
+fn print_comment(target: &mut dyn std::io::Write, c: &Spanned<String>) -> std::io::Result<()> {
+    writeln!(target, "{c}")
 }
 
-pub fn print_ledger<'l>(root: &Root, entries: impl Iterator<Item = &'l LedgerEntry>) {
-    let mut ordering = BTreeMap::<Option<SourceFile>, BTreeMap<Span, &LedgerEntry>>::new();
+pub fn print_ledger<'l>(
+    fsdata: &mut crate::io::FilesystemData,
+    root: &Root,
+    entries: impl Iterator<Item = &'l LedgerEntry>,
+) -> std::io::Result<()> {
+    let mut ordering = BTreeMap::<Option<SourceFile>, BTreeMap<Span, Vec<&LedgerEntry>>>::new();
 
     entries.for_each(|e| {
         ordering
             .entry(e.span().context)
             .or_default()
-            .insert(e.span(), &e);
+            .entry(e.span())
+            .or_default()
+            .push(e);
     });
 
     let Some(padding) = root.spec_root.accounts.keys().map(|k| k.len()).max() else {
         // no accounts
-        return;
+        return Ok(());
     };
 
     for (source, entries) in ordering {
-        if let Some(filename) = source {
-            println!(
-                "==== file {} ====",
-                std::path::Path::new(filename.as_ref()).display()
-            );
-        } else {
-            println!("==== new data ====");
-        }
-        for (_span, le) in entries {
-            match le {
-                LedgerEntry::Transaction(tx) => print_transaction(root, tx, padding),
-                LedgerEntry::Comment(c) => print_comment(c),
+        let Some(filename) = source else {
+            log::warn!("Skipping ledger content with no source file!");
+            continue;
+        };
+        log::info!(
+            "Updating ledger file {} ...",
+            std::path::Path::new(filename.as_str()).display()
+        );
+
+        let mut outfile = fsdata.try_write(filename)?;
+
+        for (_span, mut les) in entries {
+            les.sort_by_key(|le| le.as_transaction().map(|tx| tx.datestamp));
+            for le in les {
+                match le {
+                    LedgerEntry::Transaction(tx) => {
+                        print_transaction(&mut outfile, root, tx, padding)?
+                    }
+                    LedgerEntry::Comment(c) => print_comment(&mut outfile, c)?,
+                }
             }
         }
+
+        outfile.commit()?;
     }
+    Ok(())
 }

+ 5 - 5
src/data/spec.rs

@@ -4,7 +4,7 @@ use super::{AccountName, UnitName};
 
 #[derive(Debug, serde::Deserialize, PartialEq)]
 #[serde(deny_unknown_fields, rename_all = "snake_case")]
-pub enum CSVColumnSpec {
+pub enum CsvColumnSpec {
     Ignore,
     Datestamp,
     Title,
@@ -17,17 +17,17 @@ pub enum CSVColumnSpec {
 
 #[derive(Debug, serde::Deserialize)]
 #[serde(deny_unknown_fields)]
-pub struct CSVImportSpec {
+pub struct CsvImportSpec {
     pub skip_start: Option<usize>,
     pub skip_end: Option<usize>,
-    pub cols: Vec<CSVColumnSpec>,
+    pub cols: Vec<CsvColumnSpec>,
     pub date_format: String,
 }
 
 #[derive(Debug, serde::Deserialize)]
-#[serde(deny_unknown_fields, tag = "format")]
+#[serde(deny_unknown_fields, tag = "format", rename_all = "snake_case")]
 pub enum ImportFileFormat {
-    CSV(CSVImportSpec),
+    Csv(CsvImportSpec),
 }
 
 #[derive(Debug, serde::Deserialize)]

+ 28 - 30
src/import.rs

@@ -1,7 +1,7 @@
 use crate::data::{
-    AccountName, Datestamp, Decimal, Span, Spanned, UnitName,
+    AccountName, Datestamp, Decimal, UnitName,
     ledger::{Change, Transaction},
-    spec::{AccountSpec, CSVColumnSpec, CSVImportSpec, ImportFileFormat},
+    spec::{AccountSpec, CsvColumnSpec, CsvImportSpec, ImportFileFormat},
 };
 
 #[derive(Debug)]
@@ -9,7 +9,7 @@ pub enum ImportError {
     IOError(std::io::Error),
     ConfigError(String),
     InputError(String),
-    CSVError(csv::Error),
+    CsvError(csv::Error),
 }
 
 impl From<std::io::Error> for ImportError {
@@ -20,7 +20,7 @@ impl From<std::io::Error> for ImportError {
 
 impl From<csv::Error> for ImportError {
     fn from(value: csv::Error) -> Self {
-        Self::CSVError(value)
+        Self::CsvError(value)
     }
 }
 
@@ -47,29 +47,27 @@ fn try_parse_decimal(from: &str) -> Result<Decimal, ImportError> {
 }
 
 fn import_from_csv(
-    csv_spec: &CSVImportSpec,
+    csv_spec: &CsvImportSpec,
     aspec: &AccountSpec,
     target: AccountName,
-    mut reader: impl std::io::Read,
+    reader: impl std::io::Read,
 ) -> Result<Vec<Transaction>, ImportError> {
     let mut csv_reader = csv::Reader::from_reader(reader);
 
     // validate CSV spec
-    if !csv_spec.cols.contains(&CSVColumnSpec::Datestamp) {
+    if !csv_spec.cols.contains(&CsvColumnSpec::Datestamp) {
         return Err(ImportError::ConfigError(
             "CSV config does not have a datestamp column".into(),
         ));
     }
 
-    if !csv_spec.cols.contains(&CSVColumnSpec::Change) {
-        if !csv_spec.cols.contains(&CSVColumnSpec::Withdraw)
-            || !csv_spec.cols.contains(&CSVColumnSpec::Deposit)
-        {
-            return Err(ImportError::ConfigError(
-                "CSV config needs either a change column or both withdraw and deposit columns!"
-                    .into(),
-            ));
-        }
+    if !csv_spec.cols.contains(&CsvColumnSpec::Change)
+        && (!csv_spec.cols.contains(&CsvColumnSpec::Withdraw)
+            || !csv_spec.cols.contains(&CsvColumnSpec::Deposit))
+    {
+        return Err(ImportError::ConfigError(
+            "CSV config needs either a change column or both withdraw and deposit columns!".into(),
+        ));
     }
 
     // strptime is silly and wants a &'static format string
@@ -91,8 +89,8 @@ fn import_from_csv(
 
         for (record, spec) in record.iter().zip(csv_spec.cols.iter()) {
             match spec {
-                CSVColumnSpec::Ignore => (),
-                CSVColumnSpec::Datestamp => {
+                CsvColumnSpec::Ignore => (),
+                CsvColumnSpec::Datestamp => {
                     let date = date_parser.parse(record)?.date()?;
                     txn_datestamp = Some(Datestamp {
                         year: date.year() as u16,
@@ -100,17 +98,17 @@ fn import_from_csv(
                         day: date.day(),
                     });
                 }
-                CSVColumnSpec::Title => {
+                CsvColumnSpec::Title => {
                     txn_title = Some(record.into());
                 }
-                CSVColumnSpec::Deposit => {
+                CsvColumnSpec::Deposit => {
                     if record.trim().is_empty() {
                         continue;
                     }
 
                     txn_change = Some(try_parse_decimal(record)?);
                 }
-                CSVColumnSpec::Withdraw => {
+                CsvColumnSpec::Withdraw => {
                     if record.trim().is_empty() {
                         continue;
                     }
@@ -118,21 +116,21 @@ fn import_from_csv(
                     dec.set_sign_negative(true);
                     txn_change = Some(dec);
                 }
-                CSVColumnSpec::Change => {
+                CsvColumnSpec::Change => {
                     if record.trim().is_empty() {
                         continue;
                     }
 
                     txn_change = Some(try_parse_decimal(record)?);
                 }
-                CSVColumnSpec::Balance => {
+                CsvColumnSpec::Balance => {
                     if record.trim().is_empty() {
                         continue;
                     }
 
                     txn_balance = Some(try_parse_decimal(record)?);
                 }
-                CSVColumnSpec::Unit => {
+                CsvColumnSpec::Unit => {
                     txn_unit = Some(UnitName::new(record));
                 }
             }
@@ -152,16 +150,16 @@ fn import_from_csv(
                 .into(),
                 Change {
                     account: unbalanced.into(),
-                    amount: txn_change.unwrap().into(),
-                    balance: txn_balance.map(Into::into),
+                    amount: Decimal::ZERO
+                        .checked_sub(txn_change.unwrap())
+                        .unwrap()
+                        .into(),
+                    balance: None,
                     unit: txn_unit.or(aspec.unit).map(Into::into).unwrap(),
                 }
                 .into(),
             ],
         });
-
-        // println!("{txn_datestamp:?}: {txn_title:?}");
-        // println!("- account: {txn_change:?} = {txn_balance:?} {txn_unit:?}");
     }
 
     Ok(txns)
@@ -175,7 +173,7 @@ pub fn import_from(
     let reader = std::fs::File::open(path)?;
 
     match &aspec.import {
-        Some(ImportFileFormat::CSV(csv)) => import_from_csv(csv, aspec, target, reader),
+        Some(ImportFileFormat::Csv(csv)) => import_from_csv(csv, aspec, target, reader),
         None => Err(ImportError::ConfigError(format!(
             "no import configuration for {target}"
         ))),

+ 83 - 0
src/io.rs

@@ -0,0 +1,83 @@
+use std::collections::HashMap;
+
+pub struct SourceTag;
+impl stringstore::NamespaceTag for SourceTag {
+    const PREFIX: &'static str = "loc";
+}
+/// Canonicalized path to a file
+pub type SourceFile = stringstore::StoredOsString<SourceTag>;
+
+/// Helper for accessing data on the filesystem
+#[derive(Default)]
+pub struct FilesystemData {
+    file_data: HashMap<SourceFile, ariadne::Source<std::rc::Rc<str>>>,
+}
+
+impl FilesystemData {
+    pub fn try_write(&self, file: SourceFile) -> std::io::Result<FileOutput> {
+        let data_path = std::path::Path::new(file.as_str()).parent().unwrap();
+        let tmpfile = tempfile::Builder::new()
+            .prefix(".")
+            .suffix(".tmp")
+            .tempfile_in(data_path)?;
+
+        Ok(FileOutput {
+            target: file,
+            tmpfile,
+        })
+    }
+}
+
+pub struct FileOutput {
+    target: SourceFile,
+    tmpfile: tempfile::NamedTempFile<std::fs::File>,
+}
+
+impl FileOutput {
+    pub fn cancel(self) -> std::io::Result<()> {
+        self.tmpfile.close()
+    }
+    pub fn commit(self) -> std::io::Result<()> {
+        self.tmpfile.persist(self.target.as_str())?;
+        Ok(())
+    }
+}
+
+impl std::io::Write for FileOutput {
+    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
+        self.tmpfile.write(buf)
+    }
+    fn flush(&mut self) -> std::io::Result<()> {
+        self.tmpfile.flush()
+    }
+}
+
+impl std::fmt::Debug for FilesystemData {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("FilesystemData").finish()
+    }
+}
+
+impl ariadne::Cache<SourceFile> for FilesystemData {
+    type Storage = std::rc::Rc<str>;
+    fn fetch(
+        &mut self,
+        id: &SourceFile,
+    ) -> Result<&ariadne::Source<Self::Storage>, impl std::fmt::Debug> {
+        if !self.file_data.contains_key(id) {
+            match std::fs::read_to_string(id.as_str()) {
+                Ok(data) => {
+                    let data: std::rc::Rc<_> = std::rc::Rc::from(data.into_boxed_str());
+                    self.file_data.insert(*id, ariadne::Source::from(data));
+                }
+                Err(e) => return Err(e),
+            }
+        }
+
+        Ok(self.file_data.get(id).unwrap())
+    }
+
+    fn display<'a>(&self, id: &'a SourceFile) -> Option<impl std::fmt::Display + 'a> {
+        Some(id.as_str().to_string_lossy())
+    }
+}

+ 1 - 0
src/main.rs

@@ -2,6 +2,7 @@ mod check;
 mod cmd;
 mod data;
 mod import;
+mod io;
 mod show;
 
 fn main() -> anyhow::Result<()> {

+ 1 - 4
src/show.rs

@@ -1,5 +1,3 @@
-use std::fmt::Display;
-
 use console::Style;
 
 use crate::data::{
@@ -29,7 +27,7 @@ enum Row {
     Data(Vec<String>),
 }
 
-fn show_table<'d>(cols: Vec<Column>, rows: impl Iterator<Item = Row>) {
+fn show_table(cols: Vec<Column>, rows: impl Iterator<Item = Row>) {
     let mut min_widths = vec![0usize; cols.len()];
 
     let table_data = rows.collect::<Vec<_>>();
@@ -50,7 +48,6 @@ fn show_table<'d>(cols: Vec<Column>, rows: impl Iterator<Item = Row>) {
                     for _ in 0..=*col {
                         print!("{}", boxy::Char::horizontal(boxy::Weight::Normal));
                     }
-                    // print!("{:-width$}", "", width = col + 1);
                     if cols[idx].right_border {
                         print!("{}", boxy::Char::cross(boxy::Weight::Normal));
                     }

+ 4 - 1
testdata/root.toml

@@ -4,6 +4,9 @@ ledger_path = "./ledger"
 CAD = { name = "Canadian Dollar", precision = 2 }
 USD = { name = "United States Dollar", precision = 2 }
 
+[accounts.initial]
+[accounts.unbalanced]
+
 [accounts.chequing]
 title = "Chequing"
 description = ""
@@ -13,7 +16,7 @@ unit = "CAD"
 [accounts.savings]
 unit = "CAD"
 
-import = { format = "CSV", skip_start = 1, cols = ["datestamp", "title", "change", "balance"], date_format = "%d %b %Y" }
+import = { format = "csv", skip_start = 1, cols = ["datestamp", "title", "change", "balance"], date_format = "%d %b %Y" }
 
 [accounts.savings_usd]
 unit = "USD"