tanu_core/
config.rs

1//! # Configuration Module
2//!
3//! Handles loading and managing tanu configuration from `tanu.toml` files.
4//! Supports project-specific configurations, environment variables, and
5//! various test execution settings.
6//!
7//! ## Configuration Loading Flow (block diagram)
8//!
9//! ```text
10//! +-------------------+     +-------------------+     +-------------------+
11//! | TANU_CONFIG env   | --> | Path resolution   | --> | tanu.toml file    |
12//! | (optional)        |     | or default ./     |     |                   |
13//! +-------------------+     +-------------------+     +-------------------+
14//!                                                              |
15//!                                                              v
16//! +-------------------+     +-------------------+     +-------------------+
17//! | tanu.toml file    | --> | TOML parser       | --> | Config struct     |
18//! |                   |     | (deserialization) |     | projects[]        |
19//! +-------------------+     +-------------------+     +-------------------+
20//!                                                              |
21//!          +---------------------------------------------------+
22//!          v
23//! +-------------------+     +-------------------+     +-------------------+
24//! | Environment vars  | --> | TANU_* prefix     | --> | Merged into       |
25//! | TANU_KEY=value    |     | TANU_PROJECT_*    |     | project.data      |
26//! +-------------------+     +-------------------+     +-------------------+
27//!                                                              |
28//!                                                              v
29//!                           +-------------------+     +-------------------+
30//!                           | Task-local        | <-- | get_config()      |
31//!                           | PROJECT context   |     | per-test access   |
32//!                           +-------------------+     +-------------------+
33//! ```
34//!
35//! ## Config File Location
36//!
37//! The configuration file is loaded in the following order:
38//!
39//! 1. If `TANU_CONFIG` environment variable is set, load from that path
40//! 2. Otherwise, load from `tanu.toml` in the current directory
41//!
42//! ```bash
43//! # Use custom config file location
44//! TANU_CONFIG=/path/to/my-config.toml cargo run
45//!
46//! # Or use default ./tanu.toml
47//! cargo run
48//! ```
49//!
50//! **Note:** `TANU_CONFIG` is reserved for specifying the config file path.
51//! Do not use it as a config value key. If tanu detects misuse (e.g.,
52//! `TANU_CONFIG=true`), it will error with a helpful message.
53//!
54//! ## Configuration Structure
55//!
56//! Tanu uses TOML configuration files with the following structure:
57//!
58//! ```toml
59//! [[projects]]
60//! name = "staging"
61//! base_url = "https://staging.api.example.com"
62//! timeout = 30000
63//! retry.count = 3
64//! retry.factor = 2.0
65//! test_ignore = ["slow_test", "flaky_test"]
66//!
67//! [[projects]]
68//! name = "production"
69//! base_url = "https://api.example.com"
70//! timeout = 10000
71//! ```
72//!
73//! ## Usage
74//!
75//! ```rust,ignore
76//! use tanu::{get_config, get_tanu_config};
77//!
78//! // Get global configuration
79//! let config = get_tanu_config();
80//!
81//! // Get current project configuration (within test context)
82//! let project_config = get_config();
83//! let base_url = project_config.get_str("base_url").unwrap_or_default();
84//! ```
85
86use chrono::{DateTime, Utc};
87use once_cell::sync::Lazy;
88use serde::{de::DeserializeOwned, Deserialize};
89use std::{collections::HashMap, io::Read, path::Path, sync::Arc, time::Duration};
90use toml::Value as TomlValue;
91use tracing::*;
92
93use crate::{Error, Result};
94
95/// Environment variable name for specifying the config file path.
96const TANU_CONFIG_ENV: &str = "TANU_CONFIG";
97
98static CONFIG: Lazy<Config> = Lazy::new(|| {
99    let _ = dotenv::dotenv();
100    Config::load().unwrap_or_default()
101});
102
103tokio::task_local! {
104    pub static PROJECT: Arc<ProjectConfig>;
105}
106
107#[doc(hidden)]
108pub fn get_tanu_config() -> &'static Config {
109    &CONFIG
110}
111
112/// Get configuration for the current project. This function has to be called in the tokio
113/// task created by tanu runner. Otherwise, calling this function will panic.
114pub fn get_config() -> Arc<ProjectConfig> {
115    PROJECT.get()
116}
117
118/// tanu's configuration.
119#[derive(Debug, Clone)]
120pub struct Config {
121    pub projects: Vec<Arc<ProjectConfig>>,
122    /// Global tanu configuration
123    pub tui: Tui,
124}
125
126impl Default for Config {
127    fn default() -> Self {
128        Config {
129            projects: vec![Arc::new(ProjectConfig {
130                name: "default".to_string(),
131                ..Default::default()
132            })],
133            tui: Tui::default(),
134        }
135    }
136}
137
138/// Global tanu configuration
139#[derive(Debug, Clone, Default, Deserialize)]
140pub struct Tui {
141    #[serde(default)]
142    pub payload: Payload,
143}
144
145#[derive(Debug, Clone, Default, Deserialize)]
146pub struct Payload {
147    /// Optional color theme for terminal output
148    pub color_theme: Option<String>,
149}
150
151impl Config {
152    /// Load tanu configuration from path.
153    fn load_from(path: &Path) -> Result<Config> {
154        let Ok(mut file) = std::fs::File::open(path) else {
155            return Ok(Config::default());
156        };
157
158        let mut buf = String::new();
159        file.read_to_string(&mut buf)
160            .map_err(|e| Error::LoadError(e.to_string()))?;
161
162        #[derive(Deserialize)]
163        struct ConfigHelper {
164            #[serde(default)]
165            projects: Vec<ProjectConfig>,
166            #[serde(default)]
167            tui: Tui,
168        }
169
170        let helper: ConfigHelper = toml::from_str(&buf).map_err(|e| {
171            Error::LoadError(format!(
172                "failed to deserialize tanu.toml into tanu::Config: {e}"
173            ))
174        })?;
175
176        let mut cfg = Config {
177            projects: helper.projects.into_iter().map(Arc::new).collect(),
178            tui: helper.tui,
179        };
180
181        debug!("tanu.toml was successfully loaded: {cfg:#?}");
182
183        cfg.load_env();
184
185        Ok(cfg)
186    }
187
188    /// Load tanu configuration.
189    ///
190    /// Loading order:
191    /// 1. If `TANU_CONFIG` env var is set, load from that path
192    /// 2. Otherwise, load from `tanu.toml` in the current directory
193    fn load() -> Result<Config> {
194        match std::env::var(TANU_CONFIG_ENV) {
195            Ok(path) => {
196                let path = Path::new(&path);
197
198                // Detect misuse: if it doesn't look like a file path, error out
199                if path.extension().is_none_or(|ext| ext != "toml")
200                    && !path.to_string_lossy().contains(std::path::MAIN_SEPARATOR)
201                    && !path.to_string_lossy().contains('/')
202                {
203                    return Err(Error::LoadError(format!(
204                        "{TANU_CONFIG_ENV} should be a path to a config file, not a config value. \
205                         Got: {:?}. Use TANU_<KEY>=value for config values instead.",
206                        path
207                    )));
208                }
209
210                if !path.exists() {
211                    return Err(Error::LoadError(format!(
212                        "Config file specified by {TANU_CONFIG_ENV} not found: {:?}",
213                        path
214                    )));
215                }
216
217                debug!("Loading config from {TANU_CONFIG_ENV}={:?}", path);
218                Config::load_from(path)
219            }
220            Err(_) => Config::load_from(Path::new("tanu.toml")),
221        }
222    }
223
224    /// Load tanu configuration from environment variables.
225    ///
226    /// Global environment variables: tanu automatically detects environment variables prefixed
227    /// with tanu_XXX and maps them to the corresponding configuration variable as "xxx". This
228    /// global configuration can be accessed in any project.
229    ///
230    /// Project environment variables: tanu automatically detects environment variables prefixed
231    /// with tanu_PROJECT_ZZZ_XXX and maps them to the corresponding configuration variable as
232    /// "xxx" for project "ZZZ". This configuration is isolated within the project.
233    fn load_env(&mut self) {
234        static PREFIX: &str = "TANU";
235
236        let global_prefix = format!("{PREFIX}_");
237        let project_prefixes: Vec<_> = self
238            .projects
239            .iter()
240            .map(|p| format!("{PREFIX}_{}_", p.name.to_uppercase()))
241            .collect();
242        debug!("Loading global configuration from env");
243        let global_vars: HashMap<_, _> = std::env::vars()
244            .filter_map(|(k, v)| {
245                // Skip TANU_CONFIG as it's used for config file path, not a config value
246                if k == TANU_CONFIG_ENV {
247                    // Log error if it looks like misuse (value doesn't look like a file path)
248                    let path = Path::new(&v);
249                    if path.extension().is_none_or(|ext| ext != "toml")
250                        && !v.contains(std::path::MAIN_SEPARATOR)
251                        && !v.contains('/')
252                    {
253                        error!(
254                            "{TANU_CONFIG_ENV} is reserved for specifying the config file path, \
255                             not a config value. Use TANU_<KEY>=value for config values instead. \
256                             Got: {TANU_CONFIG_ENV}={v:?}"
257                        );
258                    }
259                    return None;
260                }
261
262                let is_project_var = project_prefixes.iter().any(|pp| k.contains(pp));
263                if is_project_var {
264                    return None;
265                }
266
267                k.find(&global_prefix)?;
268                Some((
269                    k[global_prefix.len()..].to_string().to_lowercase(),
270                    TomlValue::String(v),
271                ))
272            })
273            .collect();
274
275        debug!("Loading project configuration from env");
276        for project_arc in &mut self.projects {
277            let project_prefix = format!("{PREFIX}_{}_", project_arc.name.to_uppercase());
278            let vars: HashMap<_, _> = std::env::vars()
279                .filter_map(|(k, v)| {
280                    k.find(&project_prefix)?;
281                    Some((
282                        k[project_prefix.len()..].to_string().to_lowercase(),
283                        TomlValue::String(v),
284                    ))
285                })
286                .collect();
287            let project = Arc::make_mut(project_arc);
288            project.data.extend(vars);
289            project.data.extend(global_vars.clone());
290        }
291
292        debug!("tanu configuration loaded from env: {self:#?}");
293    }
294
295    /// Get the current color theme
296    pub fn color_theme(&self) -> Option<&str> {
297        self.tui.payload.color_theme.as_deref()
298    }
299}
300
301/// tanu's project configuration.
302#[derive(Debug, Clone, Default, Deserialize)]
303pub struct ProjectConfig {
304    /// Project name specified by user.
305    pub name: String,
306    /// Keys and values specified by user.
307    #[serde(flatten)]
308    pub data: HashMap<String, TomlValue>,
309    /// List of files to ignore in the project.
310    #[serde(default)]
311    pub test_ignore: Vec<String>,
312    #[serde(default)]
313    pub retry: RetryConfig,
314}
315
316impl ProjectConfig {
317    pub fn get(&self, key: impl AsRef<str>) -> Result<&TomlValue> {
318        let key = key.as_ref();
319        self.data
320            .get(key)
321            .ok_or_else(|| Error::ValueNotFound(key.to_string()))
322    }
323
324    pub fn get_str(&self, key: impl AsRef<str>) -> Result<&str> {
325        let key = key.as_ref();
326        self.get(key)?
327            .as_str()
328            .ok_or_else(|| Error::ValueNotFound(key.to_string()))
329    }
330
331    pub fn get_int(&self, key: impl AsRef<str>) -> Result<i64> {
332        self.get_str(key)?
333            .parse()
334            .map_err(|e| Error::ValueError(eyre::Error::from(e)))
335    }
336
337    pub fn get_float(&self, key: impl AsRef<str>) -> Result<f64> {
338        self.get_str(key)?
339            .parse()
340            .map_err(|e| Error::ValueError(eyre::Error::from(e)))
341    }
342
343    pub fn get_bool(&self, key: impl AsRef<str>) -> Result<bool> {
344        self.get_str(key)?
345            .parse()
346            .map_err(|e| Error::ValueError(eyre::Error::from(e)))
347    }
348
349    pub fn get_datetime(&self, key: impl AsRef<str>) -> Result<DateTime<Utc>> {
350        self.get_str(key)?
351            .parse::<DateTime<Utc>>()
352            .map_err(|e| Error::ValueError(eyre::Error::from(e)))
353    }
354
355    pub fn get_array<T: DeserializeOwned>(&self, key: impl AsRef<str>) -> Result<Vec<T>> {
356        serde_json::from_str(self.get_str(key)?)
357            .map_err(|e| Error::ValueError(eyre::Error::from(e)))
358    }
359
360    pub fn get_object<T: DeserializeOwned>(&self, key: impl AsRef<str>) -> Result<T> {
361        serde_json::from_str(self.get_str(key)?)
362            .map_err(|e| Error::ValueError(eyre::Error::from(e)))
363    }
364}
365
366#[derive(Debug, Clone, Deserialize)]
367pub struct RetryConfig {
368    /// Number of retries.
369    #[serde(default)]
370    pub count: Option<usize>,
371    /// Factor to multiply the delay between retries.
372    #[serde(default)]
373    pub factor: Option<f32>,
374    /// Whether to add jitter to the delay between retries.
375    #[serde(default)]
376    pub jitter: Option<bool>,
377    /// Minimum delay between retries.
378    #[serde(default)]
379    #[serde(with = "humantime_serde")]
380    pub min_delay: Option<Duration>,
381    /// Maximum delay between retries.
382    #[serde(default)]
383    #[serde(with = "humantime_serde")]
384    pub max_delay: Option<Duration>,
385}
386
387impl Default for RetryConfig {
388    fn default() -> Self {
389        RetryConfig {
390            count: Some(0),
391            factor: Some(2.0),
392            jitter: Some(false),
393            min_delay: Some(Duration::from_secs(1)),
394            max_delay: Some(Duration::from_secs(60)),
395        }
396    }
397}
398
399impl RetryConfig {
400    pub fn backoff(&self) -> backon::ExponentialBuilder {
401        let builder = backon::ExponentialBuilder::new()
402            .with_max_times(self.count.unwrap_or_default())
403            .with_factor(self.factor.unwrap_or(2.0))
404            .with_min_delay(self.min_delay.unwrap_or(Duration::from_secs(1)))
405            .with_max_delay(self.max_delay.unwrap_or(Duration::from_secs(60)));
406
407        if self.jitter.unwrap_or_default() {
408            builder.with_jitter()
409        } else {
410            builder
411        }
412    }
413}
414
415#[cfg(test)]
416mod test {
417    use super::*;
418    use pretty_assertions::assert_eq;
419    use std::{time::Duration, vec};
420    use test_case::test_case;
421
422    fn load_test_config() -> eyre::Result<Config> {
423        let manifest_dir = env!("CARGO_MANIFEST_DIR");
424        let config_path = Path::new(manifest_dir).join("../tanu-sample.toml");
425        Ok(super::Config::load_from(&config_path)?)
426    }
427
428    fn load_test_project_config() -> eyre::Result<ProjectConfig> {
429        Ok(Arc::try_unwrap(load_test_config()?.projects.remove(0)).unwrap())
430    }
431
432    #[test]
433    fn load_config() -> eyre::Result<()> {
434        let cfg = load_test_config()?;
435        assert_eq!(cfg.projects.len(), 1);
436
437        let project = &cfg.projects[0];
438        assert_eq!(project.name, "default");
439        assert_eq!(project.test_ignore, Vec::<String>::new());
440        assert_eq!(project.retry.count, Some(0));
441        assert_eq!(project.retry.factor, Some(2.0));
442        assert_eq!(project.retry.jitter, Some(false));
443        assert_eq!(project.retry.min_delay, Some(Duration::from_secs(1)));
444        assert_eq!(project.retry.max_delay, Some(Duration::from_secs(60)));
445
446        Ok(())
447    }
448
449    #[test_case("TANU_DEFAULT_STR_KEY"; "project config")]
450    #[test_case("TANU_STR_KEY"; "global config")]
451    fn get_str(key: &str) -> eyre::Result<()> {
452        std::env::set_var(key, "example_string");
453        let project = load_test_project_config()?;
454        assert_eq!(project.get_str("str_key")?, "example_string");
455        Ok(())
456    }
457
458    #[test_case("TANU_DEFAULT_INT_KEY"; "project config")]
459    #[test_case("TANU_INT_KEY"; "global config")]
460    fn get_int(key: &str) -> eyre::Result<()> {
461        std::env::set_var(key, "42");
462        let project = load_test_project_config()?;
463        assert_eq!(project.get_int("int_key")?, 42);
464        Ok(())
465    }
466
467    #[test_case("TANU_DEFAULT"; "project config")]
468    #[test_case("TANU"; "global config")]
469    fn get_float(prefix: &str) -> eyre::Result<()> {
470        std::env::set_var(format!("{prefix}_FLOAT_KEY"), "5.5");
471        let project = load_test_project_config()?;
472        assert_eq!(project.get_float("float_key")?, 5.5);
473        Ok(())
474    }
475
476    #[test_case("TANU_DEFAULT_BOOL_KEY"; "project config")]
477    #[test_case("TANU_BOOL_KEY"; "global config")]
478    fn get_bool(key: &str) -> eyre::Result<()> {
479        std::env::set_var(key, "true");
480        let project = load_test_project_config()?;
481        assert_eq!(project.get_bool("bool_key")?, true);
482        Ok(())
483    }
484
485    #[test_case("TANU_DEFAULT_DATETIME_KEY"; "project config")]
486    #[test_case("TANU_DATETIME_KEY"; "global config")]
487    fn get_datetime(key: &str) -> eyre::Result<()> {
488        let datetime_str = "2025-03-08T12:00:00Z";
489        std::env::set_var(key, datetime_str);
490        let project = load_test_project_config()?;
491        assert_eq!(
492            project
493                .get_datetime("datetime_key")?
494                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
495            datetime_str
496        );
497        Ok(())
498    }
499
500    #[test_case("TANU_DEFAULT_ARRAY_KEY"; "project config")]
501    #[test_case("TANU_ARRAY_KEY"; "global config")]
502    fn get_array(key: &str) -> eyre::Result<()> {
503        std::env::set_var(key, "[1, 2, 3]");
504        let project = load_test_project_config()?;
505        let array: Vec<i64> = project.get_array("array_key")?;
506        assert_eq!(array, vec![1, 2, 3]);
507        Ok(())
508    }
509
510    #[test_case("TANU_DEFAULT"; "project config")]
511    #[test_case("TANU"; "global config")]
512    fn get_object(prefix: &str) -> eyre::Result<()> {
513        #[derive(Debug, Deserialize, PartialEq)]
514        struct Foo {
515            foo: Vec<String>,
516        }
517        std::env::set_var(
518            format!("{prefix}_OBJECT_KEY"),
519            "{\"foo\": [\"bar\", \"baz\"]}",
520        );
521        let project = load_test_project_config()?;
522        let obj: Foo = project.get_object("object_key")?;
523        assert_eq!(obj.foo, vec!["bar", "baz"]);
524        Ok(())
525    }
526
527    mod tanu_config_env {
528        use super::{Config, Path, TANU_CONFIG_ENV};
529        use pretty_assertions::assert_eq;
530        use test_case::test_case;
531
532        #[test]
533        fn load_from_tanu_config_env() {
534            let manifest_dir = env!("CARGO_MANIFEST_DIR");
535            let config_path = Path::new(manifest_dir).join("../tanu-sample.toml");
536
537            std::env::set_var(TANU_CONFIG_ENV, config_path.to_str().unwrap());
538            let cfg = Config::load().unwrap();
539            std::env::remove_var(TANU_CONFIG_ENV);
540
541            assert_eq!(cfg.projects.len(), 1);
542            assert_eq!(cfg.projects[0].name, "default");
543        }
544
545        #[test]
546        fn error_when_file_not_found() {
547            std::env::set_var(TANU_CONFIG_ENV, "/nonexistent/path/tanu.toml");
548            let result = Config::load();
549            std::env::remove_var(TANU_CONFIG_ENV);
550
551            assert!(result.is_err());
552            let err = result.unwrap_err().to_string();
553            assert!(err.contains("not found"), "error should mention file not found: {err}");
554        }
555
556        #[test_case("true"; "boolean value")]
557        #[test_case("123"; "numeric value")]
558        #[test_case("some_value"; "string value")]
559        fn error_when_value_looks_like_config_value(value: &str) {
560            std::env::set_var(TANU_CONFIG_ENV, value);
561            let result = Config::load();
562            std::env::remove_var(TANU_CONFIG_ENV);
563
564            assert!(result.is_err());
565            let err = result.unwrap_err().to_string();
566            assert!(
567                err.contains("should be a path"),
568                "error should guide user: {err}"
569            );
570        }
571
572        #[test_case("config.toml"; "toml extension")]
573        #[test_case("./tanu.toml"; "relative path with dot")]
574        #[test_case("configs/tanu.toml"; "path with separator")]
575        fn accepts_valid_path_patterns(value: &str) {
576            std::env::set_var(TANU_CONFIG_ENV, value);
577            let result = Config::load();
578            std::env::remove_var(TANU_CONFIG_ENV);
579
580            // These should fail with "not found", not "should be a path"
581            assert!(result.is_err());
582            let err = result.unwrap_err().to_string();
583            assert!(
584                err.contains("not found"),
585                "valid path pattern should fail with 'not found', not path validation: {err}"
586            );
587        }
588    }
589}