...
 
Commits (5)
......@@ -54,6 +54,11 @@ The default command when none is specified is to list all entries in the
database.
### `check`
Loads and verifies all database entries.
### `currencies`
Lists all unique currencies from all entries.
......
......@@ -49,10 +49,8 @@ fn cache_stale(path: &std::path::Path, max_age: u64) -> std::io::Result<bool> {
.duration_since(meta.modified().unwrap())
.unwrap()
> std::time::Duration::new(max_age, 0)),
Err(e) => match e.kind() {
std::io::ErrorKind::NotFound => Ok(true),
_ => Err(e),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(true),
Err(e) => Err(e),
}
}
......
use std::convert::TryFrom;
use std::io::prelude::*;
use super::dec;
......@@ -24,14 +25,29 @@ pub struct Entry {
}
impl Entry {
// TODO more detailed errors
pub fn from_line(l: &str) -> Entry {
pub fn from_line(l: &str) -> Result<Entry, EntryParseError> {
let mut fields = l.split(' ');
let date = fields.next().unwrap();
let value = fields.next().unwrap();
let date = match fields.next() {
None | Some("") => return Err(
EntryParseError::new(String::from("missing date"))),
Some(x) => x,
};
let value = fields.next()
.ok_or_else(||
EntryParseError::new(String::from("missing amount")))
.and_then(|x|
if x.len() < 7 {
Err(EntryParseError::new(
format!(r#"invalid amount "{}""#, x)))
} else {
Ok(x)
}
)?;
let mut currency = value[value.len() - 3..].bytes();
let tag = fields.next().unwrap().bytes().nth(0).unwrap();
Entry {
let tag = fields.next()
.ok_or_else(|| EntryParseError::new(String::from("missing tag")))?
.bytes().next().unwrap();
Ok(Entry {
date: String::from(date),
value: dec::Decimal::try_from(&value[..value.len() - 3])
.expect("invalid decimal in entry"),
......@@ -42,7 +58,7 @@ impl Entry {
],
tag,
text: String::from(&l[date.len() + value.len() + 4..]),
}
})
}
pub fn to_line(&self) -> String {
......@@ -95,32 +111,44 @@ impl Entry {
)
}
pub fn read_db_file(
r: &mut impl std::io::Read,
v: &mut Vec<Entry>,
) -> std::io::Result<()> {
let mut s = String::new();
r.read_to_string(&mut s)?;
Ok(s.lines()
.take_while(|&x| x != "")
.map(Entry::from_line)
.for_each(|x| v.push(x)))
pub fn check_db(path: &std::path::Path) -> Result<(), DBError> {
match DBIterator::new(path)?.find(Result::is_err) {
Some(e) => Err(e.unwrap_err()),
None => Ok(()),
}
}
pub fn read_db(path: &std::path::Path) -> std::io::Result<Vec<Entry>> {
use std::io::Result;
let mut ret = Vec::new();
let mut files =
Find::new(path).collect::<Result<Vec<std::path::PathBuf>>>()?;
files.sort();
files
.iter()
.filter(|x| x.extension().unwrap_or_default() == "txt")
.map(|x|
Entry::read_db_file(
&mut std::fs::File::open(x)?, &mut ret))
.collect::<Result<()>>()
.and(Ok(ret))
pub fn read_db(path: &std::path::Path) -> Result<Vec<Entry>, DBError> {
DBIterator::new(path)?.collect()
}
}
#[derive(Debug)]
pub struct EntryParseError {
msg: String,
}
impl EntryParseError {
fn new(msg: String) -> EntryParseError {
EntryParseError { msg }
}
}
#[derive(Debug)]
pub enum DBError {
ParseError(EntryParseError),
IOError(std::io::Error),
}
impl From<EntryParseError> for DBError {
fn from(e: EntryParseError) -> DBError {
Self::ParseError(e)
}
}
impl From<std::io::Error> for DBError {
fn from(e: std::io::Error) -> DBError {
Self::IOError(e)
}
}
......@@ -147,7 +175,7 @@ impl Iterator for Find {
fn next(&mut self) -> Option<Self::Item> {
while let Some(x) = self.stack.pop() {
if !x.is_dir() {
if x.is_file() {
return Some(Ok(x));
}
if let Err(e) = self.read_dir(&x) {
......@@ -158,6 +186,76 @@ impl Iterator for Find {
}
}
#[derive(Debug)]
struct DBIterator {
files: Vec<std::path::PathBuf>,
file_it: Option<FileIterator>,
}
impl DBIterator {
fn new(path: &std::path::Path) -> std::io::Result<DBIterator> {
let mut files = Find::new(path)
.collect::<std::io::Result<Vec<std::path::PathBuf>>>()?;
files.retain(|x| x.extension().unwrap_or_default() == "txt");
files.sort();
files.reverse();
Ok(DBIterator {
files,
file_it: None,
})
}
}
impl Iterator for DBIterator {
type Item = Result<Entry, DBError>;
fn next(&mut self) -> Option<Self::Item> {
loop {
if self.file_it.is_none() {
match self.files.pop() {
None => return None,
Some(x) => match FileIterator::new(&x) {
Ok(x) => self.file_it = Some(x),
Err(e) => return Some(Err(e.into())),
},
}
}
if let Some(x) = self.file_it.as_mut().unwrap().next() {
return Some(x);
}
self.file_it = None;
}
}
}
#[derive(Debug)]
struct FileIterator {
lines: std::io::Lines<std::io::BufReader<std::fs::File>>,
}
impl FileIterator {
fn new(path: &std::path::Path) -> std::io::Result<FileIterator> {
Ok(FileIterator {
lines: std::io::BufReader::new(std::fs::File::open(path)?).lines(),
})
}
}
impl Iterator for FileIterator {
type Item = Result<Entry, DBError>;
fn next(&mut self) -> Option<Self::Item> {
match self.lines.next() {
None => None,
Some(x) => match x {
Err(e) => Some(Err(e.into())),
Ok(x) if x == "" => None,
Ok(x) => Some(Entry::from_line(&x).map_err(Into::into)),
}
}
}
}
#[cfg(test)]
mod tests {
use super::Entry;
......@@ -169,8 +267,8 @@ mod tests {
#[test]
fn from_line() {
let e = Entry::from_line(
"2020-04-20 -100.00eur t description");
let e = Entry::from_line("2020-04-20 -100.00eur t description")
.unwrap();
assert_eq!(e.date, "2020-04-20");
assert_eq!(e.value, super::dec::Decimal::new(-100.0));
assert_eq!(e.currency, EUR);
......@@ -178,6 +276,20 @@ mod tests {
assert_eq!(e.text, "description");
}
#[test]
fn parse_error() {
assert_eq!(&Entry::from_line("").unwrap_err().msg, "missing date");
assert_eq!(
&Entry::from_line("2020-05-07").unwrap_err().msg,
"missing amount");
assert_eq!(
&Entry::from_line("2020-05-07 a").unwrap_err().msg,
r#"invalid amount "a""#);
assert_eq!(
&Entry::from_line("2020-05-07 1.00eur").unwrap_err().msg,
"missing tag");
}
#[test]
fn to_line() {
let e = Entry {
......@@ -259,35 +371,4 @@ mod tests {
dec::Decimal::new(-1500.0),
));
}
#[test]
fn read_db_file() -> std::io::Result<()> {
let input = b"\
2020-04-18 -100.00eur t description0
2020-04-19 200.00usd u description1
2020-04-20 -300.00gbp v description2
";
let mut v = Vec::new();
Entry::read_db_file(&mut &input[..], &mut v)?;
assert_eq!(v, vec![Entry {
date: String::from("2020-04-18"),
value: dec::Decimal::new(-100.0),
currency: EUR,
tag: b't',
text: String::from("description0"),
}, Entry {
date: String::from("2020-04-19"),
value: dec::Decimal::new(200.0),
currency: USD,
tag: b'u',
text: String::from("description1"),
}, Entry {
date: String::from("2020-04-20"),
value: dec::Decimal::new(-300.0),
currency: GBP,
tag: b'v',
text: String::from("description2"),
}]);
Ok(())
}
}
......@@ -15,6 +15,7 @@ fn usage() {
Commands:
<none> List all entries.
check Verify database entries.
currencies List all currencies present in the database.
update-cache Force an update of the currency exchange cache
file.
......@@ -26,11 +27,17 @@ Commands:
)
}
fn parse_args() -> Option<(std::path::PathBuf, Vec<String>)> {
struct Configuration {
exe: String,
dir: std::path::PathBuf,
args: Vec<String>,
}
fn parse_args() -> Option<Configuration> {
let mut dir = std::path::PathBuf::new();
let mut pos = Vec::new();
let mut args = std::env::args();
args.next().unwrap();
let exe = args.next().unwrap();
loop {
match args.next() {
None => break,
......@@ -52,7 +59,7 @@ fn parse_args() -> Option<(std::path::PathBuf, Vec<String>)> {
.join(PROG_NAME)
.join("db");
}
Some((dir, pos))
Some(Configuration { exe, dir, args: pos })
}
fn cmd_list(d: &std::path::Path) {
......@@ -61,6 +68,10 @@ fn cmd_list(d: &std::path::Path) {
}
}
fn cmd_check(d: &std::path::Path) {
db::Entry::check_db(&d).unwrap();
}
fn cmd_currencies(d: &std::path::Path) {
for x in db::Entry::unique_currencies(&db::Entry::read_db(&d).unwrap()) {
println!("{}", std::str::from_utf8(&x).unwrap());
......@@ -87,16 +98,20 @@ fn update_cache(force: bool) -> std::io::Result<cache::Cache> {
}
fn main() {
let (dir, args) = match parse_args() {
let conf = match parse_args() {
None => return,
Some(x) => x,
};
let mut args = args.iter();
let mut args = conf.args.iter();
match args.next().map(|x| x.as_str()).unwrap_or_default() {
"" => cmd_list(&dir),
"currencies" => cmd_currencies(&dir),
"" => cmd_list(&conf.dir),
"check" => cmd_check(&conf.dir),
"currencies" => cmd_currencies(&conf.dir),
"update-cache" => update_cache(true).and(Ok(())).unwrap(),
"plot" => cmd_plot(&dir),
x => panic!("invalid command: {}", x),
"plot" => cmd_plot(&conf.dir),
x => {
eprintln!("{}: invalid command: {}", conf.exe, x);
std::process::exit(1);
},
}
}