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    config.schema_version = SCHEMA_VERSION;
277    Ok((config, header.schema_version))
278}
279
280fn reject_obsolete_fields(path: &Path, source: &str, version: u32) -> Result<(), ConfigError> {
281    let value: toml::Value = toml::from_str(source).map_err(|source| ConfigError::Parse {
282        path: path.to_path_buf(),
283        source: Box::new(source),
284    })?;
285    let Some(devices) = value.get("devices").and_then(toml::Value::as_table) else {
286        return Ok(());
287    };
288    for (device_key, value) in devices {
289        let Some(device) = value.as_table() else {
290            continue;
291        };
292        for (field, last_version) in [
293            ("button_bindings", 1),
294            ("gesture_bindings", 1),
295            ("gesture_owner", 3),
296        ] {
297            if version > last_version && device.contains_key(field) {
298                return Err(ConfigError::ObsoleteField {
299                    path: path.to_path_buf(),
300                    field: format!("devices.{device_key}.{field}"),
301                    version,
302                });
303            }
304        }
305    }
306    Ok(())
307}
308
309fn render_config(
310    config: &Config,
311    original: Option<&str>,
312    path: &Path,
313) -> Result<String, ConfigError> {
314    let generated = toml::to_string_pretty(config)?;
315    let Some(original) = original else {
316        return Ok(generated);
317    };
318    let mut document = original
319        .parse::<DocumentMut>()
320        .map_err(|source| ConfigError::Edit {
321            path: path.to_path_buf(),
322            source: Box::new(source),
323        })?;
324    let generated = generated
325        .parse::<DocumentMut>()
326        .map_err(|source| ConfigError::Edit {
327            path: path.to_path_buf(),
328            source: Box::new(source),
329        })?;
330    reconcile_table(document.as_table_mut(), generated.as_table());
331    Ok(document.to_string())
332}
333
334fn reconcile_table(current: &mut Table, generated: &Table) {
335    let stale: Vec<String> = current
336        .iter()
337        .filter(|(key, _)| generated.get(key).is_none())
338        .map(|(key, _)| key.to_string())
339        .collect();
340    for key in stale {
341        current.remove(&key);
342    }
343    for (key, generated_item) in generated {
344        if let Some(current_item) = current.get_mut(key) {
345            reconcile_item(current_item, generated_item);
346        } else {
347            current.insert(key, generated_item.clone());
348        }
349    }
350}
351
352fn reconcile_item(current: &mut Item, generated: &Item) {
353    if let (Some(current), Some(generated)) = (current.as_table_mut(), generated.as_table()) {
354        reconcile_table(current, generated);
355        return;
356    }
357    let decor = current.as_value().map(|value| value.decor().clone());
358    *current = generated.clone();
359    if let (Some(decor), Some(value)) = (decor, current.as_value_mut()) {
360        *value.decor_mut() = decor;
361    }
362}
363
364fn backup_config_once(path: &Path) -> io::Result<()> {
365    let mut backed_up = BACKED_UP_CONFIGS
366        .lock()
367        .unwrap_or_else(PoisonError::into_inner);
368    if backed_up.contains(path) {
369        return Ok(());
370    }
371    match fs::metadata(path) {
372        Ok(_) => backup_existing_config(path)?,
373        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
374        Err(error) => return Err(error),
375    }
376    backed_up.insert(path.to_path_buf());
377    Ok(())
378}
379
380pub(super) fn backup_existing_config(path: &Path) -> io::Result<()> {
381    for generation in (1..CONFIG_BACKUP_GENERATIONS).rev() {
382        let source = config_backup_path(path, generation)?;
383        match fs::read(&source) {
384            Ok(bytes) => write_atomic(&config_backup_path(path, generation + 1)?, &bytes)?,
385            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
386            Err(error) => return Err(error),
387        }
388    }
389    write_atomic(&config_backup_path(path, 1)?, &fs::read(path)?)
390}
391
392/// Path of the pre-migration copy: the config's own name with
393/// `.v<version>.bak` appended, so `config.toml` yields
394/// `config.toml.v4.bak`. Appended, not substituted — `with_extension` would
395/// replace `.toml` and hand back `config.v4.bak`, which no longer names the
396/// file it is a copy of.
397pub(super) fn migration_backup_path(path: &Path, version: u32) -> io::Result<PathBuf> {
398    let Some(file_name) = path.file_name() else {
399        return Err(io::Error::new(
400            io::ErrorKind::InvalidInput,
401            "config path has no file name",
402        ));
403    };
404    let mut backup_name = OsString::from(file_name);
405    backup_name.push(format!(".v{version}.bak"));
406    Ok(path.with_file_name(backup_name))
407}
408
409pub(super) fn config_backup_path(path: &Path, generation: usize) -> io::Result<PathBuf> {
410    let Some(file_name) = path.file_name() else {
411        return Err(io::Error::new(
412            io::ErrorKind::InvalidInput,
413            "config path has no file name",
414        ));
415    };
416    let mut backup_name = OsString::from(file_name);
417    backup_name.push(format!(".backup.{generation}"));
418    Ok(path.with_file_name(backup_name))
419}
420
421fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
422    #[cfg_attr(
423        not(unix),
424        expect(unused_mut, reason = "only the unix path mutates the options")
425    )]
426    let mut options = AtomicWriteFile::options();
427    #[cfg(unix)]
428    {
429        use atomic_write_file::unix::OpenOptionsExt as _;
430        use std::os::unix::fs::OpenOptionsExt as _;
431        options.preserve_mode(false).mode(0o600);
432    }
433    let mut file = options.open(path)?;
434    io::Write::write_all(&mut file, bytes)?;
435    file.commit()
436}