1#![allow(rustdoc::broken_intra_doc_links)]
2
3use std::ffi::OsString;
4use std::fs;
5use std::io::{self, Write};
6use std::path::{Path, PathBuf};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use super::model::{BhtuneConfig, default_allow_uncertain_quality, parse_config_contents};
10use super::paths::config_path_from;
11use super::tuning::{
12 TuningConfig, TuningConfigSources, resolve_and_validate_tuning_config, tuning_config_sources,
13};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ConfigPathResolution {
19 pub path: Option<PathBuf>,
20 pub missing_is_allowed: bool,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct LoadedConfigStore {
26 pub path: Option<PathBuf>,
27 pub missing_is_allowed: bool,
28 pub original_raw: Option<String>,
29 pub config: BhtuneConfig,
30 pub revision: String,
31 pub toml_allow_uncertain_quality: Option<bool>,
34 pub toml_tuning: TuningConfig,
36 pub tuning_sources: TuningConfigSources,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ConfigPolicyUpdate {
44 pub allow_uncertain_quality: bool,
45 pub retention_days: Option<u32>,
46 pub mrft_delay_secs: Option<u32>,
47 pub poll_interval_ms: Option<u64>,
48 pub timeout_secs: Option<u64>,
49 pub op_timeout_secs: Option<u64>,
50 pub restore_timeout_secs: Option<u64>,
51}
52
53impl ConfigPolicyUpdate {
54 pub fn tuning(&self) -> TuningConfig {
55 TuningConfig {
56 mrft_delay_secs: self.mrft_delay_secs,
57 poll_interval_ms: self.poll_interval_ms,
58 timeout_secs: self.timeout_secs,
59 op_timeout_secs: self.op_timeout_secs,
60 restore_timeout_secs: self.restore_timeout_secs,
61 }
62 }
63}
64
65impl Default for ConfigPolicyUpdate {
66 fn default() -> Self {
67 Self {
68 allow_uncertain_quality: default_allow_uncertain_quality(),
69 retention_days: None,
70 mrft_delay_secs: None,
71 poll_interval_ms: None,
72 timeout_secs: None,
73 op_timeout_secs: None,
74 restore_timeout_secs: None,
75 }
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct ConfigSaveResult {
82 pub backup_path: Option<PathBuf>,
83 pub state: LoadedConfigStore,
84}
85
86#[derive(Debug)]
88pub enum ConfigStoreError {
89 PathNotResolved,
90 Missing {
91 path: PathBuf,
92 },
93 Unreadable {
94 path: PathBuf,
95 source: io::Error,
96 },
97 Malformed {
98 path: Option<PathBuf>,
99 source: String,
100 },
101 Conflict {
102 path: Option<PathBuf>,
103 message: String,
104 },
105 Write {
106 path: PathBuf,
107 action: &'static str,
108 source: io::Error,
109 },
110}
111
112impl std::fmt::Display for ConfigStoreError {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 match self {
115 Self::PathNotResolved => write!(
116 f,
117 "no config path could be resolved from --config / XDG_CONFIG_HOME / HOME / APPDATA"
118 ),
119 Self::Missing { path } => write!(f, "config file not found: {}", path.display()),
120 Self::Unreadable { path, source } => {
121 write!(f, "failed to read config file {}: {source}", path.display())
122 }
123 Self::Malformed {
124 path: Some(path),
125 source,
126 } => write!(
127 f,
128 "failed to parse config file {}: {source}",
129 path.display()
130 ),
131 Self::Malformed { path: None, source } => {
132 write!(f, "failed to parse config contents: {source}")
133 }
134 Self::Conflict {
135 path: Some(path),
136 message,
137 } => write!(f, "config store conflict for {}: {message}", path.display()),
138 Self::Conflict {
139 path: None,
140 message,
141 } => write!(f, "config store conflict: {message}"),
142 Self::Write {
143 path,
144 action,
145 source,
146 } => write!(f, "failed to {action} {}: {source}", path.display()),
147 }
148 }
149}
150
151impl std::error::Error for ConfigStoreError {
152 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
153 match self {
154 Self::Unreadable { source, .. } | Self::Write { source, .. } => Some(source),
155 _ => None,
156 }
157 }
158}
159
160pub fn resolve_config_store_path(
165 explicit_path: Option<&Path>,
166 xdg_config_home: Option<&str>,
167 home: Option<&str>,
168 appdata: Option<&str>,
169 is_windows: bool,
170) -> ConfigPathResolution {
171 match explicit_path {
172 Some(path) => ConfigPathResolution {
173 path: Some(path.to_path_buf()),
174 missing_is_allowed: false,
175 },
176 None => ConfigPathResolution {
177 path: config_path_from(xdg_config_home, home, appdata, is_windows),
178 missing_is_allowed: true,
179 },
180 }
181}
182
183const FNV1A_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
184
185const FNV1A_PRIME: u64 = 0x100000001b3;
186
187fn stable_revision_hash(bytes: &[u8]) -> u64 {
188 let mut hash = FNV1A_OFFSET_BASIS;
189 for byte in bytes {
190 hash ^= u64::from(*byte);
191 hash = hash.wrapping_mul(FNV1A_PRIME);
192 }
193 hash
194}
195
196fn revision_token_for_raw(raw: Option<&str>) -> String {
197 match raw {
198 Some(raw) => format!(
199 "present:v1:{}:{:016x}",
200 raw.len(),
201 stable_revision_hash(raw.as_bytes())
202 ),
203 None => "absent:v1".to_string(),
204 }
205}
206
207fn config_malformed(path: Option<&Path>, error: impl std::fmt::Display) -> ConfigStoreError {
208 ConfigStoreError::Malformed {
209 path: path.map(Path::to_path_buf),
210 source: error.to_string(),
211 }
212}
213
214fn load_config_store_from_resolution(
215 resolution: ConfigPathResolution,
216) -> Result<LoadedConfigStore, ConfigStoreError> {
217 match resolution.path {
218 Some(path) => match fs::read(&path) {
219 Ok(bytes) => {
220 let raw = String::from_utf8(bytes).map_err(|e| {
221 config_malformed(Some(&path), format!("config file is not valid UTF-8: {e}"))
222 })?;
223 let config =
224 parse_config_contents(&raw).map_err(|e| config_malformed(Some(&path), e))?;
225 let toml_allow_uncertain_quality = raw
226 .parse::<toml_edit::DocumentMut>()
227 .ok()
228 .and_then(|document| {
229 document
230 .get("allow_uncertain_quality")
231 .and_then(|item| item.as_value())
232 .and_then(toml_edit::Value::as_bool)
233 });
234 let toml_tuning = config.tuning;
235 let tuning_sources = tuning_config_sources(&toml_tuning);
236 let revision = revision_token_for_raw(Some(&raw));
237 Ok(LoadedConfigStore {
238 path: Some(path),
239 missing_is_allowed: resolution.missing_is_allowed,
240 original_raw: Some(raw),
241 config,
242 revision,
243 toml_allow_uncertain_quality,
244 toml_tuning,
245 tuning_sources,
246 })
247 }
248 Err(e) if e.kind() == io::ErrorKind::NotFound && resolution.missing_is_allowed => {
249 let revision = revision_token_for_raw(None);
250 Ok(LoadedConfigStore {
251 path: Some(path),
252 missing_is_allowed: true,
253 original_raw: None,
254 config: BhtuneConfig::default(),
255 revision,
256 toml_allow_uncertain_quality: None,
257 toml_tuning: TuningConfig::default(),
258 tuning_sources: tuning_config_sources(&TuningConfig::default()),
259 })
260 }
261 Err(e) if e.kind() == io::ErrorKind::NotFound => {
262 Err(ConfigStoreError::Missing { path })
263 }
264 Err(e) => Err(ConfigStoreError::Unreadable { path, source: e }),
265 },
266 None => Ok(LoadedConfigStore {
267 path: None,
268 missing_is_allowed: true,
269 original_raw: None,
270 config: BhtuneConfig::default(),
271 revision: revision_token_for_raw(None),
272 toml_allow_uncertain_quality: None,
273 toml_tuning: TuningConfig::default(),
274 tuning_sources: tuning_config_sources(&TuningConfig::default()),
275 }),
276 }
277}
278
279pub fn load_config_file(path: &Path, missing_is_error: bool) -> anyhow::Result<BhtuneConfig> {
286 load_config_store_from_resolution(ConfigPathResolution {
287 path: Some(path.to_path_buf()),
288 missing_is_allowed: !missing_is_error,
289 })
290 .map(|store| store.config)
291 .map_err(|e| anyhow::anyhow!(e.to_string()))
292}
293
294fn patch_config_contents<F>(raw: Option<&str>, mutator: F) -> Result<(String, BhtuneConfig), String>
295where
296 F: FnOnce(&mut toml_edit::DocumentMut),
297{
298 let mut document = raw
299 .unwrap_or_default()
300 .parse::<toml_edit::DocumentMut>()
301 .map_err(|e| e.to_string())?;
302 mutator(&mut document);
303 let patched = document.to_string();
304 let parsed = parse_config_contents(&patched).map_err(|e| e.to_string())?;
305 Ok((patched, parsed))
306}
307
308pub fn patch_allow_uncertain_quality(
311 raw: Option<&str>,
312 allow_uncertain_quality: bool,
313) -> Result<String, ConfigStoreError> {
314 patch_config_contents(raw, |document| {
315 document["allow_uncertain_quality"] = toml_edit::value(allow_uncertain_quality);
316 })
317 .map_err(|source| config_malformed(None, source))
318 .map(|(patched, _)| patched)
319}
320
321pub fn patch_retention_days(
324 raw: Option<&str>,
325 retention_days: Option<u32>,
326) -> Result<String, ConfigStoreError> {
327 patch_config_contents(raw, |document| match retention_days {
328 Some(days) => {
329 document["retention_days"] = toml_edit::value(i64::from(days));
330 }
331 None => {
332 document.as_table_mut().remove("retention_days");
333 }
334 })
335 .map_err(|source| config_malformed(None, source))
336 .map(|(patched, _)| patched)
337}
338
339fn patch_optional_tuning_value<T>(
340 document: &mut toml_edit::DocumentMut,
341 key: &str,
342 value: Option<T>,
343) where
344 T: Into<toml_edit::Value>,
345{
346 match value {
347 Some(value) => {
348 if document.get("tuning").is_none() {
349 document["tuning"] = toml_edit::table();
350 }
351 document["tuning"][key] = toml_edit::value(value);
352 }
353 None => {
354 if let Some(table) = document
355 .get_mut("tuning")
356 .and_then(toml_edit::Item::as_table_like_mut)
357 {
358 table.remove(key);
359 }
360 }
361 }
362}
363
364fn optional_u64_to_toml_integer(
365 field: &'static str,
366 value: Option<u64>,
367) -> Result<Option<i64>, String> {
368 value
369 .map(|value| {
370 i64::try_from(value)
371 .map_err(|_| format!("tuning.{field} is too large to store as a TOML integer"))
372 })
373 .transpose()
374}
375
376pub fn patch_tuning_config(
379 raw: Option<&str>,
380 tuning: &TuningConfig,
381) -> Result<String, ConfigStoreError> {
382 resolve_and_validate_tuning_config(tuning, false)
383 .map_err(|source| config_malformed(None, source))?;
384 let poll_interval_ms =
385 optional_u64_to_toml_integer("poll_interval_ms", tuning.poll_interval_ms)
386 .map_err(|source| config_malformed(None, source))?;
387 let timeout_secs = optional_u64_to_toml_integer("timeout_secs", tuning.timeout_secs)
388 .map_err(|source| config_malformed(None, source))?;
389 let op_timeout_secs = optional_u64_to_toml_integer("op_timeout_secs", tuning.op_timeout_secs)
390 .map_err(|source| config_malformed(None, source))?;
391 let restore_timeout_secs =
392 optional_u64_to_toml_integer("restore_timeout_secs", tuning.restore_timeout_secs)
393 .map_err(|source| config_malformed(None, source))?;
394 let (patched, parsed) = patch_config_contents(raw, |document| {
395 patch_optional_tuning_value(
396 document,
397 "mrft_delay_secs",
398 tuning.mrft_delay_secs.map(i64::from),
399 );
400 patch_optional_tuning_value(document, "poll_interval_ms", poll_interval_ms);
401 patch_optional_tuning_value(document, "timeout_secs", timeout_secs);
402 patch_optional_tuning_value(document, "op_timeout_secs", op_timeout_secs);
403 patch_optional_tuning_value(document, "restore_timeout_secs", restore_timeout_secs);
404 })
405 .map_err(|source| config_malformed(None, source))?;
406 resolve_and_validate_tuning_config(&parsed.tuning, false)
407 .map_err(|source| config_malformed(None, source))?;
408 Ok(patched)
409}
410
411fn patch_config_policy(
412 raw: Option<&str>,
413 update: &ConfigPolicyUpdate,
414) -> Result<(String, BhtuneConfig), String> {
415 let tuning = update.tuning();
416 resolve_and_validate_tuning_config(&tuning, false).map_err(|e| e.to_string())?;
417 let poll_interval_ms =
418 optional_u64_to_toml_integer("poll_interval_ms", tuning.poll_interval_ms)?;
419 let timeout_secs = optional_u64_to_toml_integer("timeout_secs", tuning.timeout_secs)?;
420 let op_timeout_secs = optional_u64_to_toml_integer("op_timeout_secs", tuning.op_timeout_secs)?;
421 let restore_timeout_secs =
422 optional_u64_to_toml_integer("restore_timeout_secs", tuning.restore_timeout_secs)?;
423 let result = patch_config_contents(raw, |document| {
424 document["allow_uncertain_quality"] = toml_edit::value(update.allow_uncertain_quality);
425 match update.retention_days {
426 Some(days) => {
427 document["retention_days"] = toml_edit::value(i64::from(days));
428 }
429 None => {
430 document.as_table_mut().remove("retention_days");
431 }
432 }
433 patch_optional_tuning_value(
434 document,
435 "mrft_delay_secs",
436 tuning.mrft_delay_secs.map(i64::from),
437 );
438 patch_optional_tuning_value(document, "poll_interval_ms", poll_interval_ms);
439 patch_optional_tuning_value(document, "timeout_secs", timeout_secs);
440 patch_optional_tuning_value(document, "op_timeout_secs", op_timeout_secs);
441 patch_optional_tuning_value(document, "restore_timeout_secs", restore_timeout_secs);
442 })?;
443 resolve_and_validate_tuning_config(&result.1.tuning, false).map_err(|e| e.to_string())?;
444 Ok(result)
445}
446
447pub fn load_config_store(
449 explicit_path: Option<&Path>,
450) -> Result<LoadedConfigStore, ConfigStoreError> {
451 load_config_store_from(
452 explicit_path,
453 std::env::var("XDG_CONFIG_HOME").ok().as_deref(),
454 std::env::var("HOME").ok().as_deref(),
455 std::env::var("APPDATA").ok().as_deref(),
456 cfg!(target_os = "windows"),
457 )
458}
459
460pub fn load_config_store_from(
464 explicit_path: Option<&Path>,
465 xdg_config_home: Option<&str>,
466 home: Option<&str>,
467 appdata: Option<&str>,
468 is_windows: bool,
469) -> Result<LoadedConfigStore, ConfigStoreError> {
470 load_config_store_from_resolution(resolve_config_store_path(
471 explicit_path,
472 xdg_config_home,
473 home,
474 appdata,
475 is_windows,
476 ))
477}
478
479fn ensure_parent_dir(path: &Path) -> Result<(), ConfigStoreError> {
480 path.parent()
481 .filter(|parent| !parent.as_os_str().is_empty())
482 .map(|parent| {
483 fs::create_dir_all(parent).map_err(|e| ConfigStoreError::Write {
484 path: path.to_path_buf(),
485 action: "create config directory",
486 source: e,
487 })
488 })
489 .transpose()
490 .map(|_| ())
491}
492
493fn unique_suffix() -> String {
494 let timestamp = SystemTime::now()
495 .duration_since(UNIX_EPOCH)
496 .unwrap_or_default();
497 format!(
498 "{}-{}-{:09}",
499 std::process::id(),
500 timestamp.as_secs(),
501 timestamp.subsec_nanos()
502 )
503}
504
505fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf {
506 let mut file_name = path
507 .file_name()
508 .map(OsString::from)
509 .unwrap_or_else(|| OsString::from("bhtune.toml"));
510 file_name.push(format!(".{suffix}-{}", unique_suffix()));
511 path.with_file_name(file_name)
512}
513
514fn backup_path_for(path: &Path) -> PathBuf {
515 let mut file_name = path
516 .file_name()
517 .map(OsString::from)
518 .unwrap_or_else(|| OsString::from("bhtune.toml"));
519 file_name.push(format!(".backup-{}.bak", unique_suffix()));
520 path.with_file_name(file_name)
521}
522
523fn create_temp_file(path: &Path) -> Result<(PathBuf, fs::File), ConfigStoreError> {
524 create_temp_file_with(path, || sibling_with_suffix(path, "tmp"))
525}
526
527fn create_temp_file_with<F>(
528 path: &Path,
529 mut next_path: F,
530) -> Result<(PathBuf, fs::File), ConfigStoreError>
531where
532 F: FnMut() -> PathBuf,
533{
534 for _ in 0..16 {
535 let temp_path = next_path();
536 match fs::OpenOptions::new()
537 .create_new(true)
538 .truncate(true)
539 .write(true)
540 .open(&temp_path)
541 {
542 Ok(file) => return Ok((temp_path, file)),
543 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
544 Err(e) => {
545 return Err(ConfigStoreError::Write {
546 path: path.to_path_buf(),
547 action: "create temporary config file",
548 source: e,
549 });
550 }
551 }
552 }
553
554 Err(ConfigStoreError::Write {
555 path: path.to_path_buf(),
556 action: "create temporary config file",
557 source: io::Error::new(
558 io::ErrorKind::AlreadyExists,
559 "exhausted unique temp-file names",
560 ),
561 })
562}
563
564trait SyncConfigFile {
565 fn sync_config(&self) -> io::Result<()>;
566}
567
568impl SyncConfigFile for fs::File {
569 fn sync_config(&self) -> io::Result<()> {
570 self.sync_all()
571 }
572}
573
574fn write_and_flush_temp_file<T>(
575 path: &Path,
576 temp_path: &Path,
577 bytes: &[u8],
578 temp_file: &mut T,
579) -> Result<(), ConfigStoreError>
580where
581 T: Write + SyncConfigFile,
582{
583 if let Err(e) = temp_file.write_all(bytes) {
584 let _ = fs::remove_file(temp_path);
585 return Err(ConfigStoreError::Write {
586 path: path.to_path_buf(),
587 action: "write temporary config file",
588 source: e,
589 });
590 }
591 if let Err(e) = temp_file.sync_config() {
592 let _ = fs::remove_file(temp_path);
593 return Err(ConfigStoreError::Write {
594 path: path.to_path_buf(),
595 action: "flush temporary config file",
596 source: e,
597 });
598 }
599 Ok(())
600}
601
602#[cfg(unix)]
603fn sync_parent_dir(path: &Path) {
604 if let Some(parent) = path
605 .parent()
606 .filter(|parent| !parent.as_os_str().is_empty())
607 && let Ok(dir) = fs::File::open(parent)
608 {
609 let _ = dir.sync_all();
610 }
611}
612
613#[cfg(not(unix))]
614fn sync_parent_dir(_path: &Path) {}
615
616fn write_config_file_atomically(
617 path: &Path,
618 bytes: &[u8],
619 create_parent_dir: bool,
620) -> Result<Option<PathBuf>, ConfigStoreError> {
621 write_config_file_atomically_with(
622 path,
623 bytes,
624 create_parent_dir,
625 |source, destination| fs::copy(source, destination),
626 |source, destination| fs::rename(source, destination),
627 )
628}
629
630fn write_config_file_atomically_with<Copy, Rename>(
631 path: &Path,
632 bytes: &[u8],
633 create_parent_dir: bool,
634 copy_backup: Copy,
635 replace: Rename,
636) -> Result<Option<PathBuf>, ConfigStoreError>
637where
638 Copy: Fn(&Path, &Path) -> io::Result<u64>,
639 Rename: Fn(&Path, &Path) -> io::Result<()>,
640{
641 if create_parent_dir {
642 ensure_parent_dir(path)?;
643 }
644
645 let (temp_path, mut temp_file) = create_temp_file(path)?;
646 write_and_flush_temp_file(path, &temp_path, bytes, &mut temp_file)?;
647 drop(temp_file);
648
649 let backup_path = if path.exists() {
650 let backup_path = backup_path_for(path);
651 if let Err(e) = copy_backup(path, &backup_path) {
652 let _ = fs::remove_file(&temp_path);
653 return Err(ConfigStoreError::Write {
654 path: path.to_path_buf(),
655 action: "create config backup",
656 source: e,
657 });
658 }
659 Some(backup_path)
660 } else {
661 None
662 };
663
664 let replace_result = replace(&temp_path, path);
665
666 if let Err(e) = replace_result {
667 let _ = fs::remove_file(&temp_path);
668 return Err(ConfigStoreError::Write {
669 path: path.to_path_buf(),
670 action: "replace config file",
671 source: e,
672 });
673 }
674
675 sync_parent_dir(path);
676 Ok(backup_path)
677}
678
679pub fn save_config_store(
685 state: &LoadedConfigStore,
686 expected_revision: &str,
687 update: &ConfigPolicyUpdate,
688) -> Result<ConfigSaveResult, ConfigStoreError> {
689 if state.revision != expected_revision {
690 return Err(ConfigStoreError::Conflict {
691 path: state.path.clone(),
692 message: format!(
693 "stale config revision token: expected {expected_revision}, latest {}",
694 state.revision
695 ),
696 });
697 }
698
699 let path = state
700 .path
701 .clone()
702 .ok_or(ConfigStoreError::PathNotResolved)?;
703
704 let current_bytes = match fs::read(&path) {
705 Ok(bytes) => Some(bytes),
706 Err(e) if e.kind() == io::ErrorKind::NotFound => None,
707 Err(e) => {
708 return Err(ConfigStoreError::Unreadable {
709 path: path.clone(),
710 source: e,
711 });
712 }
713 };
714 let loaded_bytes = state.original_raw.as_ref().map(String::as_bytes);
715 let disk_matches_loaded = match (loaded_bytes, current_bytes.as_deref()) {
716 (None, None) => true,
717 (Some(loaded), Some(current)) => loaded == current,
718 _ => false,
719 };
720 if !disk_matches_loaded {
721 return Err(ConfigStoreError::Conflict {
722 path: Some(path.clone()),
723 message: "config file changed on disk since it was loaded".to_string(),
724 });
725 }
726
727 if state.original_raw.is_none() && !state.missing_is_allowed {
728 return Err(ConfigStoreError::Missing { path });
729 }
730
731 let (patched_raw, config) = patch_config_policy(state.original_raw.as_deref(), update)
732 .map_err(|source| config_malformed(Some(&path), source))?;
733 let backup_path =
734 write_config_file_atomically(&path, patched_raw.as_bytes(), state.original_raw.is_none())?;
735 let revision = revision_token_for_raw(Some(&patched_raw));
736 let toml_allow_uncertain_quality = Some(update.allow_uncertain_quality);
737 let toml_tuning = update.tuning();
738 let tuning_sources = tuning_config_sources(&toml_tuning);
739
740 Ok(ConfigSaveResult {
741 backup_path,
742 state: LoadedConfigStore {
743 path: Some(path),
744 missing_is_allowed: state.missing_is_allowed,
745 original_raw: Some(patched_raw),
746 config,
747 revision,
748 toml_allow_uncertain_quality,
749 toml_tuning,
750 tuning_sources,
751 },
752 })
753}
754
755fn load_discovered_config(path: Option<PathBuf>) -> anyhow::Result<BhtuneConfig> {
761 load_config_store_from_resolution(ConfigPathResolution {
762 path,
763 missing_is_allowed: true,
764 })
765 .map(|store| store.config)
766 .map_err(|e| anyhow::anyhow!(e.to_string()))
767}
768
769pub fn load_config(explicit_path: Option<&Path>) -> anyhow::Result<BhtuneConfig> {
773 match explicit_path {
774 Some(path) => load_config_file(path, true),
775 None => {
776 let path = config_path_from(
777 std::env::var("XDG_CONFIG_HOME").ok().as_deref(),
778 std::env::var("HOME").ok().as_deref(),
779 std::env::var("APPDATA").ok().as_deref(),
780 cfg!(target_os = "windows"),
781 );
782 load_discovered_config(path)
783 }
784 }
785}
786
787#[cfg(test)]
788mod tests;