joule_profiler_cli/config/
table.rs1use 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#[derive(Debug)]
20pub struct ConfigTable {
21 pub profiler_config: ProfilerConfig,
23
24 pub sources_config: HashMap<String, toml::Value>,
26
27 enabled_sources: HashSet<String>,
29}
30
31impl ConfigTable {
32 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 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 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 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 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 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}