1use 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
95const 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
112pub fn get_config() -> Arc<ProjectConfig> {
115 PROJECT.get()
116}
117
118#[derive(Debug, Clone)]
120pub struct Config {
121 pub projects: Vec<Arc<ProjectConfig>>,
122 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#[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 pub color_theme: Option<String>,
149}
150
151impl Config {
152 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 fn load() -> Result<Config> {
194 match std::env::var(TANU_CONFIG_ENV) {
195 Ok(path) => {
196 let path = Path::new(&path);
197
198 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 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 if k == TANU_CONFIG_ENV {
247 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 pub fn color_theme(&self) -> Option<&str> {
297 self.tui.payload.color_theme.as_deref()
298 }
299}
300
301#[derive(Debug, Clone, Default, Deserialize)]
303pub struct ProjectConfig {
304 pub name: String,
306 #[serde(flatten)]
308 pub data: HashMap<String, TomlValue>,
309 #[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 #[serde(default)]
370 pub count: Option<usize>,
371 #[serde(default)]
373 pub factor: Option<f32>,
374 #[serde(default)]
376 pub jitter: Option<bool>,
377 #[serde(default)]
379 #[serde(with = "humantime_serde")]
380 pub min_delay: Option<Duration>,
381 #[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 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}