Skip to main content

joule_profiler_cli/config/
overrides.rs

1//! CLI overrides of the TOML configuration (`-D KEY=VALUE`).
2
3use std::str::FromStr;
4
5use anyhow::{Result, anyhow, bail};
6use toml::{Value, map::Map};
7
8/// A single `-D KEY=VALUE` override, `KEY` being the dotted path of the key in
9/// the configuration file.
10#[derive(Debug, Clone, PartialEq)]
11pub struct ConfigOverride {
12    path: Vec<String>,
13    value: Value,
14}
15
16impl ConfigOverride {
17    /// Writes the override into `root`, creating the tables it goes through.
18    ///
19    /// Returns an error if the path traverses a key that is not a table.
20    pub fn apply(&self, root: &mut Value) -> Result<()> {
21        let (key, parents) = self
22            .path
23            .split_last()
24            .expect("an override path always has at least one segment");
25
26        let key_path = self.path.join(".");
27
28        let mut table = root.as_table_mut().ok_or_else(|| {
29            anyhow!("cannot override `{key_path}`: the configuration is not a table.")
30        })?;
31
32        for (depth, segment) in parents.iter().enumerate() {
33            let node = table
34                .entry(segment.clone())
35                .or_insert_with(|| Value::Table(Map::new()));
36            let kind = node.type_str();
37
38            table = node.as_table_mut().ok_or_else(|| {
39                anyhow!(
40                    "cannot override `{key_path}`: `{}` is a {kind}, not a table.",
41                    self.path[..=depth].join("."),
42                )
43            })?;
44        }
45
46        table.insert(key.clone(), self.value.clone());
47
48        Ok(())
49    }
50}
51
52impl FromStr for ConfigOverride {
53    type Err = anyhow::Error;
54
55    fn from_str(s: &str) -> Result<Self> {
56        let Some((key, value)) = s.split_once('=') else {
57            bail!("expected KEY=VALUE, got `{s}`.");
58        };
59
60        let path: Vec<String> = key
61            .trim()
62            .split('.')
63            .map(|segment| segment.trim().to_owned())
64            .collect();
65
66        if path.iter().any(String::is_empty) {
67            bail!("`{key}` is not a valid configuration key.");
68        }
69
70        Ok(Self {
71            path,
72            value: parse_value(value),
73        })
74    }
75}
76
77/// Parses a `-D` value as a TOML value, falling back to a bare string.
78///
79/// The fallback is what lets durations, regexes and paths go unquoted while
80/// `false`, `10` or `[0,1]` keep the type the sources expect.
81fn parse_value(raw: &str) -> Value {
82    let raw = raw.trim();
83
84    toml::from_str::<Value>(&format!("value = {raw}"))
85        .ok()
86        .and_then(|mut parsed| parsed.as_table_mut()?.remove("value"))
87        .unwrap_or_else(|| Value::String(raw.to_owned()))
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    fn parse(s: &str) -> ConfigOverride {
95        s.parse::<ConfigOverride>().unwrap()
96    }
97
98    fn applied(overrides: &[&str]) -> Value {
99        let mut root = Value::Table(Map::new());
100        for raw in overrides {
101            parse(raw).apply(&mut root).unwrap();
102        }
103        root
104    }
105
106    #[test]
107    fn parses_a_dotted_key_path() {
108        let parsed = parse("sources.rapl.rapl_path=/sys/class/powercap");
109
110        assert_eq!(parsed.path, ["sources", "rapl", "rapl_path"]);
111        assert_eq!(
112            parsed.value,
113            Value::String("/sys/class/powercap".to_owned())
114        );
115    }
116
117    #[test]
118    fn parses_toml_typed_values() {
119        assert_eq!(parse("a=42").value, Value::Integer(42));
120        assert_eq!(parse("a=false").value, Value::Boolean(false));
121        assert_eq!(
122            parse("a=[0, 1]").value,
123            Value::Array(vec![Value::Integer(0), Value::Integer(1)])
124        );
125    }
126
127    #[test]
128    fn falls_back_to_a_string_value() {
129        assert_eq!(parse("a=10ms").value, Value::String("10ms".to_owned()));
130        assert_eq!(
131            parse("a=__[A-Z]+__").value,
132            Value::String("__[A-Z]+__".to_owned())
133        );
134    }
135
136    #[test]
137    fn quoting_forces_a_string_value() {
138        assert_eq!(parse("a=\"42\"").value, Value::String("42".to_owned()));
139    }
140
141    #[test]
142    fn keeps_equal_signs_of_the_value() {
143        assert_eq!(parse("a=b=c").value, Value::String("b=c".to_owned()));
144    }
145
146    #[test]
147    fn rejects_a_value_less_override() {
148        assert!("profiler.use_root".parse::<ConfigOverride>().is_err());
149    }
150
151    #[test]
152    fn rejects_an_empty_key_segment() {
153        assert!("sources..rapl=1".parse::<ConfigOverride>().is_err());
154        assert!("=1".parse::<ConfigOverride>().is_err());
155    }
156
157    #[test]
158    fn apply_creates_the_intermediate_tables() {
159        let root = applied(&["sources.cgroup.create_cgroup=false"]);
160
161        assert_eq!(
162            root["sources"]["cgroup"]["create_cgroup"],
163            Value::Boolean(false)
164        );
165    }
166
167    #[test]
168    fn apply_keeps_the_neighbouring_keys() {
169        let root = applied(&[
170            "sources.cgroup.cgroup_name=first",
171            "sources.cgroup.create_cgroup=false",
172            "sources.rapl.sockets_spec=[0]",
173            "profiler.use_root=true",
174        ]);
175
176        assert_eq!(
177            root["sources"]["cgroup"]["cgroup_name"],
178            Value::String("first".to_owned())
179        );
180        assert_eq!(
181            root["sources"]["cgroup"]["create_cgroup"],
182            Value::Boolean(false)
183        );
184        assert_eq!(
185            root["sources"]["rapl"]["sockets_spec"][0],
186            Value::Integer(0)
187        );
188        assert_eq!(root["profiler"]["use_root"], Value::Boolean(true));
189    }
190
191    #[test]
192    fn apply_replaces_an_existing_value() {
193        let root = applied(&["profiler.output_format=csv", "profiler.output_format=json"]);
194
195        assert_eq!(
196            root["profiler"]["output_format"],
197            Value::String("json".to_owned())
198        );
199    }
200
201    #[test]
202    fn apply_rejects_a_path_through_a_non_table() {
203        let mut root = Value::Table(Map::new());
204        parse("profiler.use_root=true").apply(&mut root).unwrap();
205
206        let err = parse("profiler.use_root.nested=1")
207            .apply(&mut root)
208            .unwrap_err();
209
210        assert!(err.to_string().contains("profiler.use_root"));
211    }
212}