Skip to main content

bhtune_runtime/config/
browser.rs

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/// Path-resolution result for the TOML config store: either an explicit `--config` path or
16/// the auto-discovered default, plus whether a missing file is acceptable at that tier.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ConfigPathResolution {
19    pub path: Option<PathBuf>,
20    pub missing_is_allowed: bool,
21}
22
23/// A path-aware TOML config snapshot suitable for server-side read/modify/write flows.
24#[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    /// Raw file value, if the key was present. This distinguishes an explicit `true` from
32    /// the defaulted value when reporting configuration provenance.
33    pub toml_allow_uncertain_quality: Option<bool>,
34    /// Raw optional values from the TOML `[tuning]` table.
35    pub toml_tuning: TuningConfig,
36    /// Per-field TOML/default provenance for [`Self::toml_tuning`].
37    pub tuning_sources: TuningConfigSources,
38}
39
40/// Config-page-owned settings that can be patched in place while preserving every unrelated
41/// key and comment in the source TOML.
42#[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/// Result of safely saving a patched TOML config file.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct ConfigSaveResult {
82    pub backup_path: Option<PathBuf>,
83    pub state: LoadedConfigStore,
84}
85
86/// Typed errors for the path-aware TOML config store used by the server config page.
87#[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
160/// Resolve which path the TOML config store should use: an explicit `--config` path if one
161/// was provided, otherwise the platform-default `bhtune.toml` location from
162/// [`config_path_from`]. Explicit paths must already exist; an auto-discovered missing file
163/// is acceptable and can be created on first save.
164pub 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
279/// Load a bhtune config from `path`.
280///
281/// A missing file resolves to `Ok(BhtuneConfig::default())` when `missing_is_error` is
282/// false (the auto-discovered path may legitimately not exist yet); with an explicit
283/// `--config` path a missing file is a hard error instead. A file that exists but fails to
284/// parse as TOML is always a hard error -- a config typo should never be silently ignored.
285pub 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
308/// Patch only `allow_uncertain_quality` while preserving every unrelated key, comment, and
309/// formatting detail the source document already had.
310pub 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
321/// Patch only `retention_days` while preserving every unrelated key, comment, and formatting
322/// detail the source document already had. `None` removes the key entirely.
323pub 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
376/// Patch all five `[tuning]` values while preserving unrelated keys, comments, and formatting.
377/// A `None` value removes only that key.
378pub 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
447/// Load the path-aware TOML config store using real environment-based auto-discovery.
448pub 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
460/// Load the path-aware TOML config store using injected path-discovery inputs so every
461/// resolution branch stays unit-testable without mutating process-global environment
462/// variables.
463pub 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
679/// Safely save the two config-page-managed settings with optimistic-concurrency checks.
680///
681/// The caller must provide the revision token it last loaded. The save is rejected when that
682/// token is stale *or* the on-disk bytes no longer match the bytes this store last loaded or
683/// wrote, preventing blind overwrites of external edits.
684pub 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
755/// Load the config from an auto-discovered path, falling back to defaults when no path
756/// could be discovered at all (e.g. neither `XDG_CONFIG_HOME` nor `HOME` is set on a
757/// non-Windows host). Split out from [`load_config`] so the no-path-discovered branch is
758/// directly unit-testable with a literal `None`, without mutating real process-global
759/// environment variables in a parallel test binary.
760fn 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
769/// Resolve and load the bhtune config: an explicit `--config` path if given, otherwise the
770/// platform's auto-discovered path (silently falls back to defaults if none of the relevant
771/// environment variables are set, or if the discovered file doesn't exist).
772pub 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;