Browse Source

Initial work with working config and ledger parsers.

Kestrel 1 day ago
commit
cc58184c4d
11 changed files with 503 additions and 0 deletions
  1. 2 0
      .gitignore
  2. 1 0
      .vimrc
  3. 23 0
      Cargo.toml
  4. 187 0
      src/data.rs
  5. 2 0
      src/data/account.rs
  6. 114 0
      src/data/ledger.rs
  7. 38 0
      src/data/root.rs
  8. 29 0
      src/data/spec.rs
  9. 83 0
      src/main.rs
  10. 8 0
      testdata/ledger
  11. 16 0
      testdata/root.toml

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+/target
+.*.sw?

+ 1 - 0
.vimrc

@@ -0,0 +1 @@
+set wildignore+=target

+ 23 - 0
Cargo.toml

@@ -0,0 +1,23 @@
+[package]
+name = "hoard"
+version = "0.0.1"
+edition = "2024"
+
+[dependencies]
+# core dependencies
+log = { version = "0.4.0" }
+anyhow = { version = "1" }
+stringstore = { version = "0.1.2" }
+
+# data processing dependencies
+commodity = { version = "0.4.0" }
+
+# i/o dependencies
+serde = { version = "1.0", features = ["derive"] }
+toml = { version = "0.8", features = [] }
+chumsky = { version = "0.10", features = [] }
+ariadne = { version = "0.5" }
+
+# cli dependencies
+pretty_env_logger = { version = "0.5.0" }
+clap = { version = "4.5", features = ["derive", "env"] }

+ 187 - 0
src/data.rs

@@ -0,0 +1,187 @@
+#![allow(unused)]
+
+use std::collections::HashMap;
+use std::rc::Rc;
+
+use ariadne::Cache;
+
+mod ledger;
+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";
+}
+pub type UnitName = stringstore::StoredString<UnitTag>;
+
+pub struct AccountTag;
+impl stringstore::NamespaceTag for AccountTag {
+    const PREFIX: &'static str = "acc";
+}
+pub type AccountName = stringstore::StoredString<AccountTag>;
+
+#[derive(Clone, Debug, PartialEq, Hash)]
+pub struct DataSource {
+    file: SourceFile,
+    range: std::ops::Range<usize>,
+}
+
+#[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),
+    Report(ariadne::Report<'static, (SourceFile, std::ops::Range<usize>)>),
+}
+
+impl std::fmt::Display for DataError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        <Self as std::fmt::Debug>::fmt(self, f)
+    }
+}
+
+impl std::error::Error for DataError {}
+
+#[derive(Debug)]
+pub struct Root {
+    path: std::path::PathBuf,
+    root_spec: spec::RootSpec,
+
+    ledger_data: Vec<ledger::LedgerEntry>,
+
+    account_ledger_data: HashMap<AccountName, Vec<ledger::LedgerEntry>>,
+}
+
+impl Root {
+    pub fn load<'l, 'm>(
+        fsdata: &'l mut FilesystemData,
+        path: &'m std::path::Path,
+    ) -> Result<Self, DataError> {
+        let sf = SourceFile::new(path.as_os_str());
+        let root_data = fsdata.fetch(&sf).unwrap();
+
+        match toml::from_str::<spec::RootSpec>(root_data.text()) {
+            Ok(root_spec) => {
+                let mut r = Self {
+                    path: path.into(),
+                    root_spec,
+                    ledger_data: vec![],
+                    account_ledger_data: Default::default(),
+                };
+
+                r.load_ledgers(fsdata)?;
+                r.preprocess_ledger_data();
+
+                Ok(r)
+            }
+            Err(te) => {
+                let Some(range) = te.span() else {
+                    panic!("TOML parse error with no range: {te}");
+                };
+
+                let report =
+                    ariadne::Report::build(ariadne::ReportKind::Error, (sf, range.clone()))
+                        .with_label(ariadne::Label::new((sf, range)).with_message(te.message()))
+                        .with_message("Failed to parse root TOML")
+                        .finish();
+
+                Err(DataError::Report(report))
+            }
+        }
+    }
+
+    fn load_ledger<'s, 'd>(
+        &'s mut self,
+        fsdata: &'d mut FilesystemData,
+        path: &mut std::path::PathBuf,
+    ) -> Result<(), DataError> {
+        log::debug!("Loading ledger data from {}", path.display());
+
+        let md = std::fs::metadata(path.as_path()).map_err(DataError::IOError)?;
+
+        if md.is_dir() {
+            // recurse
+            for de in std::fs::read_dir(path.as_path()).map_err(DataError::IOError)? {
+                let de = de.map_err(DataError::IOError)?;
+                path.push(de.file_name());
+
+                self.load_ledger(fsdata, path)?;
+
+                path.pop();
+            }
+        } else {
+            let s = SourceFile::new(path.as_os_str());
+            let data = fsdata.fetch(&s).unwrap();
+            self.ledger_data
+                .extend(ledger::parse_ledger(s, data.text())?);
+        }
+
+        Ok(())
+    }
+
+    fn load_ledgers<'s, 'd>(&'s mut self, fsdata: &'d mut FilesystemData) -> Result<(), DataError> {
+        let mut ledger_path = self.path.to_owned();
+        ledger_path.pop();
+        ledger_path.push(&self.root_spec.ledger_path);
+
+        self.load_ledger(fsdata, &mut ledger_path)?;
+
+        self.ledger_data.sort();
+
+        Ok(())
+    }
+
+    fn preprocess_ledger_data(&mut self) {
+        for entry in &self.ledger_data {
+            for bal in &entry.balances {
+                self.account_ledger_data
+                    .entry(bal.account)
+                    .or_default()
+                    .push(entry.clone());
+            }
+        }
+    }
+
+    pub fn ledger_data_for(&self, aname: AccountName) -> Option<&[ledger::LedgerEntry]> {
+        self.account_ledger_data.get(&aname).map(Vec::as_slice)
+    }
+}

+ 2 - 0
src/data/account.rs

@@ -0,0 +1,2 @@
+#[derive(Debug)]
+pub struct AccountData {}

+ 114 - 0
src/data/ledger.rs

@@ -0,0 +1,114 @@
+use super::{AccountName, UnitName};
+
+use chumsky::prelude::*;
+
+#[derive(Clone, Copy, Hash, PartialEq, PartialOrd, Debug, Ord, Eq)]
+pub enum Direction {
+    Deposit,
+    Withdrawal,
+}
+
+#[derive(Clone, Debug, PartialEq, PartialOrd, Ord, Eq)]
+pub struct Balance {
+    pub account: AccountName,
+    pub amount: usize,
+    pub dir: Direction,
+    pub unit: UnitName,
+}
+
+#[derive(Clone, Debug, PartialEq, PartialOrd, Eq, Ord)]
+pub struct LedgerEntry {
+    pub datestamp: (u16, u8, u8),
+    pub title: Option<String>,
+    pub balances: Vec<Balance>,
+}
+
+impl LedgerEntry {
+    pub fn modifies(&self, account: AccountName) -> bool {
+        self.balances
+            .iter()
+            .find(|b| b.account == account)
+            .is_some()
+    }
+}
+
+fn ledger_parser<'a>()
+-> impl Parser<'a, &'a str, Vec<LedgerEntry>, chumsky::extra::Err<chumsky::error::Rich<'a, char>>> {
+    let int = chumsky::text::digits(10)
+        .collect()
+        .map(|v: String| v.parse::<usize>().unwrap());
+
+    let datestamp = group((int, just('-').ignored(), int, just('-').ignored(), int))
+        .map(|(y, _, m, _, d)| (y as u16, m as u8, d as u8));
+
+    let mark = |m| {
+        chumsky::text::inline_whitespace()
+            .ignore_then(just(m))
+            .then_ignore(chumsky::text::inline_whitespace())
+    };
+
+    let balance = group((
+        mark('-'),
+        chumsky::text::ident().map(|v| stringstore::StoredString::new(v)),
+        mark(':'),
+        choice((
+            mark('-').map(|_| Direction::Withdrawal),
+            mark('+').map(|_| Direction::Deposit),
+        )),
+        int,
+        just('.'),
+        int,
+        chumsky::text::inline_whitespace(),
+        chumsky::primitive::none_of("\n")
+            .repeated()
+            .collect::<String>(),
+        chumsky::text::newline(),
+    ))
+    .map(|(_, acc, _, dir, w, _, f, _, unit, _)| Balance {
+        account: acc,
+        dir,
+        amount: w,
+        unit: "UNIT".into(),
+    });
+
+    let entry = group((
+        chumsky::text::whitespace(),
+        datestamp,
+        mark(':'),
+        chumsky::text::inline_whitespace(),
+        chumsky::primitive::none_of("\n")
+            .repeated()
+            .collect::<String>(),
+        chumsky::text::newline(),
+        balance.repeated().at_least(1).collect(),
+        chumsky::text::whitespace(),
+    ))
+    .map(|(_, datestamp, _, _, title, _, balances, _)| LedgerEntry {
+        datestamp,
+        title: (!title.is_empty()).then_some(title),
+        balances,
+    });
+
+    entry.repeated().collect()
+}
+
+pub fn parse_ledger(
+    source: super::SourceFile,
+    data: &str,
+) -> Result<Vec<LedgerEntry>, super::DataError> {
+    let parser = ledger_parser();
+
+    let (presult, errors) = parser.parse(data).into_output_errors();
+
+    if let Some(e) = errors.first() {
+        let span = e.span().start()..e.span().end();
+
+        let report = ariadne::Report::build(ariadne::ReportKind::Error, (source, span.clone()))
+            .with_label(ariadne::Label::new((source, span)).with_message(e.reason()))
+            .finish();
+
+        Err(crate::data::DataError::Report(report))
+    } else {
+        Ok(presult.unwrap())
+    }
+}

+ 38 - 0
src/data/root.rs

@@ -0,0 +1,38 @@
+use std::collections::HashMap;
+
+#[derive(Debug, serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+struct AccountSpec {
+    title: Option<String>,
+    description: Option<String>,
+
+    annotations: Option<HashMap<String, String>>,
+}
+
+#[derive(Debug, serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+struct RootSpec {
+    accounts: HashMap<String, AccountSpec>,
+
+    account_data: HashMap<String, AccountData>,
+}
+
+pub struct Root {
+    path: std::path::PathBuf,
+    data: RootSpec,
+}
+
+impl Root {
+    pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
+        let content = std::fs::read_to_string(path)?;
+        let data = toml::from_str::<RootSpec>(&content).expect("could not parse root");
+        Ok(Self {
+            path: path.into(),
+            data,
+        })
+    }
+
+    pub fn iter_accounts(&self) -> impl Iterator<Item = (&str, &AccountSpec)> {
+        self.data.accounts.iter().map(|v| (v.0.as_str(), v.1))
+    }
+}

+ 29 - 0
src/data/spec.rs

@@ -0,0 +1,29 @@
+use std::collections::HashMap;
+
+#[derive(Debug, serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AccountSpec {
+    title: Option<String>,
+    description: Option<String>,
+
+    annotations: Option<HashMap<String, String>>,
+
+    default_unit: Option<String>,
+}
+
+#[derive(Debug, serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct UnitSpec {
+    name: String,
+    format: String,
+}
+
+#[derive(Debug, serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct RootSpec {
+    pub ledger_path: std::path::PathBuf,
+
+    units: HashMap<String, UnitSpec>,
+
+    accounts: HashMap<String, AccountSpec>,
+}

+ 83 - 0
src/main.rs

@@ -0,0 +1,83 @@
+mod data;
+
+#[derive(clap::Parser)]
+struct Invocation {
+    /// path to treasure file
+    #[clap(long, short, env = "TREASURE_FILE")]
+    file: std::path::PathBuf,
+
+    /// verbosity of output messages
+    #[clap(long, short, action = clap::ArgAction::Count)]
+    verbose: u8,
+
+    #[clap(subcommand)]
+    cmd: Command,
+}
+
+#[derive(clap::Subcommand)]
+enum Command {
+    Summarize,
+    Ledger { account: String },
+}
+
+fn load_data(
+    fsdata: &mut data::FilesystemData,
+    invocation: &Invocation,
+) -> anyhow::Result<data::Root> {
+    match data::Root::load(fsdata, &invocation.file) {
+        Ok(data) => Ok(data),
+        Err(data::DataError::IOError(ioerror)) => Err(ioerror.into()),
+        Err(data::DataError::Report(report)) => {
+            report.eprint(fsdata)?;
+            Err(anyhow::anyhow!("Parse error"))
+        }
+    }
+}
+
+impl Command {
+    fn run(&self, inv: &Invocation) -> anyhow::Result<()> {
+        let mut fsdata = data::FilesystemData::default();
+
+        match self {
+            Self::Summarize => {
+                let data = load_data(&mut fsdata, inv)?;
+
+                /*for shortname in data.iter_account_shortnames() {
+                    println!("account {shortname}");
+                }*/
+            }
+            Self::Ledger { account } => {
+                let data = load_data(&mut fsdata, inv)?;
+
+                let aname = data::AccountName::new(account.as_str());
+                if let Some(ld) = data.ledger_data_for(aname) {
+                    for le in ld {
+                        log::info!("- le: {le:?}");
+                    }
+                } else {
+                    log::info!("account not found. data: {data:?}");
+                }
+
+                // let data = data::Root::load(&mut fsdata, &inv.file);
+
+                // data.account(account)?;
+            }
+        }
+        Ok(())
+    }
+}
+
+fn main() -> anyhow::Result<()> {
+    use clap::Parser;
+    let inv = Invocation::parse();
+
+    pretty_env_logger::formatted_builder()
+        .filter_level(match inv.verbose {
+            0 => log::LevelFilter::Info,
+            1 => log::LevelFilter::Debug,
+            _ => log::LevelFilter::Trace,
+        })
+        .init();
+
+    inv.cmd.run(&inv)
+}

+ 8 - 0
testdata/ledger

@@ -0,0 +1,8 @@
+2001-01-05: initial balance
+  - initial: -400.00
+  - chequing: +400.00
+
+2001-01-07: transfer to savings
+  - chequing: -300.00
+  - savings: +300.00
+

+ 16 - 0
testdata/root.toml

@@ -0,0 +1,16 @@
+ledger_path = "./ledger"
+
+[units]
+CAD = { name = "Canadian Dollar", format = "CA$" }
+
+[accounts.chequing]
+title = "Chequing"
+description = ""
+annotations = { abc = "def" }
+default_unit = "CAD"
+
+[accounts.savings]
+default_unit = "CAD"
+
+[accounts.loan]
+default_unit = "CAD"