1use 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
101const 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
118pub fn get_config() -> Arc<ProjectConfig> {
121 PROJECT.get()
122}
123
124#[derive(Debug, Clone)]
126pub struct Config {
127 pub projects: Vec<Arc<ProjectConfig>>,
128 pub tui: Tui,
130 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#[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 pub color_theme: Option<String>,
158}
159
160#[derive(Debug, Clone, Default, Deserialize)]
162pub struct Runner {
163 #[serde(default)]
165 pub capture_http: Option<bool>,
166 #[serde(default)]
168 pub capture_rust: Option<bool>,
169 #[serde(default)]
171 pub show_sensitive: Option<bool>,
172 #[serde(default)]
174 pub concurrency: Option<usize>,
175}
176
177impl Config {
178 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 fn load() -> Result<Config> {
223 match std::env::var(TANU_CONFIG_ENV) {
224 Ok(path) => {
225 let path = Path::new(&path);
226
227 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 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 if k == TANU_CONFIG_ENV {
276 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 pub fn color_theme(&self) -> Option<&str> {
326 self.tui.payload.color_theme.as_deref()
327 }
328}
329
330#[derive(Debug, Clone, Default, Deserialize)]
332pub struct ProjectConfig {
333 pub name: String,
335 #[serde(flatten)]
337 pub data: HashMap<String, TomlValue>,
338 #[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 #[serde(default)]
399 pub count: Option<usize>,
400 #[serde(default)]
402 pub factor: Option<f32>,
403 #[serde(default)]
405 pub jitter: Option<bool>,
406 #[serde(default)]
408 #[serde(with = "humantime_serde")]
409 pub min_delay: Option<Duration>,
410 #[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 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}