oro_config/
lib.rs

1//! Configuration loader for Orogene config files.
2
3use std::{collections::HashSet, ffi::OsString, path::PathBuf};
4
5pub use clap::{ArgMatches, Command};
6pub use config::Config as OroConfig;
7use config::{builder::DefaultState, ConfigBuilder, Environment, File, ValueKind};
8use kdl_source::KdlFormat;
9use miette::Result;
10
11use error::OroConfigError;
12
13mod error;
14mod kdl_source;
15
16pub trait OroConfigLayerExt {
17    fn with_negations(self) -> Self;
18    fn layered_args(&self, args: &mut Vec<OsString>, config: &OroConfig) -> Result<()>;
19}
20
21impl OroConfigLayerExt for Command {
22    fn with_negations(self) -> Self {
23        let negated = self
24            .get_arguments()
25            .filter(|opt| opt.get_long().is_some())
26            .map(|opt| format!("no-{}", opt.get_long().expect("long option")))
27            .collect::<Vec<_>>();
28        let negations = self
29            .get_arguments()
30            .filter(|opt| opt.get_long().is_some())
31            .zip(negated)
32            .map(|(opt, negated)| {
33                // This is a bit tricky. For arguments that we want to have
34                // `--no-foo` for, but we want `foo` to default to true, we
35                // need to set the `long` flag _on the original_ to `no-foo`,
36                // and then this one will "reverse" it.
37                let long = if negated.starts_with("no-no-") {
38                    negated.replace("no-no-", "")
39                } else {
40                    negated.clone()
41                };
42                clap::Arg::new(negated)
43                    .long(long)
44                    .global(opt.is_global_set())
45                    .hide(true)
46                    .action(clap::ArgAction::SetTrue)
47                    .overrides_with(opt.get_id())
48            })
49            .collect::<Vec<_>>();
50        // Add the negations
51        self.args(negations)
52    }
53
54    fn layered_args(&self, args: &mut Vec<OsString>, config: &OroConfig) -> Result<()> {
55        let mut long_opts = HashSet::new();
56        for opt in self.get_arguments() {
57            if opt.get_long().is_some() {
58                long_opts.insert(opt.get_id().to_string());
59            }
60        }
61        let matches = self
62            .clone()
63            .ignore_errors(true)
64            .get_matches_from(&args.clone());
65        for opt in long_opts {
66            // TODO: _prepend_ args unconditionally if they're coming from
67            // config, so multi-args get parsed right. Right now, if you have
68            // something in your config, it'll get completely overridden by
69            // the command line.
70            if matches.value_source(&opt) != Some(clap::parser::ValueSource::CommandLine) {
71                let opt = opt.replace('_', "-");
72                if !args.contains(&OsString::from(format!("--no-{opt}"))) {
73                    if let Ok(bool) = config.get_bool(&opt) {
74                        if bool {
75                            args.push(OsString::from(format!("--{}", opt)));
76                        } else {
77                            args.push(OsString::from(format!("--no-{}", opt)));
78                        }
79                    } else if let Ok(value) = config.get_string(&opt) {
80                        args.push(OsString::from(format!("--{}", opt)));
81                        args.push(OsString::from(value));
82                    } else if let Ok(value) = config.get_table(&opt) {
83                        for (key, val) in value {
84                            match &val.kind {
85                                ValueKind::Table(map) => {
86                                    for (k, v) in map {
87                                        args.push(OsString::from(format!("--{}", opt)));
88                                        args.push(OsString::from(format!("{{{key}}}{k}={v}")));
89                                    }
90                                }
91                                // TODO: error if val.kind is an Array
92                                _ => {
93                                    args.push(OsString::from(format!("--{}", opt)));
94                                    args.push(OsString::from(format!("{key}={val}")));
95                                }
96                            }
97                        }
98                    } else if let Ok(value) = config.get_array(&opt) {
99                        for val in value {
100                            if let Ok(val) = val.into_string() {
101                                args.push(OsString::from(format!("--{}", opt)));
102                                args.push(OsString::from(val));
103                            }
104                        }
105                    }
106                }
107            }
108        }
109        Ok(())
110    }
111}
112
113#[derive(Debug, Clone)]
114pub struct OroConfigOptions {
115    builder: ConfigBuilder<DefaultState>,
116    global: bool,
117    env: bool,
118    pkg_root: Option<PathBuf>,
119    global_config_file: Option<PathBuf>,
120}
121
122impl Default for OroConfigOptions {
123    fn default() -> Self {
124        OroConfigOptions {
125            builder: OroConfig::builder(),
126            global: true,
127            env: true,
128            pkg_root: None,
129            global_config_file: None,
130        }
131    }
132}
133
134impl OroConfigOptions {
135    pub fn new() -> Self {
136        Self::default()
137    }
138
139    pub fn global(mut self, global: bool) -> Self {
140        self.global = global;
141        self
142    }
143
144    pub fn env(mut self, env: bool) -> Self {
145        self.env = env;
146        self
147    }
148
149    pub fn pkg_root(mut self, root: Option<PathBuf>) -> Self {
150        self.pkg_root = root;
151        self
152    }
153
154    pub fn global_config_file(mut self, file: Option<PathBuf>) -> Self {
155        self.global_config_file = file;
156        self
157    }
158
159    pub fn set_default(mut self, key: &str, value: &str) -> Result<Self, OroConfigError> {
160        self.builder = self.builder.set_default(key, value)?;
161        Ok(self)
162    }
163
164    pub fn load(self) -> Result<OroConfig> {
165        let mut builder = self.builder;
166        if self.global {
167            if let Some(config_file) = self.global_config_file {
168                let path = config_file.display().to_string();
169                builder = builder.add_source(File::new(&path, KdlFormat).required(false));
170            }
171        }
172        if self.env {
173            builder = builder.add_source(Environment::with_prefix("oro_config"));
174        }
175        if let Some(root) = self.pkg_root {
176            builder = builder.add_source(
177                File::new(&root.join("oro.kdl").display().to_string(), KdlFormat).required(false),
178            );
179        }
180        Ok(builder.build().map_err(OroConfigError::ConfigError)?)
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    use std::env;
189    use std::fs;
190
191    use miette::{IntoDiagnostic, Result};
192    use pretty_assertions::assert_eq;
193    use tempfile::tempdir;
194
195    #[test]
196    fn env_configs() -> Result<()> {
197        let dir = tempdir().into_diagnostic()?;
198        env::set_var("ORO_CONFIG_STORE", dir.path().display().to_string());
199        let config = OroConfigOptions::new().global(false).load()?;
200        env::remove_var("ORO_CONFIG_STORE");
201        assert_eq!(
202            config.get_string("store").into_diagnostic()?,
203            dir.path().display().to_string()
204        );
205        Ok(())
206    }
207
208    #[test]
209    fn global_config() -> Result<()> {
210        let dir = tempdir().into_diagnostic()?;
211        let file = dir.path().join("oro.kdl");
212        fs::write(&file, "options{\nstore \"hello world\"\n}").into_diagnostic()?;
213        let config = OroConfigOptions::new()
214            .env(false)
215            .global_config_file(Some(file))
216            .load()?;
217        assert_eq!(
218            config.get_string("store").into_diagnostic()?,
219            String::from("hello world")
220        );
221        Ok(())
222    }
223
224    #[test]
225    fn missing_config() -> Result<()> {
226        let config = OroConfigOptions::new().global(false).env(false).load()?;
227        assert!(config.get_string("store").is_err());
228        Ok(())
229    }
230}