1
\$\begingroup\$

I wrote this Rust code to parse my financials from a YAML file and my main concern is the large match branches (although general code review is welcome; still a Rust beginner):

extern crate yaml_rust;
use yaml_rust::{Yaml, YamlLoader};

fn main() -> Result<(), std::io::Error> {
    let doc = std::fs::read_to_string("finances.yaml")?;
    let data = YamlLoader::load_from_str(&doc).unwrap();
    let doc = &data[0];

    let map = doc.as_hash().unwrap();

    for (k, v) in map.iter() {
        println!("{}: ", k.as_str().unwrap());
        let sum = unwrap_value(v);
        println!("== {}", sum);
        println!();
    }

    Ok(())
}

fn unwrap_value(v: &Yaml) -> f32 {
    match v {
        Yaml::Hash(v) => {
            let mut sum = 0.;
            for (k, vv) in v.iter() {
                match vv {
                    Yaml::String(_vv) => {
                        print!("\t\t {}: ", k.as_str().unwrap());
                    }
                    Yaml::Integer(_vv) => {
                        print!("\t\t {}: ", k.as_str().unwrap());
                    }
                    _ => {
                        println!("\t* {}:", k.as_str().unwrap());
                    }
                }
                sum += unwrap_value(vv);
            }
            sum
        }
        Yaml::Array(v) => {
            let mut sub_sum = 0.;
            for h in v.iter() {
                sub_sum += unwrap_value(h);
            }
            println!("\t= {}", sub_sum);
            sub_sum
        }
        Yaml::String(v) => {
            let tot: f32 = v
                .split("+")
                .fold(0., |sum, s| sum + s.trim().parse::<f32>().unwrap());
            println!("{}", tot);
            tot
        }
        Yaml::Integer(v) => {
            println!("{}", v);
            *v as f32
        }
        _ => 0.,
    }
}

Here's a sample YAML of the file I'm parsing:

---
'2021-01-01':
  apartment:
    - rent: 2750
  transportation:
    - uber: 87.69 + 55.36 + 26 + 42 + 42 + 34.92 + 25.76 + 42 + 42
    - bus: 12
'2021-02-01':
  apartment:
    - rent: 2750
  bills:
    - elctricity: 27
  transportation:
    - uber: 87.69 + 55.36 + 26 + 42 + 42 + 34.92 + 25.76 + 42 + 42
    - bus: 12

and this is the output for the example above:

2021-01-01:
        * apartment:
                 rent: 2750
        = 2750
        * transportation:
                 uber: 397.73
                 bus: 12
        = 409.73
== 3159.73

2021-02-01:
        * apartment:
                 rent: 2750
        = 2750
        * bills:
                 elctricity: 27
        = 27
        * transportation:
                 uber: 397.73
                 bus: 12
        = 409.73
== 3186.73
\$\endgroup\$
4
  • 2
    \$\begingroup\$ Can you please also add the output of your program to your post? \$\endgroup\$ – Zeta Mar 14 at 16:54
  • \$\begingroup\$ Thanks @zeta. I just did. \$\endgroup\$ – aonemd Mar 14 at 16:57
  • \$\begingroup\$ Your YAML is unusual because the -s turn the innermost structure into a sequence of strings instead of a map. Is that intentional? \$\endgroup\$ – trentcl Mar 19 at 16:12
  • \$\begingroup\$ Just a small aside: you might be interested in ledger-cli or similar applications. \$\endgroup\$ – Zeta Mar 21 at 7:51
4
\$\begingroup\$

Structure your data

The reason you need nested matches is that even after parsing the YAML document into a Yaml object, it's still essentially unstructured data. Not every YAML object will have the right shape, so you have to validate it piece by piece as you traverse it. Worse, you'll have to validate it all over again next time you try to traverse it. This is inefficient and ugly.

Define some data structures that will hold the information currently held in a YAML document. How you define these depends on how rigid the data format is and what you plan to do with it. Here's one way to do it. You'll need to add chrono to your dependencies.

use chrono::NaiveDate as Date;

struct Document {
    by_date: HashMap<Date, Expenses>,
}

struct Expenses {
    by_category: HashMap<String, Vec<Entry>>,
}

struct Entry {
    payee: String,
    payments: Vec<f32>,
}

Define what it means to total up the whole Document by deferring to its contents:

impl Document {
    fn total(&self) -> f32 {
        // the total of a Document is the sum of the total expenses on each date
        self.by_date.values().map(|expenses| expenses.total()).sum()
    }
}

impl Expenses {
    fn total(&self) -> f32 {
        // the total is the sum of the totals of each Entry in each category
        self.by_category.values().flat_map(|entries| entries.iter().map(Entry::total)).sum()
    }
}

impl Entry {
    fn total(&self) -> f32 {
        // the total is the sum of all the individual payments
        self.payments.iter().sum()
    }
}

Note 1: I am completely ignoring the printing part of unwrap_value. You could add that to the above code relatively easily, but it would be noisy and not super instructive, so I'm just going to focus on the totaling.

Note 2: It might sometimes be useful to write a Total trait and implement it for Entry, Document and Expenses, instead of having three unrelated functions. But it doesn't seem useful here, so I didn't.


Deserialize with Serde

Now, the only problem that remains is how to turn a YAML document into a Document. You could write code to convert from a Yaml object, but the bulk of the work has been done for you already by the Serde library. Serde is an extremely versatile library for serialization and deserialization of just about any Rust data structure from just about any data format. Any time you're considering serialization, unless you know you need something specific, it's a good idea to start with Serde. You'll need to add serde, serde-yaml and serde-derive to your dependencies.

When the data format is flexible, you might just add #[derive(Deserialize)] on all your structs to get the default behavior. In this case, since there's a particular YAML format we're trying to match, we need to add some annotations to explain to Serde just how to put things together. I used chrono::NaiveDate in part because it already implements Deserialize, which makes this task easier by half.

#[derive(Deserialize)]
#[serde(transparent)]
struct Document {
    by_date: HashMap<Date, Expenses>,
}

#[derive(Deserialize)]
#[serde(transparent)]
struct Expenses {
    by_category: HashMap<String, Vec<Entry>>,
}

#[derive(Deserialize)]
#[serde(try_from = "HashMap<String, String>")]
struct Entry {
    payee: String,
    payments: Vec<f32>,
}

impl TryFrom<HashMap<String, String>> for Entry {
    type Error = String;
    fn try_from(value: HashMap<String, String>) -> Result<Self, Self::Error> {
        for (payee, etc) in value {
            let payments = etc.split('+')
                .map(|s| s.trim().parse::<f32>())
                .collect::<Result<_, _>>()
                .map_err(|e| e.to_string())?;
            return Ok(Entry { payee, payments });
        }
        Err("Empty map".to_owned())
    }
}

I didn't have to actually write any code for Document and Expenses; I just used the serde(transparent) attribute to tell Serde to treat those structs exactly the same as their contained types. For Entry, since Serde's YAML deserializer can already parse a string like rent: 2750 into a HashMap<String, String>, I just implemented TryFrom<HashMap<String, String>> and told Serde to use that. This handy trick requires less code and is easier to wrap your head around than a full Deserialize implementation, but may be slightly slower.

Now all we have to do is use it:

let doc: Document = serde_yaml::from_str(&doc).unwrap();
println!("total: {}", doc.total());

For a small initial investment in code, your data is now made of meaningful, Rusty types like Date and HashMap instead of all different Yaml values. You can add a little bit more code to also support serialization, and because Serde is a general purpose (de)serialization library and not a particular data format, switching to another format like JSON or bincode (should you want to) is just a couple lines of code.

And another thing

Floating-point numbers are bad for money because they have variable precision. Use a decimal library, or fixed-point arithmetic (i.e. store an integer number of the smallest possible amount of money – cents instead of dollars.) If you absolutely must use floating-point numbers, at least use f64, and don't say I didn't warn you not to.

\$\endgroup\$
1
  • 1
    \$\begingroup\$ "don't say I didn't warn you": +1 \$\endgroup\$ – aghast Mar 21 at 5:28

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.