Skip to main content

joule_profiler_cli/config/
mod.rs

1use std::{collections::HashMap, fs, path::Path, time::Duration};
2
3use anyhow::{Context, Result};
4use serde::Deserialize;
5
6#[cfg(feature = "_rapl")]
7use crate::RaplBackend;
8use crate::{config::overrides::ConfigOverride, output::formats::OutputFormat};
9
10pub mod overrides;
11pub mod source;
12pub mod table;
13
14const DEFAULT_INIT_TIMEOUT: Duration = Duration::from_secs(1);
15const DEFAULT_TOKEN_PATTERN: &str = "__[A-Z0-9_]+__";
16
17fn default_timeout() -> Duration {
18    DEFAULT_INIT_TIMEOUT
19}
20
21fn default_token_pattern() -> String {
22    DEFAULT_TOKEN_PATTERN.to_owned()
23}
24
25/// Unknown keys are rejected: a misspelled one would otherwise be dropped
26/// without a trace, which is easy to miss on a `-D` override.
27#[derive(Debug, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct ProfilerConfig {
30    /// Optional file to redirect the profiled program stdout.
31    pub stdout_file: Option<String>,
32
33    /// Regex used to detect phase tokens in program output.
34    #[serde(default = "default_token_pattern")]
35    pub token_pattern: String,
36
37    /// Executes the profiled command with root privileges if true and Joule Profiler is launched as root.
38    #[serde(default)]
39    pub use_root: bool,
40
41    /// Output file for CSV/JSON. (else `data<TIMESTAMP>`.csv/json)
42    pub output_file: Option<String>,
43
44    /// The output format to use. (e.g., terminal, json, csv)
45    #[serde(default)]
46    pub output_format: OutputFormat,
47
48    /// Duration before aborting sources initialization. (default: 1s)
49    #[serde(default = "default_timeout", with = "humantime_serde")]
50    pub init_timeout: Duration,
51
52    #[cfg(feature = "_rapl")]
53    #[serde(default)]
54    pub rapl_backend: RaplBackend,
55}
56
57impl Default for ProfilerConfig {
58    fn default() -> Self {
59        Self {
60            token_pattern: default_token_pattern(),
61            init_timeout: default_timeout(),
62            output_format: OutputFormat::Terminal,
63            #[cfg(feature = "_rapl")]
64            rapl_backend: RaplBackend::default(),
65            use_root: false,
66            stdout_file: None,
67            output_file: None,
68        }
69    }
70}
71
72#[derive(Debug, Default, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct GlobalConfig {
75    /// The global configuration of the profiler.
76    #[serde(default)]
77    pub profiler: ProfilerConfig,
78
79    /// The sources configurations.
80    #[serde(default)]
81    pub sources: HashMap<String, toml::Value>,
82}
83
84/// Loads the configuration from `config_file`, with the `-D` overrides applied
85/// on top of it.
86///
87/// Overrides are applied to the raw TOML rather than to the deserialized
88/// configuration, so a `-D` reaches any key a configuration file can set,
89/// including per-source ones, and goes through the same checks.
90pub fn load_global_config(
91    config_file: Option<&Path>,
92    overrides: &[ConfigOverride],
93) -> Result<GlobalConfig> {
94    let mut value = match config_file {
95        Some(path) => {
96            let content = fs::read_to_string(path)
97                .with_context(|| format!("configuration file error on `{}`", path.display()))?;
98            toml::from_str(&content).context("error parsing configuration file")?
99        }
100        None => toml::Value::Table(toml::map::Map::new()),
101    };
102
103    for config_override in overrides {
104        config_override.apply(&mut value)?;
105    }
106
107    value
108        .try_into()
109        .context("error applying the configuration. Check the `-D` overrides")
110}
111
112#[cfg(test)]
113mod tests {
114    use std::io::Write;
115
116    use super::*;
117
118    fn overrides(raw: &[&str]) -> Vec<ConfigOverride> {
119        raw.iter().map(|s| s.parse().unwrap()).collect()
120    }
121
122    fn config_file(content: &str) -> tempfile::NamedTempFile {
123        let mut file = tempfile::NamedTempFile::new().unwrap();
124        write!(file, "{content}").unwrap();
125        file
126    }
127
128    #[test]
129    fn load_without_a_file_nor_overrides_is_the_default_config() {
130        let config = load_global_config(None, &[]).unwrap();
131
132        assert_eq!(config.profiler.token_pattern, DEFAULT_TOKEN_PATTERN);
133        assert!(config.sources.is_empty());
134    }
135
136    #[test]
137    fn load_applies_overrides_without_a_config_file() {
138        let config = load_global_config(
139            None,
140            &overrides(&[
141                "profiler.token_pattern=__CLI__",
142                "sources.cgroup.create_cgroup=false",
143            ]),
144        )
145        .unwrap();
146
147        assert_eq!(config.profiler.token_pattern, "__CLI__");
148        assert_eq!(
149            config.sources["cgroup"]["create_cgroup"],
150            toml::Value::Boolean(false)
151        );
152    }
153
154    #[test]
155    fn load_overrides_take_precedence_over_the_file() {
156        let file = config_file(
157            "[profiler]\ntoken_pattern = \"__FILE__\"\nuse_root = true\n\
158             [sources.cgroup]\ncgroup_name = \"from-file\"\n",
159        );
160
161        let config = load_global_config(
162            Some(file.path()),
163            &overrides(&["profiler.token_pattern=__CLI__"]),
164        )
165        .unwrap();
166
167        assert_eq!(config.profiler.token_pattern, "__CLI__");
168        assert!(config.profiler.use_root);
169        assert_eq!(
170            config.sources["cgroup"]["cgroup_name"],
171            toml::Value::String("from-file".to_owned())
172        );
173    }
174
175    #[test]
176    fn load_overrides_a_source_absent_from_the_file() {
177        let file = config_file("[sources.rapl]\nsockets_spec = [0]\n");
178
179        let config = load_global_config(
180            Some(file.path()),
181            &overrides(&["sources.cgroup.poll_interval=20ms"]),
182        )
183        .unwrap();
184
185        assert_eq!(
186            config.sources["cgroup"]["poll_interval"],
187            toml::Value::String("20ms".to_owned())
188        );
189        assert_eq!(
190            config.sources["rapl"]["sockets_spec"][0],
191            toml::Value::Integer(0)
192        );
193    }
194
195    #[test]
196    fn load_reports_a_missing_config_file() {
197        let err = load_global_config(Some(Path::new("/does/not/exist.toml")), &[]).unwrap_err();
198
199        assert!(err.to_string().contains("/does/not/exist.toml"));
200    }
201
202    #[test]
203    fn load_reports_an_override_of_the_wrong_type() {
204        let err = load_global_config(None, &overrides(&["profiler.use_root=42"])).unwrap_err();
205
206        assert!(err.to_string().contains("-D"));
207    }
208
209    #[test]
210    fn load_rejects_an_unknown_profiler_key() {
211        let err =
212            load_global_config(None, &overrides(&["profiler.output_forrmat=json"])).unwrap_err();
213
214        assert!(format!("{err:#}").contains("output_forrmat"));
215    }
216
217    #[test]
218    fn load_rejects_an_unknown_top_level_key() {
219        let err = load_global_config(None, &overrides(&["profilr.use_root=true"])).unwrap_err();
220
221        assert!(format!("{err:#}").contains("profilr"));
222    }
223
224    #[test]
225    fn load_rejects_an_unknown_key_of_a_config_file() {
226        let file = config_file("[profiler]\nuse_rooot = true\n");
227
228        let err = load_global_config(Some(file.path()), &[]).unwrap_err();
229
230        assert!(format!("{err:#}").contains("use_rooot"));
231    }
232}