Skip to main content

rust_template_foundation/
config.rs

1//! Configuration file discovery and loading.
2//!
3//! Provides the three-stage config search (explicit path → `./config.toml` →
4//! `$XDG_CONFIG_HOME/<app>/config.toml`) and a generic TOML loader that
5//! produces semantic errors.
6
7use serde::de::DeserializeOwned;
8use std::path::{Path, PathBuf};
9use thiserror::Error;
10
11// ── errors ──────────────────────────────────────────────────────────────────
12
13#[derive(Debug, Error)]
14pub enum ConfigFileError {
15  #[error(
16    "Failed to read configuration file at {path:?} during startup: {source}"
17  )]
18  FileRead {
19    path: PathBuf,
20    #[source]
21    source: std::io::Error,
22  },
23
24  #[error("Failed to parse configuration file at {path:?}: {source}")]
25  Parse {
26    path: PathBuf,
27    #[source]
28    source: toml::de::Error,
29  },
30}
31
32// ── discovery ───────────────────────────────────────────────────────────────
33
34/// Resolve `$XDG_CONFIG_HOME/<app_name>`, falling back to
35/// `$HOME/.config/<app_name>` when the variable is unset.
36pub fn xdg_config_dir(app_name: &str) -> Option<PathBuf> {
37  std::env::var_os("XDG_CONFIG_HOME")
38    .map(PathBuf::from)
39    .or_else(|| home::home_dir().map(|h| h.join(".config")))
40    .map(|d| d.join(app_name))
41}
42
43/// Locate a configuration file using a two-stage search:
44///
45/// 1. If `explicit_path` is `Some`, return it unconditionally.
46/// 2. Fall back to `$XDG_CONFIG_HOME/<app_name>/config.toml`.
47///
48/// Returns `None` when no candidate exists on disk.
49///
50/// A working-directory lookup is deliberately omitted.  `config.toml` is a
51/// common enough filename that silently picking one up from whatever
52/// directory the user happens to be in is a footgun — different config
53/// would load depending on cwd, with no warning.  Callers who want a
54/// local config can pass its path via `explicit_path` (the `--config`
55/// flag, or the `<app>_config` environment variable, on the
56/// macro-generated `CliRaw`).
57pub fn find_config_file(
58  app_name: &str,
59  explicit_path: Option<&Path>,
60) -> Option<PathBuf> {
61  if let Some(p) = explicit_path {
62    return Some(p.to_path_buf());
63  }
64
65  xdg_config_dir(app_name)
66    .map(|d| d.join("config.toml"))
67    .filter(|p| p.exists())
68}
69
70/// Deserialise a TOML file into `T`, wrapping I/O and parse failures in
71/// [`ConfigFileError`].
72pub fn load_toml<T: DeserializeOwned>(
73  path: &Path,
74) -> Result<T, ConfigFileError> {
75  let contents = std::fs::read_to_string(path).map_err(|source| {
76    ConfigFileError::FileRead {
77      path: path.to_path_buf(),
78      source,
79    }
80  })?;
81
82  toml::from_str(&contents).map_err(|source| ConfigFileError::Parse {
83    path: path.to_path_buf(),
84    source,
85  })
86}
87
88// ── config-file fragment ────────────────────────────────────────────────────
89
90/// Common config-file fields shared by every project crate.  Flatten into
91/// your `ConfigFileRaw` with `#[serde(flatten)]`.
92///
93/// The CLI counterpart is generated inline by the `MergeConfig` derive
94/// macro — each app gets per-app-prefixed env vars
95/// (`<app>_log_level`, `<app>_log_format`, `<app>_config`), which a
96/// shared struct cannot deliver since clap bakes env names into the
97/// struct's own attributes at the struct's compile site.
98#[derive(Debug, serde::Deserialize, Default)]
99pub struct CommonConfigFile {
100  pub log_level: Option<String>,
101  pub log_format: Option<String>,
102}
103
104/// Returns the path to the `oidc-client-secret` credential file inside
105/// systemd's `CREDENTIALS_DIRECTORY`, if the directory is set and the
106/// file exists.
107#[cfg(feature = "server")]
108pub fn credential_secret_path() -> Option<PathBuf> {
109  let dir = std::env::var("CREDENTIALS_DIRECTORY").ok()?;
110  let path = PathBuf::from(dir).join("oidc-client-secret");
111  path.exists().then_some(path)
112}
113
114/// Resolve `log_level` and `log_format` from CLI → config-file → defaults.
115///
116/// Returns `(LogLevel, LogFormat)` or an error message suitable for user
117/// display.
118pub fn resolve_log_settings(
119  cli_level: Option<String>,
120  cli_format: Option<String>,
121  file: &CommonConfigFile,
122) -> Result<(crate::logging::LogLevel, crate::logging::LogFormat), String> {
123  let level_str = cli_level
124    .or_else(|| file.log_level.clone())
125    .unwrap_or_else(|| "info".to_string());
126
127  let level = level_str
128    .parse::<crate::logging::LogLevel>()
129    .map_err(|e| e.to_string())?;
130
131  let format_str = cli_format
132    .or_else(|| file.log_format.clone())
133    .unwrap_or_else(|| "text".to_string());
134
135  let format = format_str
136    .parse::<crate::logging::LogFormat>()
137    .map_err(|e| e.to_string())?;
138
139  Ok((level, format))
140}