Skip to main content

statime_config/load/
mod.rs

1use std::path::{Path, PathBuf};
2
3use serde::de::DeserializeOwned;
4
5use crate::{
6    ConfigError,
7    load::files::{Files, Filesystem},
8    tree::{ConfigMerger, Configurable, Merge, PartialValue},
9};
10
11mod files;
12
13/// Everything the loader needs of a partial configuration tree
14pub trait PartialTree: DeserializeOwned + Default + Merge + PartialValue {}
15
16impl<T> PartialTree for T where T: DeserializeOwned + Default + Merge + PartialValue {}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum SystemConfigSetting {
20    Unset,
21    None,
22    DefaultPath(PathBuf),
23    UserSpecifiedPath(PathBuf),
24}
25
26/// A configuration that a document can be loaded into.
27///
28/// Only the root of a configuration carries the setting that decides which
29/// system configuration to layer underneath it.
30pub trait RootConfig: Configurable<Partial: PartialTree<Resolved = Self>> {
31    /// Whether, and from where, to read a system configuration, as the document
32    /// said, or `None` if it said nothing. Generated by the derive from the
33    /// field marked `#[config(use_system_config)]`.
34    fn use_system_config(partial: &Self::Partial) -> SystemConfigSetting;
35
36    /// Load this configuration from `path`
37    fn load(path: impl AsRef<Path>) -> Result<Self, ConfigError>
38    where
39        Self: Sized,
40    {
41        load_from(&Filesystem, path.as_ref())
42    }
43}
44
45fn load_from<T: RootConfig>(files: &impl Files, main_path: &Path) -> Result<T, ConfigError> {
46    let main = parse::<T::Partial>(files, main_path)?;
47
48    let mut merger = ConfigMerger::new();
49
50    for fragment in system_fragments(files, T::use_system_config(&main))? {
51        let partial = parse::<T::Partial>(files, &fragment)?;
52
53        // this setting is what sent the loader looking for fragments, so
54        // the documents it found cannot be the ones to answer it
55        if T::use_system_config(&partial) != SystemConfigSetting::Unset {
56            return Err(ConfigError::DirectiveNotAllowed { path: fragment });
57        }
58
59        merger.add_system(fragment, partial)?;
60    }
61
62    merger.add_main(main_path.to_path_buf(), main)?;
63    merger.apply_defaults();
64
65    let (config, _provenance) = merger.finish()?;
66
67    Ok(config)
68}
69
70fn parse<T: DeserializeOwned>(files: &impl Files, path: &Path) -> Result<T, ConfigError> {
71    let contents = files
72        .read(path)
73        .map_err(|cause| ConfigError::CouldNotRead {
74            path: path.to_path_buf(),
75            cause,
76        })?;
77
78    toml::from_str(&contents).map_err(|cause| ConfigError::CouldNotParse {
79        path: path.to_path_buf(),
80        cause,
81    })
82}
83
84/// The directory to read fragments from, and whether the configuration named
85/// it: a directory that was asked for and is missing is a mistake, whereas the
86/// default one can be missing.
87fn fragment_directory(setting: SystemConfigSetting) -> Option<(PathBuf, bool)> {
88    match setting {
89        SystemConfigSetting::Unset => None,
90        SystemConfigSetting::None => None,
91        SystemConfigSetting::DefaultPath(path_buf) => Some((path_buf, false)),
92        SystemConfigSetting::UserSpecifiedPath(path_buf) => Some((path_buf, true)),
93    }
94}
95
96fn system_fragments(
97    files: &impl Files,
98    setting: SystemConfigSetting,
99) -> Result<Vec<PathBuf>, ConfigError> {
100    let Some((directory, named)) = fragment_directory(setting) else {
101        // use-system-config is not set, or set explicitly to false
102        return Ok(Vec::new());
103    };
104
105    let entries = match files.list(&directory) {
106        Ok(entries) => entries,
107        Err(cause) if cause.kind() == std::io::ErrorKind::NotFound && !named => {
108            return Ok(Vec::new());
109        }
110        Err(cause) => {
111            return Err(ConfigError::CouldNotRead {
112                path: directory,
113                cause,
114            });
115        }
116    };
117
118    // only read toml files in the directory
119    let mut fragments: Vec<_> = entries
120        .into_iter()
121        .filter(|path| {
122            path.extension()
123                .is_some_and(|extension| extension == "toml")
124        })
125        .collect();
126
127    // fragments don't need to be merged deterministically, but it helps for
128    // error messages and debugging to ensure we do
129    fragments.sort();
130
131    Ok(fragments)
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::{
138        ConfigError,
139        fixture::{Fixture, Level, Server, Source, UseSystemConfig},
140        load::files::Memory,
141    };
142
143    fn load(documents: Memory) -> Result<Fixture, ConfigError> {
144        load_from::<Fixture>(&documents, Path::new("/etc/main.toml"))
145    }
146
147    #[test]
148    fn fragments_contribute_to_the_configuration() {
149        let documents = Memory::new()
150            .document("/etc/main.toml", "use-system-config = '/etc/system.d'")
151            .document("/etc/system.d/10-logging.toml", "logging.level = 'warn'")
152            .document(
153                "/etc/system.d/20-sources.toml",
154                "[[sources]]\nkind = 'server'\naddress = 'example.com'",
155            )
156            // only TOML documents are read
157            .document("/etc/system.d/README.md", "not a configuration");
158
159        let config = load(documents).unwrap();
160
161        assert_eq!(config.logging.level, Level::Warn);
162        assert_eq!(
163            config.sources,
164            vec![Source::Server(Server {
165                address: "example.com".to_owned(),
166                version: 4,
167            })]
168        );
169    }
170
171    #[test]
172    fn the_main_config_overrides_a_fragment() {
173        let documents = Memory::new()
174            .document(
175                "/etc/main.toml",
176                "use-system-config = '/etc/system.d'\nlogging.level = 'debug'",
177            )
178            .document("/etc/system.d/10-logging.toml", "logging.level = 'warn'");
179
180        let config = load(documents).unwrap();
181
182        assert_eq!(config.logging.level, Level::Debug);
183    }
184
185    #[test]
186    fn a_fragment_may_not_steer_the_loader() {
187        let documents = Memory::new()
188            .document("/etc/main.toml", "use-system-config = '/etc/system.d'")
189            .document("/etc/system.d/10-logging.toml", "use-system-config = true");
190
191        let error = load(documents).unwrap_err();
192
193        let ConfigError::DirectiveNotAllowed { path } = error else {
194            panic!("expected a rejected directive, got {error:?}");
195        };
196        assert_eq!(path, PathBuf::from("/etc/system.d/10-logging.toml"));
197    }
198
199    #[test]
200    fn the_default_directory_is_read_when_enabled() {
201        let documents = Memory::new()
202            .document("/etc/main.toml", "use-system-config = true")
203            .document(
204                "/usr/lib/ntpd-rs/system-config/10-logging.toml",
205                "logging.level = 'warn'",
206            );
207
208        let config = load(documents).unwrap();
209
210        assert_eq!(config.logging.level, Level::Warn);
211    }
212
213    #[test]
214    fn an_unset_setting_means_no_system_configuration() {
215        let documents = Memory::new()
216            .document("/etc/main.toml", "")
217            // the same fragment the setting would have reached
218            .document(
219                "/usr/lib/ntpd-rs/system-config/10-logging.toml",
220                "logging.level = 'warn'",
221            );
222
223        let config = load(documents).unwrap();
224
225        assert_eq!(config.logging.level, Level::Info);
226    }
227
228    #[test]
229    fn the_system_config_setting_is_part_of_the_configuration() {
230        let documents = Memory::new()
231            .document("/etc/main.toml", "use-system-config = '/etc/system.d'")
232            .directory("/etc/system.d");
233
234        let config = load(documents).unwrap();
235
236        assert_eq!(
237            config.use_system_config,
238            UseSystemConfig::Directory("/etc/system.d".into())
239        );
240
241        // and it takes its default like any other setting
242        let unset = Memory::new().document("/etc/main.toml", "");
243        assert_eq!(
244            load(unset).unwrap().use_system_config,
245            UseSystemConfig::Enabled(false)
246        );
247    }
248
249    #[test]
250    fn an_empty_fragment_directory_is_not_an_error() {
251        let documents = Memory::new()
252            .document("/etc/main.toml", "use-system-config = '/etc/system.d'")
253            .directory("/etc/system.d");
254
255        let config = load(documents).unwrap();
256
257        assert_eq!(config.logging.level, Level::Info);
258    }
259
260    #[test]
261    fn a_named_directory_that_is_missing_is_an_error() {
262        let documents =
263            Memory::new().document("/etc/main.toml", "use-system-config = '/etc/system.d'");
264
265        let error = load(documents).unwrap_err();
266
267        let ConfigError::CouldNotRead { path, .. } = error else {
268            panic!("expected a read failure, got {error:?}");
269        };
270        assert_eq!(path, PathBuf::from("/etc/system.d"));
271    }
272
273    #[test]
274    fn errors_describe_themselves() {
275        let missing_value = Memory::new().document(
276            "/etc/main.toml",
277            "[[sources]]\nkind = 'server'\nversion = 5",
278        );
279        assert_eq!(
280            load(missing_value).unwrap_err().to_string(),
281            "`sources[0].address` is required, but was never set"
282        );
283
284        let unreadable = Memory::new();
285        assert_eq!(
286            load(unreadable).unwrap_err().to_string(),
287            "could not read `/etc/main.toml`: no such document"
288        );
289
290        let set_in_fragment = Memory::new()
291            .document("/etc/main.toml", "use-system-config = '/etc/system.d'")
292            .document("/etc/system.d/10-logging.toml", "use-system-config = true");
293        assert_eq!(
294            load(set_in_fragment).unwrap_err().to_string(),
295            "`/etc/system.d/10-logging.toml` sets `use-system-config`, \
296             which only the main configuration may do"
297        );
298    }
299
300    /// Assert that a document is rejected while parsing, by a complaint that
301    /// points at `offender`.
302    fn rejects(document: &str, offender: &str) {
303        let documents = Memory::new().document("/etc/main.toml", document);
304        let error = load(documents).unwrap_err();
305
306        assert!(
307            matches!(error, ConfigError::CouldNotParse { .. }),
308            "expected {document:?} to be rejected while parsing, got {error:?}"
309        );
310        assert!(
311            error.to_string().contains(offender),
312            "the complaint about {document:?} does not mention `{offender}`: {error}"
313        );
314    }
315
316    #[test]
317    fn an_unknown_key_is_rejected_wherever_it_appears() {
318        rejects("loggign = 'warn'", "loggign");
319        rejects("[logging]\nlevle = 'warn'", "levle");
320        rejects(
321            "[[sources]]\nkind = 'server'\naddress = 'a'\naddres = 'b'",
322            "addres",
323        );
324        rejects("[[sources]]\nkind = 'nonsense'", "nonsense");
325    }
326
327    #[test]
328    fn the_default_directory_need_not_exist() {
329        let documents = Memory::new().document("/etc/main.toml", "use-system-config = true");
330
331        let config = load(documents).unwrap();
332
333        assert_eq!(config.logging.level, Level::Info);
334    }
335}