evobench_tools/
config_file.rs

1//! Generic config file loader that supports reloading
2
3// TODO: integrate `serde_path_to_error` crate. (Still?)
4
5use std::{
6    borrow::{Borrow, Cow},
7    cell::Cell,
8    fmt::Display,
9    io::stderr,
10    ops::Deref,
11    path::{Path, PathBuf},
12    sync::Arc,
13    time::SystemTime,
14};
15
16use anyhow::{Result, anyhow, bail};
17use chj_unix_util::daemon::warrants_restart::WarrantsRestart;
18use cj_path_util::{path_util::AppendToPath, unix::polyfill::add_extension};
19use serde::{Serialize, de::DeserializeOwned};
20
21use crate::{
22    ctx, debug,
23    io_utils::tempfile_utils::TempfileOptions,
24    serde_types::proper_filename::ProperFilename,
25    serde_util::json5_from_str::{Json5FromStrError, json5_from_str},
26    utillib::{
27        arc::CloneArc,
28        home::{HomeError, home_dir},
29        slice_or_box::SliceOrBox,
30    },
31};
32
33pub fn ron_to_string_pretty<V: serde::Serialize>(value: &V) -> Result<String, ron::Error> {
34    ron::Options::default()
35        .to_string_pretty(value, ron::ser::PrettyConfig::default().struct_names(true))
36}
37
38/// Writes via tmpfile-and-rename; retains permissions
39pub fn ron_to_file_pretty<V: serde::Serialize>(
40    value: &V,
41    path: impl AsRef<Path>,
42    migrate_access: bool,
43    _initial_mode: Option<u32>,
44) -> Result<()> {
45    let s = ron_to_string_pretty(value)?;
46    let path = path.as_ref();
47    let tmpfile = TempfileOptions {
48        target_path: path.into(),
49        retain_tempfile: false,
50        migrate_access,
51    }
52    .tempfile()?;
53    let temp_path = &tmpfile.temp_path;
54    // XX make use of initial_mode
55    std::fs::write(temp_path, s).map_err(ctx!("writing file to {temp_path:?}"))?;
56    tmpfile.finish()?;
57    Ok(())
58}
59
60pub fn parse_ron<V: DeserializeOwned>(s: &str) -> Result<V> {
61    ron::from_str(s)
62        .map_err(|error| {
63            let ron::error::SpannedError {
64                code,
65                position: ron::error::Position { line, col },
66            } = error;
67            anyhow!("{code} at line:column {line}:{col}")
68        })
69        .map_err(ctx!("decoding RON"))
70}
71
72pub fn load_ron_file<V: DeserializeOwned>(path: impl AsRef<Path>) -> Result<V> {
73    let path = path.as_ref();
74    let s = std::fs::read_to_string(path).map_err(ctx!("loading file from {path:?}"))?;
75    parse_ron(&s).map_err(ctx!("reading file from {path:?}"))
76}
77
78#[derive(Debug, Clone, Copy)]
79pub enum ConfigBackend {
80    Ron,
81    Json5,
82    Yaml,
83    Hcl,
84}
85
86impl Display for ConfigBackend {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.write_str(self.format_name())
89    }
90}
91
92impl ConfigBackend {
93    pub fn format_name(self) -> &'static str {
94        match self {
95            ConfigBackend::Ron => "RON",
96            ConfigBackend::Json5 => "JSON5",
97            ConfigBackend::Yaml => "YAML",
98            ConfigBackend::Hcl => "HCL",
99        }
100    }
101
102    pub fn load_config_file<T: DeserializeOwned>(self, path: &Path) -> Result<T> {
103        let s = std::fs::read_to_string(path).map_err(ctx!("loading config from file {path:?}"))?;
104        match self {
105            ConfigBackend::Ron => parse_ron(&s).map_err(ctx!("reading config from file {path:?}")),
106            ConfigBackend::Json5 => {
107                // https://crates.io/crates/json5
108                // https://crates.io/crates/serde_json5 <-- currently used, fork of json5
109                // https://crates.io/crates/json5_nodes
110                if false {
111                    // Sadly this doesn't actually track paths,
112                    // probably because the constructor already does
113                    // the deserialisation.
114                    let mut d = serde_json5::Deserializer::from_str(&s)
115                        .map_err(Json5FromStrError)
116                        .map_err(ctx!(
117                            "decoding JSON5 from config file {path:?} -- step 1, too early?"
118                        ))?;
119                    // Also even loses location info this way.
120                    serde_path_to_error::deserialize(&mut d)
121                        .map_err(ctx!("decoding JSON5 from config file {path:?}"))
122                } else {
123                    json5_from_str(&s).map_err(ctx!("decoding JSON5 from config file {path:?}"))
124                }
125            }
126            ConfigBackend::Yaml => {
127                serde_yml::from_str(&s).map_err(ctx!("decoding YAML from config file {path:?}"))
128            }
129            ConfigBackend::Hcl => {
130                hcl::from_str(&s).map_err(ctx!("decoding HCL from config file {path:?}"))
131            }
132        }
133    }
134
135    pub fn save_config_file<T: Serialize>(self, path: &Path, value: &T) -> Result<()> {
136        let s = match self {
137            ConfigBackend::Ron => ron_to_string_pretty(value)?,
138            ConfigBackend::Json5 => {
139                serde_json::to_string_pretty(value).map_err(ctx!("encoding config as JSON5"))?
140            }
141            ConfigBackend::Yaml => {
142                serde_yml::to_string(value).map_err(ctx!("encoding config as YAML"))?
143            }
144            ConfigBackend::Hcl => hcl::to_string(value).map_err(ctx!("encoding config as HCL"))?,
145        };
146        std::fs::write(path, s).map_err(ctx!("writing config file to {path:?}"))
147    }
148}
149
150pub const FILE_EXTENSIONS: &[(&str, ConfigBackend)] = &[
151    ("ron", ConfigBackend::Ron),
152    ("json5", ConfigBackend::Json5),
153    ("json", ConfigBackend::Json5),
154    ("yml", ConfigBackend::Yaml),
155    ("yaml", ConfigBackend::Yaml),
156    ("hcl", ConfigBackend::Hcl),
157];
158
159pub fn supported_formats() -> impl Iterator<Item = String> {
160    FILE_EXTENSIONS
161        .iter()
162        .map(|(ext, backend)| format!(".{ext} ({backend})"))
163}
164
165pub fn backend_from_path(path: &Path) -> Result<ConfigBackend> {
166    if let Some(ext) = path.extension() {
167        if let Some(ext) = ext.to_str() {
168            if let Some((_, backend)) = FILE_EXTENSIONS.iter().find(|(e, _b)| *e == ext) {
169                Ok(*backend)
170            } else {
171                bail!("given file path does have an unknown extension {ext:?}: {path:?}")
172            }
173        } else {
174            bail!("given file path does have an extension that is not unicode: {path:?}")
175        }
176    } else {
177        bail!(
178            "given file path does not have an extension \
179             for determining the file format: {path:?}"
180        )
181    }
182}
183
184pub fn save_config_file<T: Serialize>(path: &Path, value: &T) -> Result<()> {
185    let backend = backend_from_path(path)?;
186    backend.save_config_file(path, value)
187}
188
189pub enum ConfigDir {
190    Etc,
191    Home,
192    Path(Cow<'static, Path>),
193}
194
195impl ConfigDir {
196    pub fn to_path(&self) -> Result<Cow<'_, Path>, &'static HomeError> {
197        match self {
198            ConfigDir::Etc => Ok(AsRef::<Path>::as_ref("/etc").into()),
199            ConfigDir::Home => home_dir().map(Into::into),
200            ConfigDir::Path(cow) => Ok(Cow::Borrowed(cow.borrow())),
201        }
202    }
203
204    pub fn append_file_name(
205        &self,
206        file_name: &ProperFilename,
207    ) -> Result<PathBuf, &'static HomeError> {
208        match self {
209            ConfigDir::Etc => Ok(self.to_path()?.append(file_name.as_ref())),
210            ConfigDir::Home => {
211                let dotted_file_name = format!(".{}", file_name.as_str());
212                Ok(self.to_path()?.append(dotted_file_name))
213            }
214            ConfigDir::Path(cow) => Ok(cow.as_ref().append(file_name.as_ref())),
215        }
216    }
217}
218
219pub trait DefaultConfigPath: DeserializeOwned {
220    /// `ConfigFile::load_config` tries this file name, together with
221    /// a list of file name extensions, appended to the paths from
222    /// `default_config_dirs()`, and one path is then expected to
223    /// exist in a dir, if none the next is tried, or its `or_else`
224    /// fallback is called. In the case of `ConfigDir::Home`, a dot is
225    /// prepended to the file name.
226    fn default_config_file_name_without_suffix() -> Result<Option<ProperFilename>>;
227
228    fn default_config_dirs() -> SliceOrBox<'static, ConfigDir> {
229        const V: &[ConfigDir] = &[ConfigDir::Home, ConfigDir::Etc];
230        V.into()
231    }
232}
233
234struct PathAndTrack {
235    path: Arc<Path>,
236    // A Cell since it is being mutated, including via
237    // `WarrantsRestart::warrants_restart`, thus not having &mut
238    mtime: Cell<SystemTime>,
239}
240
241/// Wrapper around a configuration type T that remembers where it was
242/// loaded from and the modification time of the file, and can reload
243/// a config if it changed.
244pub struct ConfigFile<T> {
245    config: T,
246    /// None if `config` was not loaded from a file but provided by
247    /// the `or_else` callback of `load_config`
248    path_and_track: Option<PathAndTrack>,
249}
250
251impl<T> Deref for ConfigFile<T> {
252    type Target = T;
253
254    fn deref(&self) -> &Self::Target {
255        &self.config
256    }
257}
258
259impl<T: DeserializeOwned + DefaultConfigPath> ConfigFile<T> {
260    /// Returns None if `config` was not loaded from a file but
261    /// provided by the `or_else` callback of `load_config`
262    pub fn path(&self) -> Option<&Arc<Path>> {
263        self.path_and_track.as_ref().map(|pt| &pt.path)
264    }
265
266    /// Check if the file that the config was loaded from has changed,
267    /// if so, attempt to load it and if successful returns the new
268    /// instance. Currently only checks the file that it was loaded
269    /// from for changes; if this config was a default from `or_else`,
270    /// no check is done at all. Only parse errors and the file
271    /// vanishing between checking its metadata and reading it are
272    /// reported, not stat errors. Only returns a new Self once:
273    /// updates the internal mtime and if the file doesn't change
274    /// again, no new parsing is done.
275    pub fn perhaps_reload_config(&self) -> Result<Option<Self>> {
276        if let Some(PathAndTrack { path, mtime }) = self.path_and_track.as_ref() {
277            match std::fs::metadata(path) {
278                Ok(s) => match s.modified() {
279                    Ok(m) => {
280                        if m == mtime.get() {
281                            Ok(None)
282                        } else {
283                            mtime.set(m);
284                            let val = Self::load_config(Some(path.clone_arc()), |_| {
285                                bail!("config {path:?} missing")
286                            })?;
287                            Ok(Some(val))
288                        }
289                    }
290                    Err(_) => Ok(None),
291                },
292                Err(_) => Ok(None),
293            }
294        } else {
295            Ok(None)
296        }
297    }
298
299    /// If `path` is given, the file must exist or an error is
300    /// returned. Otherwise, a default location is checked
301    /// (`default_config_path_without_suffix`) and if a file with one
302    /// of the fitting file name extensions exists, it is loaded,
303    /// otherwise `or_else` is called with a message mentioning what
304    /// was tried; it can issue an error or generate a default config
305    /// value.
306    pub fn load_config(
307        path: Option<Arc<Path>>,
308        or_else: impl FnOnce(&str) -> Result<T>,
309    ) -> Result<Self> {
310        let load_config = |path: Arc<Path>, backend: ConfigBackend| {
311            let config = backend.load_config_file(&path)?;
312            let mtime = Cell::new(std::fs::metadata(&path)?.modified()?);
313            Ok(Self {
314                config,
315                path_and_track: Some(PathAndTrack { path, mtime }),
316            })
317        };
318
319        if let Some(path) = path {
320            let backend = backend_from_path(&path)?;
321            load_config(path, backend)
322        } else {
323            if let Some(file_name) = T::default_config_file_name_without_suffix()? {
324                let mut default_paths_tried = Vec::new();
325                for config_dir in T::default_config_dirs().iter() {
326                    let path = config_dir.append_file_name(&file_name)?;
327                    let path_and_backends: Vec<(Arc<Path>, &ConfigBackend)> = FILE_EXTENSIONS
328                        .iter()
329                        .map(|(extension, backend)| {
330                            let path = add_extension(&path, extension)
331                                .ok_or_else(|| anyhow!("path is missing a file name: {path:?}"))?;
332                            if path.exists() {
333                                Ok(Some((path.into(), backend)))
334                            } else {
335                                default_paths_tried.push(path);
336                                Ok(None)
337                            }
338                        })
339                        .filter_map(|x| x.transpose())
340                        .collect::<Result<_>>()?;
341                    match path_and_backends.len() {
342                        0 => (),
343                        1 => {
344                            let (path, backend) = &path_and_backends[0];
345                            debug!("found config at {path:?}");
346                            return load_config(path.clone_arc(), **backend);
347                        }
348                        _ => {
349                            let paths = path_and_backends
350                                .iter()
351                                .map(|(path, _)| path)
352                                .collect::<Vec<_>>();
353                            bail!(
354                                "multiple config file paths found, leading to ambiguity: {paths:?}"
355                            )
356                        }
357                    }
358                }
359                let config = or_else(&format!("tried the default paths: {default_paths_tried:?}"))?;
360                Ok(Self {
361                    config,
362                    path_and_track: None,
363                })
364            } else {
365                let config = or_else(
366                    "no path was given and there is no default \
367                     config location for this type",
368                )?;
369                Ok(Self {
370                    config,
371                    path_and_track: None,
372                })
373            }
374        }
375    }
376}
377
378impl<T: DeserializeOwned + DefaultConfigPath> WarrantsRestart for ConfigFile<T> {
379    fn warrants_restart(&self) -> bool {
380        match self.perhaps_reload_config() {
381            Ok(Some(_)) => true,
382            Ok(None) => false,
383            Err(e) => {
384                use std::io::Write;
385                _ = writeln!(&mut stderr(), "could not reload config file: {e:#}");
386                false
387            }
388        }
389    }
390}