Skip to main content

muster/adapter/config/
yaml.rs

1use std::{
2    fs,
3    path::{Path, PathBuf},
4};
5
6use directories::ProjectDirs;
7use getset::Getters;
8use serde::Serialize;
9use typed_builder::TypedBuilder;
10
11use crate::domain::{
12    config::{ConfigError, WorkspaceConfig},
13    port::ConfigSource,
14};
15
16/// Application directory used to locate the platform config directory.
17const APP_DIR: &str = "muster";
18
19/// Path to `filename` inside muster's config directory, when one can be resolved
20/// (`~/.config/muster/<filename>` on Linux). Shared by the project registry and
21/// the settings store.
22pub(crate) fn config_dir_path(filename: &str) -> Option<PathBuf> {
23    ProjectDirs::from("", "", APP_DIR).map(|dirs| dirs.config_dir().join(filename))
24}
25
26/// Reads and parses a `muster.yml`-style workspace config from `path`. Shared by
27/// the single-file config source and the project registry.
28///
29/// # Errors
30/// Returns a `ConfigError` if the file cannot be read or is not valid config.
31pub(crate) fn load_workspace(path: &Path) -> Result<WorkspaceConfig, ConfigError> {
32    let raw = fs::read_to_string(path).map_err(|source| ConfigError::Read {
33        path: path.to_path_buf(),
34        source,
35    })?;
36    let config: WorkspaceConfig = serde_yaml_ng::from_str(&raw)?;
37    config.validate()?;
38    Ok(config)
39}
40
41/// Serializes `value` to YAML and writes it to `path`, creating any missing
42/// parent directories first. The write is atomic: it lands in a sibling
43/// temporary file that is then renamed over the destination, so a crash, full
44/// disk, or short write can never truncate an existing valid file.
45///
46/// An existing symlink is followed so its target is rewritten rather than
47/// replaced with a regular file. A parentless relative path (e.g. `muster.yml`)
48/// writes into the current directory.
49///
50/// # Errors
51/// Returns a `ConfigError::Write` if a directory, temp file, or rename fails.
52pub(crate) fn write_config<T: Serialize>(path: &Path, value: &T) -> Result<(), ConfigError> {
53    let dest = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
54    if let Some(parent) = dest.parent()
55        && !parent.as_os_str().is_empty()
56    {
57        fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
58            path: parent.to_path_buf(),
59            source,
60        })?;
61    }
62    let raw = serde_yaml_ng::to_string(value)?;
63    let temp = temp_path(&dest);
64    fs::write(&temp, raw).map_err(|source| ConfigError::Write {
65        path: temp.clone(),
66        source,
67    })?;
68    fs::rename(&temp, &dest).map_err(|source| {
69        let _ = fs::remove_file(&temp);
70        ConfigError::Write {
71            path: dest.clone(),
72            source,
73        }
74    })
75}
76
77/// A sibling temporary path in the same directory as `path`, so the later rename
78/// stays on one filesystem and is therefore atomic.
79fn temp_path(path: &Path) -> PathBuf {
80    let mut name = path.file_name().unwrap_or_default().to_os_string();
81    name.push(format!(".{}.tmp", std::process::id()));
82    path.with_file_name(name)
83}
84
85/// A [`ConfigSource`] that loads a `muster.yml`-style file from disk.
86#[derive(Clone, Debug, Getters, TypedBuilder)]
87#[getset(get = "pub")]
88pub struct YamlConfigSource {
89    /// Path to the YAML config file.
90    path: PathBuf,
91}
92
93impl ConfigSource for YamlConfigSource {
94    fn load(&self) -> Result<WorkspaceConfig, ConfigError> {
95        load_workspace(&self.path)
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    fn example_config() -> PathBuf {
104        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/muster.yml")
105    }
106
107    #[test]
108    fn loads_the_example_config() {
109        let source = YamlConfigSource::builder().path(example_config()).build();
110        let config = source.load().unwrap();
111        assert!(!config.to_processes().is_empty());
112    }
113
114    #[test]
115    fn missing_file_is_a_read_error() {
116        let source = YamlConfigSource::builder()
117            .path(PathBuf::from("/nonexistent/muster.yml"))
118            .build();
119        assert!(matches!(source.load(), Err(ConfigError::Read { .. })));
120    }
121}