Logic for an extendable Config struct

Hi all,

I made a library with which people can create audio playlists, and I am trying to make a module with helpers / scaffolding logic for making plug-ins for an audio player.

I have the following structs:

pub trait ConfigTrait {
    fn write(&self) -> Result<()>;

pub struct Config {
   config_path: PathBuf,
   database_path: PathBuf,

impl ConfigTrait for Config {
     fn write(&self) -> Result<()> {
         (... something using Serialize)

pub struct Library {
   pub config: Config
   (... various SQL structs)

impl Library {
    fn new(config_path: Option<PathBuf>, database_path: Option<PathBuf>) -> Result<Self> {
        (... create config)


        (... SQL stuff)

I have two questions:

  1. I know people will want to add fields to Config, because each player has player-specific options (MPD has an MPD_BASE_PATH, some NAS players have the remote NAS address, etc).
    From what it looks right now, since my Library::new() gets these options as arguments, I don't really see how I could add custom options here. Is there a better design that would allow me to have such a thing?

  2. I also know people will want to serialize / write the config file in different formats, so I would like to both provide a default for people who don't care (write the config file as JSON) as well as letting them overwrite the write method, or something similar.
    Again, would there be a way to change my design to make it possible?

I really like having that Library::new() thing that just creates a new config from user parameters, as well as taking care of the SQL schema creation, and I have a hunch that since I just call Config::new() and config.write() here, it would be possible to add custom configs there, but I'm not really sure how.

Thanks for reading that somewhat long message,

For the first part, you could add a generic parameter that each application could define. You'd also need some sort of accessors or a pub field.

After that, the second part is a bit trickier since you own both the trait and the outermost type. You could make the trait take &Config<Self>, [1] but this loses some ergonomics as the method no longer has a &self receiver. On the other hand, the ergonomics probably aren't terribly important here?

Or if they are, you could parameterize the trait too. Then the foreign trait can implement it on your type if they own the parameter. However, this makes your bounds less ergonomic instead.

Alternatively, you could turn both of these inside-out by

  • Having a trait for apps to implement that requires carrying around a BaseConfig field
    • I.e. having an accessor method (or more than one)
  • Putting all the rest of your Config functionality into a subtrait
    • Implement the config trait automatically for anything that implements the app trait
      • This maintains future flexibility as apps can't override the methods
      • The implementation just defers to the BaseConfig via accessor

I still think there's more design considerations here -- looking at this, I'm tempted to get rid of the new methods on AppConfigTrait and Library<_> for example [2] -- but the best approach is probably use-case specific.

  1. so that the implementer of the trait is the app, not your Config<_> ↩︎

  2. there's an implied constraint that implementors should be able to create themselves from the inputs to BaseConfig alone ↩︎



Thanks so much for that very complete reply! I spent some time looking at your code, and I'm thinking your last version might be the most ergonomic, so I've made some mock-up using it.

The only thing I've changed is that I've basically added a serialize_config trait to AppConfig, because I'm thinking most of the users will just want to provide a serialization function such as serde_*::to_string, and I think it might be a bit more ergonomic than provide a closure to write.
What do you think? :slight_smile:

I've also written some comments in the playground, because I'm not really sure whether my code is the smartest right now :sweat_smile:

Rather than carry around a fn (&self) -> Result<String>, it would be probably more idiomatic to just bound the serialization APIs on Config: Serialize and take an impl Serializer, or to take a fn (&Config) -> Result<String> parameter so the user can pass serde_json::to_string directly.

1 Like

Just to be clear - my serde knowledge is quite flaky - you would have a different signature for the write function, e.g.
fn write(&self, serializer: impl Serializer) -> Result<()>, or fn write(&self, write_closure: Fn(&config) -> Result<String>) ?

Wouldn't that be a bit less ergonomic, since then you'd have to make the serializer / closure optional, to allow for a default serializer to be used?