Skip to main content

joule_profiler_cli/config/
table.rs

1//! Configuration management for Joule Profiler.
2
3use std::collections::{HashMap, HashSet};
4
5use anyhow::{Result, bail};
6use clap::ValueEnum;
7use joule_profiler_core::{
8    config::{Command, Config, ProfileConfigBuilder},
9    source::MetricReader,
10};
11use log::warn;
12
13use crate::{
14    CliArgs, ProfilerCommand, Source,
15    config::{GlobalConfig, ProfilerConfig, source::MetricSourceConfig},
16};
17
18/// The structure used to resolve sources from toml configuration and the profiler config.
19#[derive(Debug)]
20pub struct ConfigTable {
21    /// Global Joule Profiler configuration.
22    pub profiler_config: ProfilerConfig,
23
24    /// Raw TOML values for each named metric source, keyed by source ID.
25    pub sources_config: HashMap<String, toml::Value>,
26
27    /// The set of source IDs that are enabled.
28    enabled_sources: HashSet<String>,
29}
30
31impl ConfigTable {
32    /// Creates a new [`ConfigTable`] from a [`GlobalConfig`] and the
33    /// sources enabled from the CLI.
34    ///
35    /// The `enabled_sources` set is the union of:
36    /// - sources declared in the global config file, and
37    /// - sources passed directly via the `sources` CLI argument.
38    pub fn new(global_config: GlobalConfig, sources: &[Source]) -> Self {
39        let enabled_sources: HashSet<_> = global_config
40            .sources
41            .keys()
42            .cloned()
43            .chain(sources.iter().map(Source::to_string))
44            .collect();
45
46        Self {
47            profiler_config: global_config.profiler,
48            sources_config: global_config.sources,
49            enabled_sources,
50        }
51    }
52
53    /// Applies every CLI override of the global (profiler-level) configuration
54    /// in one pass, so [`ConfigTable::to_config`] can read `profiler_config`
55    /// directly afterwards instead of re-merging `cli` itself.
56    ///
57    /// Only the flags that have no configuration key of their own go through
58    /// here: the `-D` overrides are already merged into the configuration
59    /// before the table is built.
60    pub fn apply_cli(&mut self, cli: &mut CliArgs) {
61        if let Some(output_format) = cli.output_format.take() {
62            self.profiler_config.output_format = output_format;
63        }
64
65        if let Some(output_file) = cli.output_file.take() {
66            self.profiler_config.output_file = Some(output_file);
67        }
68
69        if let ProfilerCommand::Profile(profile_args) = &mut cli.command {
70            if let Some(stdout_file) = profile_args.stdout_file.take() {
71                self.profiler_config.stdout_file = Some(stdout_file);
72            }
73
74            if let Some(token_pattern) = profile_args.token_pattern.take() {
75                self.profiler_config.token_pattern = token_pattern;
76            }
77
78            if let Some(init_timeout) = profile_args.init_timeout.take() {
79                self.profiler_config.init_timeout = init_timeout;
80            }
81
82            self.profiler_config.use_root |= profile_args.use_root;
83        }
84    }
85
86    /// Builds a metric source reader using the provided configuration
87    /// into the config table, or default configuration if not configured.
88    ///
89    /// It returns an error if the source initialization fails and `ignore_on_failure` is not set.
90    pub fn build_source<R>(&mut self) -> Result<Option<R>>
91    where
92        R: MetricReader,
93    {
94        if !self.enabled_sources.contains(R::get_id()) {
95            return Ok(None);
96        }
97
98        let config_wrapper = match self.sources_config.remove(R::get_id()) {
99            Some(v) => v.try_into(),
100            None => Ok(MetricSourceConfig::default()),
101        }?;
102
103        let config = config_wrapper.inner;
104
105        match R::from_config(config) {
106            Ok(reader) => Ok(Some(reader)),
107            Err(e) => {
108                if config_wrapper.ignore_on_failure {
109                    warn!(
110                        "Failed to initialize source {}, skipping. Error: {e}",
111                        R::get_name()
112                    );
113                    Ok(None)
114                } else {
115                    Err(e.into())
116                }
117            }
118        }
119    }
120
121    /// Builds a metric source reader, applying an external config
122    /// override through a caller-supplied closure before construction.
123    ///
124    /// It returns an error if the source initialization fails and `ignore_on_failure` is not set.
125    pub fn build_source_override<R>(
126        &mut self,
127        config_override_fn: impl FnOnce(&mut R::Config),
128    ) -> Result<Option<R>>
129    where
130        R: MetricReader,
131    {
132        if !self.enabled_sources.contains(R::get_id()) {
133            return Ok(None);
134        }
135
136        let config_wrapper = match self.sources_config.remove(R::get_id()) {
137            Some(v) => v.try_into(),
138            None => Ok(MetricSourceConfig::default()),
139        }?;
140
141        let mut config = config_wrapper.inner;
142
143        config_override_fn(&mut config);
144
145        match R::from_config(config) {
146            Ok(reader) => Ok(Some(reader)),
147            Err(e) => {
148                if config_wrapper.ignore_on_failure {
149                    warn!(
150                        "Failed to initialize source {}, skipping. Error: {e}",
151                        R::get_name()
152                    );
153                    Ok(None)
154                } else {
155                    Err(e.into())
156                }
157            }
158        }
159    }
160}
161
162impl ConfigTable {
163    /// Errors on the configured sources no registered source claimed.
164    ///
165    /// Must be called once every source has been registered: `build_source`
166    /// takes its own section out of the table, so what is left can only be
167    /// misspelled source names.
168    pub fn ensure_sources_are_known(&self) -> Result<()> {
169        if self.sources_config.is_empty() {
170            return Ok(());
171        }
172
173        let mut unknown: Vec<&str> = self.sources_config.keys().map(String::as_str).collect();
174        unknown.sort_unstable();
175
176        let known: Vec<String> = Source::value_variants()
177            .iter()
178            .map(Source::to_string)
179            .collect();
180
181        bail!(
182            "unknown metric source `{}`. Available sources: {}.",
183            unknown.join("`, `"),
184            known.join(", "),
185        )
186    }
187
188    /// Consumes the [`ConfigTable`] and a final [`CliArgs`] to produce the
189    /// core [`Config`].
190    ///
191    /// Returns an error if the configuration is invalid.
192    pub fn to_config(self, cli: CliArgs) -> Result<Config> {
193        let command = match cli.command {
194            ProfilerCommand::Profile(profile_args) => {
195                let mut builder = ProfileConfigBuilder::default();
196
197                let config = builder
198                    .cmd(profile_args.cmd)
199                    .stdout_file(self.profiler_config.stdout_file)
200                    .token_pattern(self.profiler_config.token_pattern)
201                    .use_root(self.profiler_config.use_root)
202                    .init_timeout(self.profiler_config.init_timeout)
203                    .build()?;
204
205                Command::Profile(config)
206            }
207            ProfilerCommand::ListSensors => Command::ListSensors,
208        };
209
210        Ok(Config { command })
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use std::time::Duration;
217
218    use joule_profiler_core::{sensor::Sensors, types::Metrics};
219    use serde::Deserialize;
220
221    use super::*;
222    use crate::{commands::profile::ProfileArgs, output::formats::OutputFormat};
223
224    #[derive(Debug, Default, Deserialize)]
225    struct MockConfig {
226        #[serde(default)]
227        fail: bool,
228        #[serde(default)]
229        value: u32,
230    }
231
232    #[derive(Debug, thiserror::Error)]
233    #[error("mock source failure")]
234    struct MockError;
235
236    struct MockSource {
237        value: u32,
238    }
239
240    impl MetricReader for MockSource {
241        type Type = ();
242        type Error = MockError;
243        type Config = MockConfig;
244
245        fn from_config(config: MockConfig) -> std::result::Result<Self, MockError> {
246            if config.fail {
247                Err(MockError)
248            } else {
249                Ok(Self {
250                    value: config.value,
251                })
252            }
253        }
254
255        async fn measure(&mut self) -> std::result::Result<(), MockError> {
256            Ok(())
257        }
258
259        async fn retrieve(&mut self) -> std::result::Result<(), MockError> {
260            Ok(())
261        }
262
263        fn get_sensors(&self) -> std::result::Result<Sensors, MockError> {
264            Ok(Vec::new())
265        }
266
267        fn to_metrics(&self, (): ()) -> std::result::Result<Metrics, MockError> {
268            Ok(Metrics::default())
269        }
270
271        fn get_name() -> &'static str {
272            "Mock"
273        }
274
275        fn get_id() -> &'static str {
276            "mock"
277        }
278    }
279
280    fn config_table_with(profiler_config: ProfilerConfig) -> ConfigTable {
281        ConfigTable {
282            profiler_config,
283            sources_config: HashMap::new(),
284            enabled_sources: HashSet::new(),
285        }
286    }
287
288    fn cli_args(command: ProfilerCommand) -> CliArgs {
289        CliArgs {
290            verbose: 0,
291            output_format: None,
292            output_file: None,
293            sources: Vec::new(),
294            overrides: Vec::new(),
295            config_file: None,
296            command,
297        }
298    }
299
300    fn configured_profiler_config() -> ProfilerConfig {
301        ProfilerConfig {
302            stdout_file: Some("configured_stdout.txt".to_owned()),
303            token_pattern: "__CONFIGURED__".to_owned(),
304            use_root: false,
305            output_file: Some("configured_output.json".to_owned()),
306            output_format: OutputFormat::Json,
307            init_timeout: Duration::from_secs(5),
308            #[cfg(feature = "_rapl")]
309            rapl_backend: crate::RaplBackend::default(),
310        }
311    }
312
313    #[test]
314    fn profiler_config_uses_default_config_when_not_override() {
315        let config = ProfilerConfig::default();
316
317        assert_eq!(config.stdout_file, None);
318        assert_eq!(config.output_file, None);
319        assert_eq!(config.token_pattern, "__[A-Z0-9_]+__");
320        assert_eq!(config.init_timeout, Duration::from_secs(1));
321        assert!(!config.use_root);
322        assert!(matches!(config.output_format, OutputFormat::Terminal));
323    }
324
325    #[test]
326    fn new_enabled_sources_is_union_of_config_file_and_cli() {
327        let from_cli = Source::value_variants()[0].clone();
328
329        let mut sources = HashMap::new();
330        sources.insert(
331            "from_file".to_owned(),
332            toml::Value::Table(toml::map::Map::default()),
333        );
334
335        let global = GlobalConfig {
336            profiler: ProfilerConfig::default(),
337            sources,
338        };
339
340        let table = ConfigTable::new(global, std::slice::from_ref(&from_cli));
341
342        assert!(table.enabled_sources.contains("from_file"));
343        assert!(table.enabled_sources.contains(&from_cli.to_string()));
344        assert!(!table.enabled_sources.contains("not_configured"));
345    }
346
347    #[test]
348    fn new_enabled_sources_empty_by_default() {
349        let table = ConfigTable::new(GlobalConfig::default(), &[]);
350        assert!(table.enabled_sources.is_empty());
351    }
352
353    #[test]
354    fn apply_cli_with_no_overrides_keeps_the_base_config() {
355        let mut table = config_table_with(configured_profiler_config());
356        let mut cli = cli_args(ProfilerCommand::Profile(ProfileArgs {
357            cmd: vec!["echo".to_owned()],
358            ..Default::default()
359        }));
360
361        table.apply_cli(&mut cli);
362
363        assert_eq!(
364            table.profiler_config.stdout_file.as_deref(),
365            Some("configured_stdout.txt")
366        );
367        assert_eq!(table.profiler_config.token_pattern, "__CONFIGURED__");
368        assert_eq!(
369            table.profiler_config.output_file.as_deref(),
370            Some("configured_output.json")
371        );
372        assert!(matches!(
373            table.profiler_config.output_format,
374            OutputFormat::Json
375        ));
376        assert_eq!(table.profiler_config.init_timeout, Duration::from_secs(5));
377        assert!(!table.profiler_config.use_root);
378    }
379
380    #[test]
381    fn apply_cli_overrides_output_format_and_consumes_it() {
382        let mut table = config_table_with(ProfilerConfig::default());
383        let mut cli = cli_args(ProfilerCommand::ListSensors);
384        cli.output_format = Some(OutputFormat::Csv);
385
386        table.apply_cli(&mut cli);
387
388        assert!(matches!(
389            table.profiler_config.output_format,
390            OutputFormat::Csv
391        ));
392        assert!(cli.output_format.is_none());
393    }
394
395    #[test]
396    fn apply_cli_overrides_output_file_and_consumes_it() {
397        let mut table = config_table_with(ProfilerConfig::default());
398        let mut cli = cli_args(ProfilerCommand::ListSensors);
399        cli.output_file = Some("cli_output.csv".to_owned());
400
401        table.apply_cli(&mut cli);
402
403        assert_eq!(
404            table.profiler_config.output_file.as_deref(),
405            Some("cli_output.csv")
406        );
407        assert!(cli.output_file.is_none());
408    }
409
410    #[test]
411    fn apply_cli_overrides_profile_only_fields_and_consumes_them() {
412        let mut table = config_table_with(ProfilerConfig::default());
413        let mut cli = cli_args(ProfilerCommand::Profile(ProfileArgs {
414            cmd: vec!["sleep".to_owned(), "1".to_owned()],
415            stdout_file: Some("cli_stdout.txt".to_owned()),
416            token_pattern: Some("__CLI__".to_owned()),
417            init_timeout: Some(Duration::from_secs(9)),
418            use_root: true,
419        }));
420
421        table.apply_cli(&mut cli);
422
423        assert_eq!(
424            table.profiler_config.stdout_file.as_deref(),
425            Some("cli_stdout.txt")
426        );
427        assert_eq!(table.profiler_config.token_pattern, "__CLI__");
428        assert_eq!(table.profiler_config.init_timeout, Duration::from_secs(9));
429        assert!(table.profiler_config.use_root);
430
431        let ProfilerCommand::Profile(args) = &cli.command else {
432            panic!("expected a Profile command");
433        };
434        assert!(args.stdout_file.is_none());
435        assert!(args.token_pattern.is_none());
436        assert!(args.init_timeout.is_none());
437    }
438
439    #[test]
440    fn apply_cli_use_root_not_overwritten() {
441        let mut table = config_table_with(ProfilerConfig {
442            use_root: true,
443            ..ProfilerConfig::default()
444        });
445        let mut cli = cli_args(ProfilerCommand::Profile(ProfileArgs {
446            cmd: vec!["true".to_owned()],
447            use_root: false,
448            ..Default::default()
449        }));
450
451        table.apply_cli(&mut cli);
452
453        assert!(table.profiler_config.use_root);
454    }
455
456    #[test]
457    fn to_config_profile_reads_resolved_fields_from_profiler_config() {
458        let table = config_table_with(configured_profiler_config());
459        let cli = cli_args(ProfilerCommand::Profile(ProfileArgs {
460            cmd: vec!["sleep".to_owned(), "1".to_owned()],
461            ..Default::default()
462        }));
463
464        let config = table.to_config(cli).unwrap();
465
466        let Command::Profile(profile_config) = config.command else {
467            panic!("expected a Profile command");
468        };
469        assert_eq!(profile_config.cmd, vec!["sleep".to_owned(), "1".to_owned()]);
470        assert_eq!(
471            profile_config.stdout_file.as_deref(),
472            Some("configured_stdout.txt")
473        );
474        assert_eq!(profile_config.token_pattern, "__CONFIGURED__");
475        assert_eq!(profile_config.init_timeout, Duration::from_secs(5));
476        assert!(!profile_config.use_root);
477    }
478
479    #[test]
480    fn build_source_returns_none_when_not_enabled() {
481        let mut table = config_table_with(ProfilerConfig::default());
482        assert!(table.build_source::<MockSource>().unwrap().is_none());
483    }
484
485    #[test]
486    fn build_source_uses_default_config_when_no_section_present() {
487        let mut table = config_table_with(ProfilerConfig::default());
488        table.enabled_sources.insert("mock".to_owned());
489
490        let source = table.build_source::<MockSource>().unwrap().unwrap();
491        assert_eq!(source.value, 0);
492    }
493
494    #[test]
495    fn build_source_uses_the_matching_config_section() {
496        let mut table = config_table_with(ProfilerConfig::default());
497        table.enabled_sources.insert("mock".to_owned());
498        table
499            .sources_config
500            .insert("mock".to_owned(), toml::from_str("value = 42").unwrap());
501
502        let source = table.build_source::<MockSource>().unwrap().unwrap();
503        assert_eq!(source.value, 42);
504    }
505
506    #[test]
507    fn build_source_ignore_on_failure_ignores_the_error() {
508        let mut table = config_table_with(ProfilerConfig::default());
509        table.enabled_sources.insert("mock".to_owned());
510        table.sources_config.insert(
511            "mock".to_owned(),
512            toml::from_str("fail = true\nignore_on_failure = true").unwrap(),
513        );
514
515        assert!(table.build_source::<MockSource>().unwrap().is_none());
516    }
517
518    #[test]
519    fn build_source_without_ignore_on_failure_propagates_the_error() {
520        let mut table = config_table_with(ProfilerConfig::default());
521        table.enabled_sources.insert("mock".to_owned());
522        table
523            .sources_config
524            .insert("mock".to_owned(), toml::from_str("fail = true").unwrap());
525
526        assert!(table.build_source::<MockSource>().is_err());
527    }
528
529    #[test]
530    fn build_source_override_applies_the_closure_before_construction() {
531        let mut table = config_table_with(ProfilerConfig::default());
532        table.enabled_sources.insert("mock".to_owned());
533
534        let source = table
535            .build_source_override::<MockSource>(|config| config.value = 99)
536            .unwrap()
537            .unwrap();
538
539        assert_eq!(source.value, 99);
540    }
541
542    #[test]
543    fn ensure_sources_are_known_accepts_a_fully_consumed_table() {
544        let mut table = config_table_with(ProfilerConfig::default());
545        table.enabled_sources.insert("mock".to_owned());
546        table
547            .sources_config
548            .insert("mock".to_owned(), toml::from_str("value = 1").unwrap());
549
550        table.build_source::<MockSource>().unwrap();
551
552        table.ensure_sources_are_known().unwrap();
553    }
554
555    #[test]
556    fn ensure_sources_are_known_rejects_an_unclaimed_source() {
557        let mut table = config_table_with(ProfilerConfig::default());
558        table
559            .sources_config
560            .insert("nope".to_owned(), toml::from_str("value = 1").unwrap());
561
562        let err = table.ensure_sources_are_known().unwrap_err();
563
564        assert!(err.to_string().contains("nope"));
565        assert!(
566            err.to_string()
567                .contains(&Source::value_variants()[0].to_string())
568        );
569    }
570}