Skip to main content

openlogi_core/config/
file.rs

1//! Version-aware configuration loading and conflict-safe persistence.
2
3use std::{
4    collections::HashSet,
5    ffi::OsString,
6    fs, io,
7    path::{Path, PathBuf},
8    sync::{LazyLock, Mutex, PoisonError},
9};
10
11use atomic_write_file::AtomicWriteFile;
12use serde::Deserialize;
13use thiserror::Error;
14use toml_edit::{DocumentMut, Item, Table};
15
16use super::{Config, SCHEMA_VERSION};
17use crate::paths::{self, PathsError};
18
19const CONFIG_BACKUP_GENERATIONS: usize = 5;
20static BACKED_UP_CONFIGS: LazyLock<Mutex<HashSet<PathBuf>>> =
21    LazyLock::new(|| Mutex::new(HashSet::new()));
22
23/// Failure loading or persisting `config.toml`.
24#[derive(Debug, Error)]
25pub enum ConfigError {
26    /// The platform config directory could not be resolved.
27    #[error("could not resolve config path: {0}")]
28    Path(#[from] PathsError),
29    /// Reading the config file from disk failed.
30    #[error("could not read config at {path}: {source}")]
31    Read {
32        /// The config file the read targeted.
33        path: PathBuf,
34        /// The underlying I/O error.
35        #[source]
36        source: io::Error,
37    },
38    /// The file is not valid TOML for its declared schema.
39    #[error("could not parse config at {path}: {source}")]
40    Parse {
41        /// The config file that failed to parse.
42        path: PathBuf,
43        /// The underlying TOML deserialization error, including line/column.
44        #[source]
45        source: Box<toml::de::Error>,
46    },
47    /// A field from an older schema was used with a version where it is invalid.
48    #[error("config at {path} uses obsolete field {field} with schema_version {version}")]
49    ObsoleteField {
50        /// The config file containing the field.
51        path: PathBuf,
52        /// Dotted path to the obsolete field.
53        field: String,
54        /// Schema version declared by the file.
55        version: u32,
56    },
57    /// The file changed after it was loaded, so overwriting it would lose edits.
58    #[error("config at {path} changed on disk; restart OpenLogi to reload it")]
59    Conflict {
60        /// The concurrently modified config file.
61        path: PathBuf,
62    },
63    /// Writing the updated config back to disk failed.
64    #[error("could not write config at {path}: {source}")]
65    Write {
66        /// The config file the write targeted.
67        path: PathBuf,
68        /// The underlying I/O error.
69        #[source]
70        source: io::Error,
71    },
72    /// The in-memory config could not be serialized to TOML.
73    #[error("could not serialize config: {0}")]
74    Serialize(#[from] toml::ser::Error),
75    /// A generated or previously parsed TOML document could not be edited.
76    #[error("could not preserve config formatting at {path}: {source}")]
77    Edit {
78        /// The config file whose document was being updated.
79        path: PathBuf,
80        /// The TOML editing parser error.
81        #[source]
82        source: Box<toml_edit::TomlError>,
83    },
84    /// The file declares a schema outside the supported version range.
85    #[error("config at {path} has unsupported schema_version {found}")]
86    UnsupportedSchemaVersion {
87        /// The config file carrying the unsupported version.
88        path: PathBuf,
89        /// The schema version the file declared.
90        found: u32,
91    },
92}
93
94/// A loaded config file plus the exact source revision it came from.
95///
96/// Saving compares that source with the current file before writing, so an
97/// editor or another process cannot be overwritten by a stale GUI snapshot.
98/// Existing comments and formatting are retained for keys that still exist.
99#[derive(Debug, Clone)]
100pub struct ConfigFile {
101    path: PathBuf,
102    source: Option<String>,
103    /// Text of the file as loaded, when it was written by an older schema.
104    /// Copied aside on the first save so a key-rewriting migration is
105    /// recoverable; cleared once that save lands, so only the first
106    /// *successful* one pays for it and a failed save still owes the copy.
107    migrated_from: Option<(u32, String)>,
108}
109
110#[derive(Deserialize)]
111struct ConfigHeader {
112    schema_version: u32,
113}
114
115impl ConfigFile {
116    /// Load the default user config, returning a writable default when the
117    /// file does not exist yet.
118    pub fn load_or_default() -> Result<(Config, Self), ConfigError> {
119        Self::load_from_path(&paths::config_path()?)
120    }
121
122    /// Load `path`, retaining its source revision for conflict-safe saves.
123    pub fn load_from_path(path: &Path) -> Result<(Config, Self), ConfigError> {
124        match fs::read_to_string(path) {
125            Ok(source) => {
126                let (config, loaded_version) = parse_config(path, &source)?;
127                let migrated_from =
128                    (loaded_version < SCHEMA_VERSION).then(|| (loaded_version, source.clone()));
129                Ok((
130                    config,
131                    Self {
132                        path: path.to_path_buf(),
133                        source: Some(source),
134                        migrated_from,
135                    },
136                ))
137            }
138            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok((
139                Config::default(),
140                Self {
141                    path: path.to_path_buf(),
142                    source: None,
143                    migrated_from: None,
144                },
145            )),
146            Err(source) => Err(ConfigError::Read {
147                path: path.to_path_buf(),
148                source,
149            }),
150        }
151    }
152
153    /// Save `config` only if the file still matches the loaded revision.
154    pub fn save(&mut self, config: &Config) -> Result<(), ConfigError> {
155        let current = match fs::read_to_string(&self.path) {
156            Ok(source) => Some(source),
157            Err(error) if error.kind() == io::ErrorKind::NotFound => None,
158            Err(source) => {
159                return Err(ConfigError::Read {
160                    path: self.path.clone(),
161                    source,
162                });
163            }
164        };
165        if current != self.source {
166            return Err(ConfigError::Conflict {
167                path: self.path.clone(),
168            });
169        }
170
171        // Borrowed, not taken: a failed write must leave the recovery copy
172        // still owed. Taking here and then failing — a full disk, a
173        // read-only directory — would spend the only chance to keep the
174        // pre-migration file, and the next save that *did* succeed would
175        // overwrite it with migrated content and no backup anywhere.
176        if let Some((version, original)) = self.migrated_from.as_ref() {
177            let backup = migration_backup_path(&self.path, *version).map_err(|source| {
178                ConfigError::Write {
179                    path: self.path.clone(),
180                    source,
181                }
182            })?;
183            // Atomic like the config write itself: this is the only copy of
184            // the pre-migration file, so an interrupted write must not be
185            // able to leave a truncated one behind.
186            write_atomic(&backup, original.as_bytes()).map_err(|source| ConfigError::Write {
187                path: backup,
188                source,
189            })?;
190        }
191
192        if let Some(parent) = self.path.parent() {
193            fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
194                path: self.path.clone(),
195                source,
196            })?;
197        }
198        let body = render_config(config, self.source.as_deref(), &self.path)?;
199        backup_config_once(&self.path).map_err(|source| ConfigError::Write {
200            path: self.path.clone(),
201            source,
202        })?;
203        write_atomic(&self.path, body.as_bytes()).map_err(|source| ConfigError::Write {
204            path: self.path.clone(),
205            source,
206        })?;
207        // The migrated file is gone from disk now, and its copy is safely
208        // beside it — only here is the debt actually settled, so only here is
209        // it cleared. A second save from this `ConfigFile` must not rewrite
210        // the backup with content that is no longer pre-migration.
211        self.migrated_from = None;
212        self.source = Some(body);
213        Ok(())
214    }
215}
216
217impl Config {
218    /// Loads the config from the default user path, returning a default when
219    /// the file does not exist yet.
220    pub fn load_or_default() -> Result<Self, ConfigError> {
221        ConfigFile::load_or_default().map(|(config, _)| config)
222    }
223
224    /// Load from `path` without retaining a writable source revision.
225    pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
226        ConfigFile::load_from_path(path).map(|(config, _)| config)
227    }
228
229    /// Atomically save to the default path. Long-lived writers should retain
230    /// and use [`ConfigFile`] so concurrent edits can be detected.
231    pub fn save_atomic(&self) -> Result<(), ConfigError> {
232        if self.ephemeral {
233            return Ok(());
234        }
235        self.save_to_path(&paths::config_path()?)
236    }
237
238    /// Atomically save to `path`, preserving comments in its current content.
239    /// Used by tests and one-shot tools; long-lived writers should use
240    /// [`ConfigFile::save`].
241    pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
242        let (_, mut file) = ConfigFile::load_from_path(path)?;
243        file.save(self)
244    }
245}
246
247/// Parse `source`, applying every migration its declared `schema_version`
248/// needs. Returns the migrated config plus the version the file actually
249/// declared, so callers can tell a migrated load from an already-current one.
250fn parse_config(path: &Path, source: &str) -> Result<(Config, u32), ConfigError> {
251    let header: ConfigHeader = toml::from_str(source).map_err(|source| ConfigError::Parse {
252        path: path.to_path_buf(),
253        source: Box::new(source),
254    })?;
255    if header.schema_version == 0 || header.schema_version > SCHEMA_VERSION {
256        return Err(ConfigError::UnsupportedSchemaVersion {
257            path: path.to_path_buf(),
258            found: header.schema_version,
259        });
260    }
261    reject_obsolete_fields(path, source, header.schema_version)?;
262    let mut config: Config = toml::from_str(source).map_err(|source| ConfigError::Parse {
263        path: path.to_path_buf(),
264        source: Box::new(source),
265    })?;
266    if header.schema_version <= 3 {
267        config.migrate_owner_locked_gestures();
268    }
269    // v5 is the schema this change is part of, so every *released* schema —
270    // v4 and below — is what needs the rename. A file already declaring v5 was
271    // written by an unpublished build of this branch and is not something users
272    // have on disk.
273    if header.schema_version <= 4 {
274        config.migrate_transport_scoped_keys();
275    }
276    // Every released schema may contain an explicit copy of the pre-v7
277    // thumb-wheel defaults, either globally or in a per-app profile.
278    if header.schema_version <= 6 {
279        config.migrate_thumbwheel_native_direction();
280    }
281    config.repair_duplicate_routes();
282    config.schema_version = SCHEMA_VERSION;
283    Ok((config, header.schema_version))
284}
285
286fn reject_obsolete_fields(path: &Path, source: &str, version: u32) -> Result<(), ConfigError> {
287    let value: toml::Value = toml::from_str(source).map_err(|source| ConfigError::Parse {
288        path: path.to_path_buf(),
289        source: Box::new(source),
290    })?;
291    let Some(devices) = value.get("devices").and_then(toml::Value::as_table) else {
292        return Ok(());
293    };
294    for (device_key, value) in devices {
295        let Some(device) = value.as_table() else {
296            continue;
297        };
298        for (field, last_version) in [
299            ("button_bindings", 1),
300            ("gesture_bindings", 1),
301            ("gesture_owner", 3),
302        ] {
303            if version > last_version && device.contains_key(field) {
304                return Err(ConfigError::ObsoleteField {
305                    path: path.to_path_buf(),
306                    field: format!("devices.{device_key}.{field}"),
307                    version,
308                });
309            }
310        }
311    }
312    Ok(())
313}
314
315fn render_config(
316    config: &Config,
317    original: Option<&str>,
318    path: &Path,
319) -> Result<String, ConfigError> {
320    let generated = toml::to_string_pretty(config)?;
321    let Some(original) = original else {
322        return Ok(generated);
323    };
324    let mut document = original
325        .parse::<DocumentMut>()
326        .map_err(|source| ConfigError::Edit {
327            path: path.to_path_buf(),
328            source: Box::new(source),
329        })?;
330    let generated = generated
331        .parse::<DocumentMut>()
332        .map_err(|source| ConfigError::Edit {
333            path: path.to_path_buf(),
334            source: Box::new(source),
335        })?;
336    reconcile_table(document.as_table_mut(), generated.as_table());
337    Ok(document.to_string())
338}
339
340fn reconcile_table(current: &mut Table, generated: &Table) {
341    let stale: Vec<String> = current
342        .iter()
343        .filter(|(key, _)| generated.get(key).is_none())
344        .map(|(key, _)| key.to_string())
345        .collect();
346    for key in stale {
347        current.remove(&key);
348    }
349    for (key, generated_item) in generated {
350        if let Some(current_item) = current.get_mut(key) {
351            reconcile_item(current_item, generated_item);
352        } else {
353            current.insert(key, generated_item.clone());
354        }
355    }
356}
357
358fn reconcile_item(current: &mut Item, generated: &Item) {
359    if let (Some(current), Some(generated)) = (current.as_table_mut(), generated.as_table()) {
360        reconcile_table(current, generated);
361        return;
362    }
363    let decor = current.as_value().map(|value| value.decor().clone());
364    *current = generated.clone();
365    if let (Some(decor), Some(value)) = (decor, current.as_value_mut()) {
366        *value.decor_mut() = decor;
367    }
368}
369
370fn backup_config_once(path: &Path) -> io::Result<()> {
371    let mut backed_up = BACKED_UP_CONFIGS
372        .lock()
373        .unwrap_or_else(PoisonError::into_inner);
374    if backed_up.contains(path) {
375        return Ok(());
376    }
377    match fs::metadata(path) {
378        Ok(_) => backup_existing_config(path)?,
379        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
380        Err(error) => return Err(error),
381    }
382    backed_up.insert(path.to_path_buf());
383    Ok(())
384}
385
386pub(super) fn backup_existing_config(path: &Path) -> io::Result<()> {
387    for generation in (1..CONFIG_BACKUP_GENERATIONS).rev() {
388        let source = config_backup_path(path, generation)?;
389        match fs::read(&source) {
390            Ok(bytes) => write_atomic(&config_backup_path(path, generation + 1)?, &bytes)?,
391            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
392            Err(error) => return Err(error),
393        }
394    }
395    write_atomic(&config_backup_path(path, 1)?, &fs::read(path)?)
396}
397
398/// Path of the pre-migration copy: the config's own name with
399/// `.v<version>.bak` appended, so `config.toml` yields
400/// `config.toml.v4.bak`. Appended, not substituted — `with_extension` would
401/// replace `.toml` and hand back `config.v4.bak`, which no longer names the
402/// file it is a copy of.
403pub(super) fn migration_backup_path(path: &Path, version: u32) -> io::Result<PathBuf> {
404    let Some(file_name) = path.file_name() else {
405        return Err(io::Error::new(
406            io::ErrorKind::InvalidInput,
407            "config path has no file name",
408        ));
409    };
410    let mut backup_name = OsString::from(file_name);
411    backup_name.push(format!(".v{version}.bak"));
412    Ok(path.with_file_name(backup_name))
413}
414
415pub(super) fn config_backup_path(path: &Path, generation: usize) -> io::Result<PathBuf> {
416    let Some(file_name) = path.file_name() else {
417        return Err(io::Error::new(
418            io::ErrorKind::InvalidInput,
419            "config path has no file name",
420        ));
421    };
422    let mut backup_name = OsString::from(file_name);
423    backup_name.push(format!(".backup.{generation}"));
424    Ok(path.with_file_name(backup_name))
425}
426
427fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
428    #[cfg_attr(
429        not(unix),
430        expect(unused_mut, reason = "only the unix path mutates the options")
431    )]
432    let mut options = AtomicWriteFile::options();
433    #[cfg(unix)]
434    {
435        use atomic_write_file::unix::OpenOptionsExt as _;
436        use std::os::unix::fs::OpenOptionsExt as _;
437        options.preserve_mode(false).mode(0o600);
438    }
439    let mut file = options.open(path)?;
440    io::Write::write_all(&mut file, bytes)?;
441    file.commit()
442}