eternaltwin_config/
lib.rs

1pub mod backend;
2pub mod frontend;
3pub mod load;
4pub mod mailer;
5pub mod opentelemetry;
6pub mod postgres;
7pub mod scrypt;
8pub mod seed;
9pub mod sqlite;
10
11use crate::backend::{BackendConfig, BackendConfigPatch};
12use crate::frontend::{FrontendConfig, FrontendConfigPatch};
13use crate::load::{ConfigRoot, ConfigRoots};
14use crate::mailer::{MailerConfig, MailerConfigPatch};
15use crate::opentelemetry::{OpentelemetryConfig, OpentelemetryConfigPatch};
16use crate::postgres::{PostgresConfig, PostgresConfigPatch};
17use crate::scrypt::{ScryptConfig, ScryptConfigPatch};
18use crate::seed::{SeedConfig, SeedConfigPatch};
19use crate::sqlite::{SqliteConfig, SqliteConfigPatch};
20use eternaltwin_core::patch::SimplePatch;
21use eternaltwin_core::types::{DisplayErrorChain, WeakError};
22use serde::{Deserialize, Serialize};
23use std::borrow::Borrow;
24use std::collections::hash_map::Entry as HashMapEntry;
25use std::collections::{HashMap, HashSet};
26use std::convert::Infallible;
27use std::fmt::Debug;
28use std::fs;
29use std::io;
30use std::path::{Path, PathBuf};
31use std::string::ToString;
32use std::sync::RwLock;
33use url::Url;
34
35#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
36pub struct ConfigChain {
37  pub items: Vec<ResolvedConfig>,
38  pub default: BuiltinEternaltwinConfigProfile,
39}
40
41impl ConfigChain {
42  pub fn merge(self) -> Config<ConfigSource> {
43    let mut cur = Config::default_for_profile(self.default);
44    for item in self.items.into_iter().rev() {
45      cur = cur.patch(item.config, item.source.clone());
46    }
47    cur
48  }
49
50  #[cfg(test)]
51  pub(crate) fn one(resolved: ResolvedConfig, default: BuiltinEternaltwinConfigProfile) -> Self {
52    Self {
53      items: vec![resolved],
54      default,
55    }
56  }
57
58  pub fn resolve_from_roots(roots: &[ConfigRoot], working_dir: &Path, profile: &EternaltwinConfigProfile) -> Self {
59    let mut resolver = ConfigResolver::new();
60    for root in roots.iter().rev() {
61      resolver.add_root(root, working_dir)
62    }
63    Self {
64      items: resolver.resolved,
65      default: profile.as_builtin_or_dev(),
66    }
67  }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
71pub struct ResolvedConfig {
72  pub source: ConfigSource,
73  format: ConfigFormat,
74  config: ConfigPatch,
75}
76
77impl ResolvedConfig {
78  pub fn parse(s: &str, format: ConfigFormat, prefer_json: bool, source: ConfigSource) -> Result<Self, WeakError> {
79    let (format, partial) = match format {
80      ConfigFormat::Auto => {
81        if prefer_json {
82          match serde_json::from_str(s) {
83            Ok(p) => (ConfigFormat::Json, p),
84            Err(e) => toml::de::from_str(s)
85              .map(|p| (ConfigFormat::Toml, p))
86              .map_err(|_| WeakError::wrap(e))?,
87          }
88        } else {
89          match toml::de::from_str(s) {
90            Ok(p) => (ConfigFormat::Toml, p),
91            Err(e) => serde_json::from_str(s)
92              .map(|p| (ConfigFormat::Json, p))
93              .map_err(|_| WeakError::wrap(e))?,
94          }
95        }
96      }
97      ConfigFormat::Json => (ConfigFormat::Json, serde_json::from_str(s).map_err(WeakError::wrap)?),
98      ConfigFormat::Toml => (ConfigFormat::Toml, toml::de::from_str(s).map_err(WeakError::wrap)?),
99    };
100    Ok(Self {
101      source,
102      format,
103      config: partial,
104    })
105  }
106
107  pub fn extends(&self, working_dir: &Url) -> Vec<(Url, ConfigFormat)> {
108    let extends = match self.config.extends.as_ref() {
109      Some(extends) => extends,
110      None => return Vec::new(),
111    };
112
113    let base_url = match &self.source {
114      ConfigSource::File(Some(file)) => Url::from_file_path(file).expect("invalid file path"),
115      _ => working_dir.clone(),
116    };
117
118    match extends {
119      ConfigRefOrList::One(e) => Self::extends_inner(&base_url, &[e]),
120      ConfigRefOrList::Many(e) => Self::extends_inner(&base_url, e),
121    }
122  }
123
124  fn extends_inner<Cr: Borrow<ConfigRef>>(base_url: &Url, extends: &[Cr]) -> Vec<(Url, ConfigFormat)> {
125    let mut result: Vec<(Url, ConfigFormat)> = Vec::new();
126    for e in extends {
127      let (url, format) = match e.borrow() {
128        ConfigRef::Url(u) => (u, ConfigFormat::Auto),
129        ConfigRef::Typed { url, format } => (url, *format),
130      };
131      let url = resolve_config_ref(base_url, url);
132      result.push((url, format));
133    }
134    result
135  }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
139struct ConfigPatch {
140  extends: Option<ConfigRefOrList>,
141  #[serde(default, skip_serializing_if = "SimplePatch::is_skip")]
142  backend: SimplePatch<BackendConfigPatch>,
143  #[serde(default, skip_serializing_if = "SimplePatch::is_skip")]
144  frontend: SimplePatch<FrontendConfigPatch>,
145  #[serde(default, skip_serializing_if = "SimplePatch::is_skip")]
146  postgres: SimplePatch<PostgresConfigPatch>,
147  #[serde(default, skip_serializing_if = "SimplePatch::is_skip")]
148  mailer: SimplePatch<MailerConfigPatch>,
149  #[serde(default, skip_serializing_if = "SimplePatch::is_skip")]
150  scrypt: SimplePatch<ScryptConfigPatch>,
151  #[serde(default, skip_serializing_if = "SimplePatch::is_skip")]
152  opentelemetry: SimplePatch<OpentelemetryConfigPatch>,
153  #[serde(default, skip_serializing_if = "SimplePatch::is_skip")]
154  seed: SimplePatch<Option<SeedConfigPatch>>,
155  #[serde(default, skip_serializing_if = "SimplePatch::is_skip")]
156  sqlite: SimplePatch<SqliteConfigPatch>,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
160#[serde(untagged)]
161enum ConfigRefOrList {
162  One(ConfigRef),
163  Many(Vec<ConfigRef>),
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
167#[serde(untagged)]
168enum ConfigRef {
169  Url(String),
170  Typed { url: String, format: ConfigFormat },
171}
172
173struct FsCache {
174  state: RwLock<HashMap<PathBuf, io::Result<String>>>,
175}
176
177impl FsCache {
178  pub fn new() -> Self {
179    Self {
180      state: RwLock::new(HashMap::new()),
181    }
182  }
183
184  pub fn read_to_string<F, R>(&self, p: &Path, f: F) -> R
185  where
186    F: for<'a> Fn(&'a io::Result<String>) -> R,
187  {
188    {
189      if let Some(r) = self.state.read().expect("lock is poisoned").get(p) {
190        return f(r);
191      }
192    }
193    let mut s = self.state.write().expect("lock is poisoned");
194    match s.entry(p.to_path_buf()) {
195      HashMapEntry::Vacant(e) => {
196        let p = e.key();
197        let r = fs::read_to_string(p);
198        let r = e.insert(r);
199        f(r)
200      }
201      HashMapEntry::Occupied(e) => f(e.get()),
202    }
203  }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, thiserror::Error)]
207enum TryResolveUrlError {
208  #[error("resource not found")]
209  NotFound,
210  #[error("unexpected URL scheme {0:?}")]
211  Scheme(String),
212  #[error("unexpected io error")]
213  Io(#[source] WeakError),
214}
215
216struct ConfigResolver {
217  resolved: Vec<ResolvedConfig>,
218  fs: FsCache,
219}
220
221impl ConfigResolver {
222  pub(crate) fn new() -> Self {
223    Self {
224      resolved: Vec::new(),
225      fs: FsCache::new(),
226    }
227  }
228
229  pub(crate) fn add_root(&mut self, root: &ConfigRoot, working_dir: &Path) {
230    let working_dir_url = Url::from_directory_path(working_dir).expect("invalid working dir");
231    let resolved = match root {
232      ConfigRoot::Data { data, format } => Some(self.add_data(data, format)),
233      ConfigRoot::Search { patterns, format } => self.add_search(patterns, format, working_dir.to_path_buf()),
234      ConfigRoot::Url { url, format } => Some(self.add_url(&working_dir_url, url, format)),
235    };
236    let resolved: &ResolvedConfig = match resolved {
237      None => return,
238      Some(resolved) => resolved,
239    };
240    let mut stack = resolved.extends(&working_dir_url);
241    while let Some((url, format)) = stack.pop() {
242      let dependency = self
243        .try_resolve_url(&url, format)
244        .expect("failed to resolve base config");
245      stack.extend_from_slice(&dependency.extends(&working_dir_url));
246      self.resolved.push(dependency);
247    }
248  }
249
250  fn add_data(
251    &mut self,
252    data: &ConfigValue<String, LoadConfigSource>,
253    format: &ConfigValue<ConfigFormat, LoadConfigSource>,
254  ) -> &ResolvedConfig {
255    let resolved_source: ConfigSource = match data.meta {
256      LoadConfigSource::Args => ConfigSource::Cli,
257      LoadConfigSource::Env => ConfigSource::Env,
258      LoadConfigSource::Default(_) => ConfigSource::Default,
259    };
260    let resolved = ResolvedConfig::parse(&data.value, format.value, true, resolved_source).expect("invalid config");
261    self.resolved.push(resolved);
262    self.resolved.last().expect("`self.resolved` is non-empty")
263  }
264
265  fn add_search(
266    &mut self,
267    patterns: &ConfigValue<Vec<String>, LoadConfigSource>,
268    format: &ConfigValue<ConfigFormat, LoadConfigSource>,
269    start_dir: PathBuf,
270  ) -> Option<&ResolvedConfig> {
271    let mut visited: HashSet<PathBuf> = HashSet::new();
272    let mut cur: PathBuf = start_dir;
273    loop {
274      let cur_url = Url::from_directory_path(&cur).expect("invalid cur search dir");
275      for pat in &patterns.value {
276        let is_relative_path = pat.starts_with("./") || pat.starts_with("../");
277        if !is_relative_path {
278          panic!(r#"search pattern must start with "./" or "../", received {pat}"#);
279        }
280        let target = Url::options().base_url(Some(&cur_url)).parse(pat);
281        let target = match target {
282          Ok(t) => t,
283          Err(e) => panic!("failed to build file url from pattern {pat:?} and base {cur_url}: {e:?}"),
284        };
285        match self.try_resolve_url(&target, format.value) {
286          Ok(r) => {
287            self.resolved.push(r);
288            return Some(self.resolved.last().expect("`self.resolved` is non-empty"));
289          }
290          Err(TryResolveUrlError::NotFound) => continue,
291          Err(e) => panic!("read error for {target}: {}", DisplayErrorChain(&e)),
292        };
293      }
294      let parent: Option<PathBuf> = cur.parent().map(|p| p.to_path_buf());
295      if let Some(parent) = parent {
296        visited.insert(cur);
297        if visited.contains(parent.as_path()) {
298          // Found cycle when iterating through parents; we assume that we
299          // reached the root on an OS where Rust has trouble noticing it.
300          return None;
301        } else {
302          cur = parent;
303        }
304      } else {
305        // Reached root without finding any match
306        return None;
307      }
308    }
309  }
310
311  fn add_url(
312    &mut self,
313    working_dir: &Url,
314    url: &ConfigValue<String, LoadConfigSource>,
315    format: &ConfigValue<ConfigFormat, LoadConfigSource>,
316  ) -> &ResolvedConfig {
317    let url = resolve_config_ref(working_dir, &url.value);
318    match self.try_resolve_url(&url, format.value) {
319      Ok(r) => {
320        self.resolved.push(r);
321        self.resolved.last().expect("`self.resolved` is non-empty")
322      }
323      Err(e) => panic!("read error for {url}: {}", DisplayErrorChain(&e)),
324    }
325  }
326
327  fn try_resolve_url(&self, url: &Url, format: ConfigFormat) -> Result<ResolvedConfig, TryResolveUrlError> {
328    match url.scheme() {
329      "file" => {
330        let path = url.to_file_path().expect("must be a file url");
331        self.fs.read_to_string(&path, |r| match r.as_deref() {
332          Ok(s) => {
333            let resolved_source = ConfigSource::File(Some(path.clone()));
334            let resolved = ResolvedConfig::parse(s, format, false, resolved_source).expect("config format error");
335            Ok(resolved)
336          }
337          Err(e) => Err(match e.kind() {
338            io::ErrorKind::NotFound => TryResolveUrlError::NotFound,
339            _ => TryResolveUrlError::Io(WeakError::wrap(e)),
340          }),
341        })
342      }
343      s => Err(TryResolveUrlError::Scheme(s.to_string())),
344    }
345  }
346}
347
348fn resolve_config_ref(base: &Url, url: &str) -> Url {
349  let parsed = if url.starts_with("./") || url.starts_with("../") {
350    Url::options().base_url(Some(base)).parse(url)
351  } else {
352    Url::parse(url)
353  };
354  match parsed {
355    Ok(u) => u,
356    Err(e) => panic!("invalid url {url}: {}", DisplayErrorChain(&e)),
357  }
358}
359
360/// Trait representing a config field.
361///
362/// A config field is path to a value. This is the main way to extract values
363/// from a config object.
364pub trait ConfigField<TyContainer, TyValue> {
365  /// Path, for debug purposes
366  fn path(&self) -> &[&str];
367
368  fn get(&self, container: TyContainer) -> TyValue;
369}
370
371/// Value extracted from config.
372///
373/// The value is always tagged with metadata.
374#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
375pub struct ConfigValue<T, M = ()> {
376  pub value: T,
377  pub meta: M,
378}
379
380impl<T, M> ConfigValue<T, M>
381where
382  M: Default,
383{
384  pub fn new(value: T) -> Self {
385    Self {
386      value,
387      meta: M::default(),
388    }
389  }
390}
391
392impl<T, M> ConfigValue<T, M> {
393  pub fn new_meta(value: T, meta: M) -> Self {
394    Self { value, meta }
395  }
396
397  pub fn clone_without_meta(&self) -> ConfigValue<T, ()>
398  where
399    T: Clone,
400  {
401    ConfigValue {
402      value: self.value.clone(),
403      meta: (),
404    }
405  }
406
407  /// Create a new `ConfigValue`, by mapping the value.
408  ///
409  /// The metadata is kept unchanged.
410  pub fn map<U, F>(self, f: F) -> ConfigValue<U, M>
411  where
412    F: FnOnce(T) -> U,
413  {
414    ConfigValue {
415      value: f(self.value),
416      meta: self.meta,
417    }
418  }
419
420  /// Create a new `ConfigValue`, by mapping the metadata.
421  ///
422  /// The value is kept unchanged.
423  pub fn map_meta<N, F>(self, f: F) -> ConfigValue<T, N>
424  where
425    F: FnOnce(M) -> N,
426  {
427    ConfigValue {
428      value: self.value,
429      meta: f(self.meta),
430    }
431  }
432}
433
434/// Entry from config: field and corresponding value.
435#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
436pub struct ConfigEntry<F, V> {
437  pub field: F,
438  pub value: V,
439}
440
441pub type DebugConfigValue<T> = ConfigValue<T, ConfigMeta<T, LoadConfigSource>>;
442
443pub type DebugConfigEntry<'a> = ConfigEntry<&'a [&'a str], DebugConfigValue<&'a dyn Debug>>;
444
445/// Source for the bootstrap config of the loader
446#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
447pub enum LoadConfigSource {
448  Args,
449  Env,
450  Default(BuiltinEternaltwinConfigProfile),
451}
452
453/// Metadata for a config value
454#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
455pub struct ConfigMeta<V, S> {
456  pub default: V,
457  pub source: S,
458}
459
460#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
461pub enum BuiltinEternaltwinConfigProfile {
462  Production,
463  Dev,
464  Test,
465  Sdk,
466}
467
468impl BuiltinEternaltwinConfigProfile {
469  pub fn as_str(&self) -> &str {
470    match &self {
471      Self::Production => "production",
472      Self::Dev => "dev",
473      Self::Test => "test",
474      Self::Sdk => "sdk",
475    }
476  }
477}
478
479#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
480pub enum EternaltwinConfigProfile {
481  Production,
482  Dev,
483  Test,
484  Sdk,
485  Custom(String),
486}
487
488impl EternaltwinConfigProfile {
489  pub fn as_str(&self) -> &str {
490    match &self {
491      Self::Production => "production",
492      Self::Dev => "dev",
493      Self::Test => "test",
494      Self::Sdk => "sdk",
495      Self::Custom(ref profile) => profile.as_str(),
496    }
497  }
498
499  pub fn parse(s: &str) -> Self {
500    match s {
501      "production" => Self::Production,
502      "dev" => Self::Dev,
503      "test" => Self::Test,
504      "sdk" => Self::Sdk,
505      s => Self::Custom(s.to_string()),
506    }
507  }
508
509  pub fn as_builtin_or_dev(&self) -> BuiltinEternaltwinConfigProfile {
510    match &self {
511      Self::Production => BuiltinEternaltwinConfigProfile::Production,
512      Self::Dev => BuiltinEternaltwinConfigProfile::Dev,
513      Self::Test => BuiltinEternaltwinConfigProfile::Test,
514      Self::Sdk => BuiltinEternaltwinConfigProfile::Sdk,
515      Self::Custom(_) => BuiltinEternaltwinConfigProfile::Dev,
516    }
517  }
518}
519
520impl core::str::FromStr for EternaltwinConfigProfile {
521  type Err = Infallible;
522
523  fn from_str(s: &str) -> Result<Self, Self::Err> {
524    Ok(Self::parse(s))
525  }
526}
527
528#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
529pub enum ConfigFormat {
530  Auto,
531  Json,
532  Toml,
533}
534
535#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, thiserror::Error)]
536#[error("invalid config format: possible values are `auto`, `json`, `toml`")]
537pub struct InvalidConfigFormat;
538
539impl ConfigFormat {
540  pub fn as_str(&self) -> &str {
541    match &self {
542      Self::Auto => "auto",
543      Self::Json => "json",
544      Self::Toml => "toml",
545    }
546  }
547
548  pub fn parse(s: &str) -> Result<Self, InvalidConfigFormat> {
549    match s {
550      "auto" => Ok(Self::Auto),
551      "json" => Ok(Self::Json),
552      "toml" => Ok(Self::Toml),
553      _ => Err(InvalidConfigFormat),
554    }
555  }
556}
557
558impl core::str::FromStr for ConfigFormat {
559  type Err = InvalidConfigFormat;
560
561  fn from_str(s: &str) -> Result<Self, Self::Err> {
562    Self::parse(s)
563  }
564}
565
566#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
567pub enum ConfigSource {
568  Default,
569  File(Option<PathBuf>),
570  Cli,
571  Env,
572}
573
574pub enum InputConfigSource {
575  Default,
576  Cwd(PathBuf),
577  Cli,
578  Env,
579}
580
581#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
582pub enum ClockConfig {
583  System,
584  Virtual,
585}
586
587#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
588pub enum StoreConfig {
589  Memory,
590  Postgres,
591}
592
593#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
594pub enum SqliteStoreConfig {
595  Memory,
596  Postgres,
597  Sqlite,
598}
599
600impl From<StoreConfig> for SqliteStoreConfig {
601  fn from(value: StoreConfig) -> Self {
602    match value {
603      StoreConfig::Memory => Self::Memory,
604      StoreConfig::Postgres => Self::Postgres,
605    }
606  }
607}
608
609#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
610pub enum ClientConfig {
611  Mock,
612  Network,
613}
614
615#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
616pub enum MailerType {
617  Mock,
618  Network,
619}
620
621#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
622pub struct Config<TyMeta = ()> {
623  pub backend: BackendConfig<TyMeta>,
624  pub frontend: FrontendConfig<TyMeta>,
625  pub postgres: PostgresConfig<TyMeta>,
626  pub mailer: MailerConfig<TyMeta>,
627  pub scrypt: ScryptConfig<TyMeta>,
628  pub opentelemetry: OpentelemetryConfig<TyMeta>,
629  pub seed: SeedConfig<TyMeta>,
630  pub sqlite: SqliteConfig<TyMeta>,
631}
632
633impl Config<ConfigSource> {
634  pub fn for_test() -> Self {
635    let cwd = std::env::current_dir().expect("failed to resolve cwd");
636    let profile = EternaltwinConfigProfile::Test;
637    let roots = ConfigRoots::default_value(&profile);
638    let chain = ConfigChain::resolve_from_roots(&roots, cwd.as_path(), &profile);
639    chain.merge()
640  }
641
642  fn patch(mut self, config: ConfigPatch, meta: ConfigSource) -> Self {
643    if let SimplePatch::Set(backend) = config.backend {
644      self.backend = self.backend.patch(backend, meta.clone());
645    }
646    if let SimplePatch::Set(frontend) = config.frontend {
647      self.frontend = self.frontend.patch(frontend, meta.clone());
648    }
649    if let SimplePatch::Set(postgres) = config.postgres {
650      self.postgres = self.postgres.patch(postgres, meta.clone());
651    }
652    if let SimplePatch::Set(mailer) = config.mailer {
653      self.mailer = self.mailer.patch(mailer, meta.clone());
654    }
655    if let SimplePatch::Set(scrypt) = config.scrypt {
656      self.scrypt = self.scrypt.patch(scrypt, meta.clone());
657    }
658    if let SimplePatch::Set(opentelemetry) = config.opentelemetry {
659      self.opentelemetry = self.opentelemetry.patch(opentelemetry, meta.clone());
660    }
661    if let SimplePatch::Set(seed) = config.seed {
662      let patch = match seed {
663        // An explicit `seed = null` is propagated to clear all the seed maps
664        None => SeedConfigPatch {
665          user: SimplePatch::Set(None),
666          app: SimplePatch::Set(None),
667          forum_section: SimplePatch::Set(None),
668        },
669        Some(patch) => patch,
670      };
671      self.seed = self.seed.patch(patch, meta.clone());
672    }
673    if let SimplePatch::Set(sqlite) = config.sqlite {
674      self.sqlite = self.sqlite.patch(sqlite, meta.clone());
675    }
676    self
677  }
678
679  fn default_for_profile(profile: BuiltinEternaltwinConfigProfile) -> Self {
680    Self {
681      backend: BackendConfig::default_for_profile(profile),
682      frontend: FrontendConfig::default_for_profile(profile),
683      postgres: PostgresConfig::default_for_profile(profile),
684      mailer: MailerConfig::default_for_profile(profile),
685      scrypt: ScryptConfig::default_for_profile(profile),
686      opentelemetry: OpentelemetryConfig::default_for_profile(profile),
687      seed: SeedConfig::default_for_profile(profile),
688      sqlite: SqliteConfig::default_for_profile(profile),
689    }
690  }
691}
692
693#[cfg(test)]
694mod test {
695  use super::*;
696
697  #[test]
698  fn test_default_dev_config() {
699    // override prod config with the one below, so we rebuild the dev config
700    // language=toml
701    const INPUT: &str = r#"
702[backend]
703listen = "[::]:50320"
704secret = "dev"
705clock = "System"
706mailer = "Mock"
707store = "Memory"
708oauth_client = "Network"
709
710[frontend]
711port = 50321
712uri = "http://localhost:50321/"
713forum_posts_per_page = 10
714forum_threads_per_page = 20
715
716[postgres]
717host = "localhost"
718port = 5432
719name = "eternaltwin.dev"
720user = "eternaltwin.dev.main"
721password = "dev"
722max_connections = 50
723
724[sqlite]
725file = "./eternaltwin.dev.sqlite"
726max_connections = 50
727
728[mailer]
729host = "localhost"
730username = "eternaltwin_mailer"
731password = "dev"
732sender = "support@eternaltwin.localhost"
733headers = [
734]
735
736[scrypt]
737max_time = "100ms"
738max_mem_frac = 0.05
739
740[opentelemetry]
741enabled = true
742grpc_proxy_port = 4317
743
744[opentelemetry.attributes]
745[opentelemetry.exporter]
746[opentelemetry.exporter.stdout]
747type = "Human"
748color = "Auto"
749target = "eternaltwin://stdout"
750
751[seed]
752[seed.user]
753[seed.user.alice]
754display_name = "Alice"
755username = "alice"
756password = "aaaaaaaaaa"
757is_administrator = true
758[seed.user.bob]
759display_name = "Bob"
760username = "bob"
761password = "bbbbbbbbbb"
762[seed.user.charlie]
763display_name = "Charlie"
764username = "charlie"
765password = "cccccccccc"
766[seed.user.dan]
767display_name = "Dan"
768username = "dan"
769password = "dddddddddd"
770[seed.user.eve]
771display_name = "Eve"
772username = "eve"
773password = "eeeeeeeeee"
774[seed.user.frank]
775display_name = "Frank"
776username = "frank"
777password = "ffffffffff"
778
779[seed.app]
780[seed.app.brute_dev]
781display_name = "LaBrute"
782uri = "http://localhost:3000/"
783oauth_callback = "http://localhost:3000/oauth/callback"
784secret = "dev"
785
786[seed.app.emush_dev]
787display_name = "eMush"
788uri = "http://emush.localhost/"
789oauth_callback = "http://emush.localhost/oauth/callback"
790secret = "dev"
791
792[seed.app.eternalfest_dev]
793display_name = "Eternalfest"
794uri = "http://localhost:50313/"
795oauth_callback = "http://localhost:50313/oauth/callback"
796secret = "dev"
797
798[seed.app.kadokadeo_dev]
799display_name = "Kadokadeo"
800uri = "http://kadokadeo.localhost/"
801oauth_callback = "http://kadokadeo.localhost/oauth/callback"
802secret = "dev"
803
804[seed.app.kingdom_dev]
805display_name = "Kingdom"
806uri = "http://localhost:8000/"
807oauth_callback = "http://localhost:8000/oauth/callback"
808secret = "dev"
809
810[seed.app.myhordes_dev]
811display_name = "MyHordes"
812uri = "http://myhordes.localhost/"
813oauth_callback = "http://myhordes.localhost/twinoid"
814secret = "dev"
815
816[seed.app.neoparc_dev]
817display_name = "NeoParc"
818uri = "http://localhost:8880/"
819oauth_callback = "http://localhost:8880/api/account/callback"
820secret = "dev"
821
822[seed.forum_section.main_en]
823display_name = "Main Forum (en-US)"
824locale = "en-US"
825
826[seed.forum_section.main_fr]
827display_name = "Forum Général (fr-FR)"
828locale = "fr-FR"
829
830[seed.forum_section.main_es]
831display_name = "Foro principal (es-SP)"
832locale = "es-SP"
833
834[seed.forum_section.main_de]
835display_name = "Hauptforum (de-DE)"
836locale = "de-DE"
837
838[seed.forum_section.main_eo]
839display_name = "Ĉefa forumo (eo)"
840locale = "eo"
841
842[seed.forum_section.eternalfest_main]
843display_name = "[Eternalfest] Le Panthéon"
844locale = "fr-FR"
845
846[seed.forum_section.emush_main]
847display_name = "[eMush] Neron is watching you"
848locale = "fr-FR"
849
850[seed.forum_section.drpg_main]
851display_name = "[DinoRPG] Jurassic Park"
852locale = "fr-FR"
853
854[seed.forum_section.myhordes_main]
855display_name = "[Myhordes] Le Saloon"
856locale = "fr-FR"
857
858[seed.forum_section.kadokadeo_main]
859display_name = "[Kadokadeo] Café des palabres"
860locale = "fr-FR"
861
862[seed.forum_section.kingdom_main]
863display_name = "[Kingdom] La foire du trône"
864locale = "fr-FR"
865
866[seed.forum_section.na_main]
867display_name = "[Naturalchimie] Le laboratoire"
868locale = "fr-FR"
869
870[seed.forum_section.sq_main]
871display_name = "[Studioquiz] Le bar à questions"
872locale = "fr-FR"
873
874[seed.forum_section.ts_main]
875display_name = "[Teacher Story] La salle des profs"
876locale = "fr-FR"
877
878[seed.forum_section.popotamo_main]
879display_name = "[Popotamo] Le mot le plus long"
880locale = "fr-FR"
881"#;
882    let actual = ResolvedConfig::parse(INPUT, ConfigFormat::Toml, false, ConfigSource::Default).unwrap();
883    let actual = ConfigChain::one(actual, BuiltinEternaltwinConfigProfile::Production).merge();
884    let expected = Config::default_for_profile(BuiltinEternaltwinConfigProfile::Dev);
885    assert_eq!(actual, expected);
886  }
887}