Mocking a Struct

DurableConfig is a struct that persists itself to a file. I want to mock DurableConfig::write for a test so instead of writing to a file, it makes assertions about parameters that were passed in. From what I've read, the standard way to do this is to use traits.

Here is what I've tried. This code does not compile.

use serde::{Deserialize, Serialize};
use std::error::Error;
use std::fs;
use std::fs::File;
use std::io::prelude::*;
use std::path::PathBuf;

#[derive(Debug, Default, PartialEq)]
pub struct DurableConfig {
    file_path: PathBuf,
    pub values: ConfigValues,
}

#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
pub struct ConfigValues {
    pub foo: Option<String>,
    pub bar: Option<String>,
    pub baz: Option<String>,
}

impl From<PathBuf> for DurableConfig {
    fn from(path: PathBuf) -> DurableConfig {
        let content = match fs::read_to_string(&path) {
            Ok(text) => text,
            Err(_) => String::from(""),
        };

        DurableConfig {
            file_path: path,
            values: ConfigValues::from(content),
        }
    }
}

impl From<String> for ConfigValues {
    fn from(s: String) -> ConfigValues {
        toml::from_str(&s).unwrap()
    }
}

pub trait Config: std::fmt::Debug {
    fn merge(self, other: ConfigValues) -> Self;
    fn write(self) -> Result<(), Box<dyn Error>>;
}

impl Config for DurableConfig {
    fn merge(self, other: ConfigValues) -> DurableConfig {
        DurableConfig {
            file_path: self.file_path,
            values: self.values.merge(other),
        }
    }

    fn write(self: DurableConfig) -> Result<(), Box<dyn Error>> {
        let content = self.values.to_string();

        let file_path = self.file_path;

        let mut file = match File::create(&file_path) {
            Err(_) => {
                fs::create_dir(&file_path.parent().unwrap())?;
                File::create(&file_path).unwrap()
            }
            Ok(file) => file,
        };

        match file.write_all(content.as_bytes()) {
            Err(e) => panic!("Could not write to {:?}: {}", file_path, e),
            Ok(_) => Ok(()),
        }
    }
}

impl ConfigValues {
    pub fn to_string(self) -> String {
        toml::to_string(&self).unwrap()
    }

    pub fn merge(self, other: ConfigValues) -> ConfigValues {
        ConfigValues {
            foo: match other.foo {
                Some(value) => Some(value),
                None => self.foo,
            },
            bar: match other.bar {
                Some(value) => Some(value),
                None => self.bar,
            },
            baz: match other.baz {
                Some(value) => Some(value),
                None => self.baz,
            },
        }
    }
}

struct ConfigArgs {
    pub key: String,
    pub value: Option<String>,
}

fn get_or_set_config_value<T: Config>(
    args: ConfigArgs,
    config: T,
) -> Result<(), Box<dyn Error>> {
    match args.value {
        Some(value) => {
            let new_values = match &args.key[..] {
                "foo" => ConfigValues {
                    foo: Some(value),
                    ..ConfigValues::default()
                },
                "bar" => ConfigValues {
                    bar: Some(value),
                    ..ConfigValues::default()
                },
                "baz" => ConfigValues {
                    baz: Some(value),
                    ..ConfigValues::default()
                },
                _ => panic!("Unknown key: {}", args.key),
            };

            config.merge(new_values).write()?;
            Ok(())
        }
        None => {
            let value = match &args.key[..] {
                "foo" => config.values.foo.unwrap(),
                "bar" => config.values.bar.unwrap(),
                "baz" => config.values.baz.unwrap(),
                _ => panic!("Unknown key: {}", args.key),
            };
            println!("{}", value);
            Ok(())
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::error::Error;
    use std::path::PathBuf;

    #[test]
    fn should_write_config_key_when_config_is_empty() {
        #[derive(Debug, Default, PartialEq)]
        struct MockConfig {
            file_path: PathBuf,
            values: ConfigValues,
        }

        impl Config for MockConfig {
            fn merge(self, other: ConfigValues) -> MockConfig {
                MockConfig {
                    file_path: self.file_path,
                    values: self.values.merge(other),
                }
            }

            fn write(self: MockConfig) -> Result<(), Box<dyn Error>> {
                assert_eq!(self.values.foo, Some("foo".to_string()));
                assert_eq!(self.values.bar, None);
                assert_eq!(self.values.baz, None);

                Ok(())
            }
        }

        let config = MockConfig {
            file_path: PathBuf::from(""),
            values: ConfigValues {
                foo: None,
                bar: None,
                baz: None,
            },
        };

        let args = ConfigArgs {
            key: String::from("foo"),
            value: Some(String::from("foo")),
        };

        let _ = get_or_set_config_value(args, config).unwrap();
    }
}

Here are some of the compiler errors. Suggestions?

error[E0609]: no field `values` on type `T`
   --> cli/src/foo.rs:130:33
    |
102 | fn get_or_set_config_value<T: Config>(
    |                            - type parameter 'T' declared here
...
130 |                 "foo" => config.values.foo.unwrap(),
    |                                 ^^^^^^

error[E0609]: no field `values` on type `T`
   --> cli/src/foo.rs:131:33
    |
102 | fn get_or_set_config_value<T: Config>(
    |                            - type parameter 'T' declared here
...
131 |                 "bar" => config.values.bar.unwrap(),
    |                                 ^^^^^^

error[E0609]: no field `values` on type `T`
   --> cli/src/foo.rs:132:33
    |
102 | fn get_or_set_config_value<T: Config>(
    |                            - type parameter 'T' declared here
...
132 |                 "baz" => config.values.baz.unwrap(),
    |                                 ^^^^^^

error[E0609]: no field `values` on type `T`
   --> cli/src/foo.rs:130:33
    |
102 | fn get_or_set_config_value<T: Config>(
    |                            - type parameter 'T' declared here
...
130 |                 "foo" => config.values.foo.unwrap(),
    |                                 ^^^^^^

error[E0609]: no field `values` on type `T`
   --> cli/src/foo.rs:131:33
    |
102 | fn get_or_set_config_value<T: Config>(
    |                            - type parameter 'T' declared here
...
131 |                 "bar" => config.values.bar.unwrap(),
    |                                 ^^^^^^

error: aborting due to 3 previous errors

For more information about this error, try `rustc --explain E0609`.
error[E0609]: no field `values` on type `T`
   --> cli/src/foo.rs:132:33
    |
102 | fn get_or_set_config_value<T: Config>(
    |                            - type parameter 'T' declared here
...
132 |                 "baz" => config.values.baz.unwrap(),
    |                                 ^^^^^^

When you are generic over a trait with <T: Config>, you can only use things that are defined in the trait. Since there are no fields on the trait (and in fact traits don't support fields), you cannot access fields through the generic type.

To use a trait like this, you must define methods on the trait that allow access to the fields.

That said, in this case, using a trait seems like a bad idea? Why not just use the structs directly?

@alice I have a version that uses structs. I'm able to write unit tests for ConfigData because it reads and writes strings, but DurableConfig reads and writes files. How would you create unit tests for that (or functions that contain a call to DurableConifg::write)?

There are probably several ways, but here is one:

fn get_or_set_config_value(
    args: ConfigArgs,
    config: DurableConfig,
) -> Result<(), Box<dyn Error>> {
    if let Some(merged) = get_or_set_config_value_inner(args, config) {
        merged.write()?;
    }
    Ok(())
}

fn get_or_set_config_value_inner(
    args: ConfigArgs,
    config: DurableConfig,
) -> Option<DurableConfig> {
    match args.value {
        Some(value) => {
            let new_values = match &args.key[..] {
                "foo" => ConfigValues {
                    foo: Some(value),
                    ..ConfigValues::default()
                },
                "bar" => ConfigValues {
                    bar: Some(value),
                    ..ConfigValues::default()
                },
                "baz" => ConfigValues {
                    baz: Some(value),
                    ..ConfigValues::default()
                },
                _ => panic!("Unknown key: {}", args.key),
            };

            Some(config.merge(new_values))
        }
        None => {
            let value = match &args.key[..] {
                "foo" => config.values.foo.unwrap(),
                "bar" => config.values.bar.unwrap(),
                "baz" => config.values.baz.unwrap(),
                _ => panic!("Unknown key: {}", args.key),
            };
            println!("{}", value);
            None
        }
    }
}

Then write a test that tests get_or_set_config_value_inner.

Of course, if you really want, you could also define a more powerful trait, but I think it would make your code harder to read.

I'm currently trying the approach outlined in Command line apps in Rust.

#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
struct Config {
    foo: Option<String>,
    bar: Option<String>,
}

impl Config {
    fn read(mut reader: impl std::io::Read) -> Config {
        let mut buffer = String::new();
        reader.read_to_string(&mut buffer).unwrap();
        toml::from_str(&buffer).unwrap()
    }

    fn write(
        self,
        mut writer: impl std::io::Write,
    ) -> Result<(), Box<dyn Error>> {
        let content = toml::to_string(self).unwrap();

        match writer.write(content.as_bytes()) {
            Err(e) => panic!(e),
            Ok(_) => Ok(()),
        }
    }
}

Here, Config::read and Config::write take readers and writers as arguments.

These are usually files.

let file_reader = std::fs::OpenOptions::new().read(true).open(&path)?;
let config = Config::read(file_reader);

let file_writer = std::fs::File::create(&path)?;
let _ = config.write(file_writer)?;

They can also be strings which is useful when writing tests.

#[test]
fn should_read_config_from_file() {
    let mut buffer = "foo = \"VALUE\"".as_bytes();
    let config = Config::read(&mut buffer);
    assert_eq!(config.foo, Some("VALUE".to_string()));
}

#[test]
fn should_write_config_to_file() {
    let config = Config {
        foo: Some("VALUE".to_string()),
        bar: None,
    };
    let mut buffer = Vec::new();
    let _ = config.write(&mut buffer);
    assert_eq!(buffer, b"foo = \"VALUE\"\n");
}

This all works fine. The problem occurs when I need a function that calls Config::write.

fn get_or_set_config_entry(
    args: ConfigArgs,
    config: Config,
    mut stdout_writer: impl std::io::Write,
    file_writer: impl std::io::Write,
) -> Result<(), Box<dyn Error>> {
    match args.value {
        Some(value) => {
            let new_config = match &args.key[..] {
                "foo" => Config {
                    foo: Some(value),
                    ..Config::default()
                },
                "bar" => Config {
                    bar: Some(value),
                    ..Config::default()
                },
                _ => panic!("Unknown key: {}", args.key),
            };
            config.merge(new_config).write(file_writer)?;
        }
        None => {
            let value = match &args.key[..] {
                "foo" => config.foo.unwrap(),
                "bar" => config.bar.unwrap(),
                _ => panic!("Unknown key: {}", args.key),
            };
            writeln!(stdout_writer, "{}", value).unwrap();
        }
    }
    Ok(())
}

Here, get_or_set_config_entry takes command-line arguments, a config, and a writer for a config file. (It also takes a writer for stdout, but that's not important now.)

If args contains a key and a value, get_or_set_config_entry writes a new config. Otherwise, it prints the value for the key given. Here is the problem: creating file writers is destructive. If get_or_set_config_entry panics or doesn't write, the file is destroyed.

I think what I really want is to create writers lazily. Instead of a passing get_or_set_config a file writer, I want a function that returns one. Then I can simply call it at the appropriate time. I need to be able to make two kinds of writers: writers for files and writers for strings.

So far, I haven't been able to get this change past the compiler. How can this be achieved?

So the solution turns out to be std::fs::OpenOptions.

Unlike std::fs::File::create, OptionOptions waits to destroy the file until it's ready to write. This combined with the above code gives the behavior I need.