voa_config/file/
loader.rs1use std::{
4 collections::BTreeMap,
5 ffi::{OsStr, OsString},
6 os::linux::fs::MetadataExt,
7 path::{Path, PathBuf},
8};
9
10use directories::ProjectDirs;
11use log::{debug, error, info, trace};
12use voa_core::identifiers::Os;
13use xdg::BaseDirectories;
14
15use crate::{CONFIG_DIRS_SYSTEM_MODE, CONFIG_FILE_EXTENSION, file::ConfigFile};
16
17const QUALIFIER_NAME: &str = "voa";
19
20fn collect_user_mode_dirs() -> Vec<PathBuf> {
22 let mut config_dirs = CONFIG_DIRS_SYSTEM_MODE
24 .iter()
25 .map(PathBuf::from)
26 .collect::<Vec<_>>();
27
28 let Some(project_dirs) = ProjectDirs::from(QUALIFIER_NAME, "VOA", "VOA") else {
33 error!("Unable to retrieve XDG dirs for current user as it has no home");
34 return config_dirs;
35 };
36 let xdg = BaseDirectories::with_prefix(QUALIFIER_NAME);
38
39 {
41 let mut xdg_config_dirs = xdg.get_config_dirs();
42 xdg_config_dirs.reverse();
43
44 if xdg_config_dirs.is_empty() {
45 xdg_config_dirs.push(PathBuf::from("/etc/xdg/voa"));
46 }
47
48 config_dirs.append(&mut xdg_config_dirs);
49 }
50
51 config_dirs.push(project_dirs.config_dir().to_path_buf());
53
54 config_dirs
55}
56
57#[derive(Clone, Copy, Debug)]
59pub enum LoadMode {
60 System,
64
65 User,
70}
71
72#[derive(Debug)]
74pub struct ConfigLoader {
75 defaults: BTreeMap<Os, ConfigFile>,
76 drop_ins: BTreeMap<Os, Vec<ConfigFile>>,
77}
78
79impl ConfigLoader {
80 pub fn load(mode: LoadMode) -> Self {
87 let (defaults, drop_ins) = match mode {
88 LoadMode::System => (
89 collect_default_config_files(CONFIG_DIRS_SYSTEM_MODE),
90 collect_drop_in_config_files(CONFIG_DIRS_SYSTEM_MODE),
91 ),
92 LoadMode::User => {
93 let dirs = collect_user_mode_dirs();
94 (
95 collect_default_config_files(&dirs),
96 collect_drop_in_config_files(&dirs),
97 )
98 }
99 };
100
101 Self { defaults, drop_ins }
102 }
103
104 pub fn defaults(&self) -> &BTreeMap<Os, ConfigFile> {
106 &self.defaults
107 }
108
109 pub fn drop_ins(&self) -> &BTreeMap<Os, Vec<ConfigFile>> {
111 &self.drop_ins
112 }
113}
114
115fn is_masked(path: impl AsRef<Path>) -> bool {
122 let path = path.as_ref();
123
124 let metadata = match path.symlink_metadata() {
125 Ok(metadata) => metadata,
126 Err(error) => {
127 error!("Unable to retrieve metadata for config candidate {path:?}: {error}");
128 return false;
129 }
130 };
131
132 let Some(file_name) = path.file_name() else {
133 error!("The config candidate {path:?} has no file name. Skipping...");
134 return false;
135 };
136
137 if metadata.is_symlink() {
138 let target_path = match path.read_link() {
139 Ok(path) => path,
140 Err(error) => {
141 error!("Unable to read link target of symlink {path:?}: {error}");
142 return false;
143 }
144 };
145 if target_path.as_path() == Path::new("/dev/null") {
146 info!(
147 "The config candidate {path:?} is masked. Skipping all configs with the same file name: {file_name:?}"
148 );
149 return true;
150 }
151 }
152
153 if metadata.is_file() && metadata.st_size() == 0 {
154 info!(
155 "The config candidate {path:?} is empty (masked). Skipping all configs with the same file name: {file_name:?}"
156 );
157 return true;
158 }
159
160 false
161}
162
163fn collect_default_config_files(dirs: &[impl AsRef<Path>]) -> BTreeMap<Os, ConfigFile> {
172 let config_paths = {
173 let extension_check = Some(OsStr::new(CONFIG_FILE_EXTENSION));
174 let mut config_paths: BTreeMap<Os, Vec<PathBuf>> = BTreeMap::new();
175
176 for config_dir in dirs {
177 let config_dir = config_dir.as_ref();
178 let read_dir = match config_dir.read_dir() {
179 Ok(read_dir) => read_dir,
180 Err(error) => {
181 debug!("Unable to read directory {config_dir:?}: {error}");
182 continue;
183 }
184 };
185
186 for dir_entry in read_dir {
187 let dir_entry = match dir_entry {
188 Ok(dir_entry) => dir_entry,
189 Err(error) => {
190 debug!("Unable to read file in {config_dir:?}: {error}");
191 continue;
192 }
193 };
194
195 let config_path = dir_entry.path();
196
197 if config_path.extension() != extension_check {
199 trace!(
200 "The drop-in config path {config_path:?} does not use the {CONFIG_FILE_EXTENSION} extension. Skipping..."
201 );
202 continue;
203 }
204
205 let os = {
207 let without_yaml = config_path.with_extension("");
208 let Some(file_name) = without_yaml.file_name() else {
209 trace!(
210 "Path {config_path:?} has no file name after removing the .yaml extension. Skipping..."
211 );
212 continue;
213 };
214
215 match Os::try_from(file_name) {
216 Ok(os) => os,
217 Err(error) => {
218 error!(
219 "Invalid OS identifier in file name of path {config_path:?}: {error}"
220 );
221 continue;
222 }
223 }
224 };
225
226 if let Some(path_list) = config_paths.get_mut(&os) {
227 path_list.push(config_path);
228 } else {
229 config_paths.insert(os, vec![config_path]);
230 }
231 }
232 }
233
234 config_paths
235 };
236
237 let mut output: BTreeMap<Os, ConfigFile> = BTreeMap::new();
239 for (os, path_list) in config_paths.into_iter() {
240 if path_list.as_slice().iter().any(is_masked) {
243 continue;
244 }
245
246 let config_file_list = {
247 let mut config_file_list: Vec<ConfigFile> = Vec::new();
248 for path in path_list.iter() {
249 if !path.is_file() {
251 error!("The path {path:?} is not a file. Skipping...");
252 continue;
253 }
254
255 match ConfigFile::from_yaml_file(path, super::ConfigFileType::Config) {
256 Ok(config_file) => config_file_list.push(config_file),
257 Err(error) => {
258 error!("Failed to read config file from file path {path:?}: {error}")
259 }
260 }
261 }
262 config_file_list
263 };
264
265 let Some(config_file) = config_file_list.into_iter().last() else {
266 error!("No valid config file found for OS {os}");
267 continue;
268 };
269
270 output.insert(os, config_file);
271 }
272
273 output
274}
275
276fn collect_drop_in_dirs(dirs: &[impl AsRef<Path>]) -> BTreeMap<Os, Vec<PathBuf>> {
284 let mut drop_in_dirs: BTreeMap<Os, Vec<PathBuf>> = BTreeMap::new();
285
286 for config_dir in dirs {
287 let config_dir = config_dir.as_ref();
288 let read_dir = match config_dir.read_dir() {
289 Ok(read_dir) => read_dir,
290 Err(error) => {
291 debug!("Unable to read directory {config_dir:?}: {error}");
292 continue;
293 }
294 };
295
296 for dir_entry in read_dir {
297 let dir_entry = match dir_entry {
298 Ok(dir_entry) => dir_entry,
299 Err(error) => {
300 debug!("Unable to read entry in {config_dir:?}: {error}");
301 continue;
302 }
303 };
304
305 let path = dir_entry.path();
306
307 if !path.is_dir() {
309 trace!("Non-directory path {path:?} cannot be a drop-in directory. Skipping...");
310 continue;
311 }
312
313 if path.extension() != Some(OsStr::new("d"))
315 || path.with_extension("").extension() != Some(OsStr::new(CONFIG_FILE_EXTENSION))
316 {
317 trace!("Directory {path:?} has no .yaml.d extension. Skipping...");
318 continue;
319 }
320
321 let os = {
323 let without_d = path.with_extension("");
324 let without_yaml = without_d.with_extension("");
325 let Some(file_name) = without_yaml.file_name() else {
326 trace!(
327 "Directory {path:?} has no file name after removing the .yaml.d extension. Skipping..."
328 );
329 continue;
330 };
331
332 match Os::try_from(file_name) {
333 Ok(os) => os,
334 Err(error) => {
335 error!("Invalid OS identifier in drop-in directory path {path:?}: {error}");
336 continue;
337 }
338 }
339 };
340
341 if let Some(paths) = drop_in_dirs.get_mut(&os) {
342 paths.push(path);
343 } else {
344 drop_in_dirs.insert(os, vec![path]);
345 }
346 }
347 }
348
349 drop_in_dirs
350}
351
352fn collect_drop_in_config_files(dirs: &[impl AsRef<Path>]) -> BTreeMap<Os, Vec<ConfigFile>> {
361 let drop_in_dirs = collect_drop_in_dirs(dirs);
362 let extension_check = Some(OsStr::new(CONFIG_FILE_EXTENSION));
363
364 let mut os_drop_ins: BTreeMap<Os, BTreeMap<OsString, Vec<PathBuf>>> = BTreeMap::new();
365 for (os, drop_in_dir_list) in drop_in_dirs.iter() {
369 for drop_in_dir in drop_in_dir_list.iter() {
370 let read_dir = match drop_in_dir.read_dir() {
371 Ok(read_dir) => read_dir,
372 Err(error) => {
373 error!("Unable to read drop-in directory {drop_in_dir:?}: {error}");
374 continue;
375 }
376 };
377
378 for dir_entry in read_dir {
379 let dir_entry = match dir_entry {
380 Ok(dir_entry) => dir_entry,
381 Err(error) => {
382 error!("Unable to read entry in {drop_in_dir:?}: {error}");
383 continue;
384 }
385 };
386
387 let config_path = dir_entry.path();
388
389 if config_path.extension() != extension_check {
391 trace!(
392 "The drop-in config path {config_path:?} does not use the {CONFIG_FILE_EXTENSION} extension. Skipping..."
393 );
394 continue;
395 }
396
397 let Some(file_name) = config_path.file_name().map(ToOwned::to_owned) else {
399 error!(
400 "The drop-in config file path {config_path:?} has no file name. Skipping..."
401 );
402 continue;
403 };
404
405 if let Some(file_name_paths) = os_drop_ins.get_mut(os) {
406 if let Some(path_list) = file_name_paths.get_mut(&file_name) {
407 path_list.push(config_path.to_path_buf());
408 } else {
409 file_name_paths.insert(file_name, vec![config_path.to_path_buf()]);
410 }
411 } else {
412 os_drop_ins.insert(
413 os.clone(),
414 BTreeMap::from_iter([(file_name, vec![config_path.to_path_buf()])]),
415 );
416 }
417 }
418 }
419 }
420
421 let mut output: BTreeMap<Os, Vec<ConfigFile>> = BTreeMap::new();
422 for (os, file_name_paths) in os_drop_ins.into_iter() {
424 for (file_name, path_list) in file_name_paths.into_iter() {
426 debug!(
427 "Precedence for {file_name:?}: {}",
428 path_list
429 .iter()
430 .map(|path| path.to_string_lossy().to_string())
431 .collect::<Vec<_>>()
432 .join(" < ")
433 );
434 if path_list.as_slice().iter().any(is_masked) {
438 continue;
439 }
440
441 let config_file_list = {
442 let mut config_file_list: Vec<ConfigFile> = Vec::new();
443 for path in path_list.iter() {
444 if !path.is_file() {
446 error!("The path {path:?} is not a file. Skipping...");
447 continue;
448 }
449
450 match ConfigFile::from_yaml_file(path, crate::file::ConfigFileType::DropIn) {
451 Ok(config_file) => config_file_list.push(config_file),
452 Err(error) => {
453 error!("Failed to read config file from file path {path:?}: {error}")
454 }
455 }
456 }
457 config_file_list
458 };
459
460 let Some(config_file) = config_file_list.into_iter().last() else {
461 error!("No valid config file with file name {file_name:?} found for OS {os}");
462 continue;
463 };
464
465 info!(
466 "Using {} as drop-in config file path with highest priority for file name {file_name:?}",
467 config_file
468 .origins()
469 .iter()
470 .map(ToString::to_string)
471 .collect::<Vec<_>>()
472 .join(", ")
473 );
474
475 if let Some(config_file_list) = output.get_mut(&os) {
476 config_file_list.push(config_file);
477 } else {
478 output.insert(os.clone(), vec![config_file]);
479 }
480 }
481 }
482
483 output
484}