Skip to main content

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