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
13pub 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
26pub trait RootConfig: Configurable<Partial: PartialTree<Resolved = Self>> {
31 fn use_system_config(partial: &Self::Partial) -> SystemConfigSetting;
35
36 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 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
84fn 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 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 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.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 .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 .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 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 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}