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}
104
105#[derive(Deserialize)]
106struct ConfigHeader {
107    schema_version: u32,
108}
109
110impl ConfigFile {
111    /// Load the default user config, returning a writable default when the
112    /// file does not exist yet.
113    pub fn load_or_default() -> Result<(Config, Self), ConfigError> {
114        Self::load_from_path(&paths::config_path()?)
115    }
116
117    /// Load `path`, retaining its source revision for conflict-safe saves.
118    pub fn load_from_path(path: &Path) -> Result<(Config, Self), ConfigError> {
119        match fs::read_to_string(path) {
120            Ok(source) => {
121                let config = parse_config(path, &source)?;
122                Ok((
123                    config,
124                    Self {
125                        path: path.to_path_buf(),
126                        source: Some(source),
127                    },
128                ))
129            }
130            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok((
131                Config::default(),
132                Self {
133                    path: path.to_path_buf(),
134                    source: None,
135                },
136            )),
137            Err(source) => Err(ConfigError::Read {
138                path: path.to_path_buf(),
139                source,
140            }),
141        }
142    }
143
144    /// Save `config` only if the file still matches the loaded revision.
145    pub fn save(&mut self, config: &Config) -> Result<(), ConfigError> {
146        let current = match fs::read_to_string(&self.path) {
147            Ok(source) => Some(source),
148            Err(error) if error.kind() == io::ErrorKind::NotFound => None,
149            Err(source) => {
150                return Err(ConfigError::Read {
151                    path: self.path.clone(),
152                    source,
153                });
154            }
155        };
156        if current != self.source {
157            return Err(ConfigError::Conflict {
158                path: self.path.clone(),
159            });
160        }
161
162        if let Some(parent) = self.path.parent() {
163            fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
164                path: self.path.clone(),
165                source,
166            })?;
167        }
168        let body = render_config(config, self.source.as_deref(), &self.path)?;
169        backup_config_once(&self.path).map_err(|source| ConfigError::Write {
170            path: self.path.clone(),
171            source,
172        })?;
173        write_atomic(&self.path, body.as_bytes()).map_err(|source| ConfigError::Write {
174            path: self.path.clone(),
175            source,
176        })?;
177        self.source = Some(body);
178        Ok(())
179    }
180}
181
182impl Config {
183    /// Loads the config from the default user path, returning a default when
184    /// the file does not exist yet.
185    pub fn load_or_default() -> Result<Self, ConfigError> {
186        ConfigFile::load_or_default().map(|(config, _)| config)
187    }
188
189    /// Load from `path` without retaining a writable source revision.
190    pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
191        ConfigFile::load_from_path(path).map(|(config, _)| config)
192    }
193
194    /// Atomically save to the default path. Long-lived writers should retain
195    /// and use [`ConfigFile`] so concurrent edits can be detected.
196    pub fn save_atomic(&self) -> Result<(), ConfigError> {
197        if self.ephemeral {
198            return Ok(());
199        }
200        self.save_to_path(&paths::config_path()?)
201    }
202
203    /// Atomically save to `path`, preserving comments in its current content.
204    /// Used by tests and one-shot tools; long-lived writers should use
205    /// [`ConfigFile::save`].
206    pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
207        let (_, mut file) = ConfigFile::load_from_path(path)?;
208        file.save(self)
209    }
210}
211
212fn parse_config(path: &Path, source: &str) -> Result<Config, ConfigError> {
213    let header: ConfigHeader = toml::from_str(source).map_err(|source| ConfigError::Parse {
214        path: path.to_path_buf(),
215        source: Box::new(source),
216    })?;
217    if header.schema_version == 0 || header.schema_version > SCHEMA_VERSION {
218        return Err(ConfigError::UnsupportedSchemaVersion {
219            path: path.to_path_buf(),
220            found: header.schema_version,
221        });
222    }
223    reject_obsolete_fields(path, source, header.schema_version)?;
224    let mut config: Config = toml::from_str(source).map_err(|source| ConfigError::Parse {
225        path: path.to_path_buf(),
226        source: Box::new(source),
227    })?;
228    if header.schema_version <= 3 {
229        config.migrate_owner_locked_gestures();
230    }
231    config.schema_version = SCHEMA_VERSION;
232    Ok(config)
233}
234
235fn reject_obsolete_fields(path: &Path, source: &str, version: u32) -> Result<(), ConfigError> {
236    let value: toml::Value = toml::from_str(source).map_err(|source| ConfigError::Parse {
237        path: path.to_path_buf(),
238        source: Box::new(source),
239    })?;
240    let Some(devices) = value.get("devices").and_then(toml::Value::as_table) else {
241        return Ok(());
242    };
243    for (device_key, value) in devices {
244        let Some(device) = value.as_table() else {
245            continue;
246        };
247        for (field, last_version) in [
248            ("button_bindings", 1),
249            ("gesture_bindings", 1),
250            ("gesture_owner", 3),
251        ] {
252            if version > last_version && device.contains_key(field) {
253                return Err(ConfigError::ObsoleteField {
254                    path: path.to_path_buf(),
255                    field: format!("devices.{device_key}.{field}"),
256                    version,
257                });
258            }
259        }
260    }
261    Ok(())
262}
263
264fn render_config(
265    config: &Config,
266    original: Option<&str>,
267    path: &Path,
268) -> Result<String, ConfigError> {
269    let generated = toml::to_string_pretty(config)?;
270    let Some(original) = original else {
271        return Ok(generated);
272    };
273    let mut document = original
274        .parse::<DocumentMut>()
275        .map_err(|source| ConfigError::Edit {
276            path: path.to_path_buf(),
277            source: Box::new(source),
278        })?;
279    let generated = generated
280        .parse::<DocumentMut>()
281        .map_err(|source| ConfigError::Edit {
282            path: path.to_path_buf(),
283            source: Box::new(source),
284        })?;
285    reconcile_table(document.as_table_mut(), generated.as_table());
286    Ok(document.to_string())
287}
288
289fn reconcile_table(current: &mut Table, generated: &Table) {
290    let stale: Vec<String> = current
291        .iter()
292        .filter(|(key, _)| generated.get(key).is_none())
293        .map(|(key, _)| key.to_string())
294        .collect();
295    for key in stale {
296        current.remove(&key);
297    }
298    for (key, generated_item) in generated {
299        if let Some(current_item) = current.get_mut(key) {
300            reconcile_item(current_item, generated_item);
301        } else {
302            current.insert(key, generated_item.clone());
303        }
304    }
305}
306
307fn reconcile_item(current: &mut Item, generated: &Item) {
308    if let (Some(current), Some(generated)) = (current.as_table_mut(), generated.as_table()) {
309        reconcile_table(current, generated);
310        return;
311    }
312    let decor = current.as_value().map(|value| value.decor().clone());
313    *current = generated.clone();
314    if let (Some(decor), Some(value)) = (decor, current.as_value_mut()) {
315        *value.decor_mut() = decor;
316    }
317}
318
319fn backup_config_once(path: &Path) -> io::Result<()> {
320    let mut backed_up = BACKED_UP_CONFIGS
321        .lock()
322        .unwrap_or_else(PoisonError::into_inner);
323    if backed_up.contains(path) {
324        return Ok(());
325    }
326    match fs::metadata(path) {
327        Ok(_) => backup_existing_config(path)?,
328        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
329        Err(error) => return Err(error),
330    }
331    backed_up.insert(path.to_path_buf());
332    Ok(())
333}
334
335pub(super) fn backup_existing_config(path: &Path) -> io::Result<()> {
336    for generation in (1..CONFIG_BACKUP_GENERATIONS).rev() {
337        let source = config_backup_path(path, generation)?;
338        match fs::read(&source) {
339            Ok(bytes) => write_atomic(&config_backup_path(path, generation + 1)?, &bytes)?,
340            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
341            Err(error) => return Err(error),
342        }
343    }
344    write_atomic(&config_backup_path(path, 1)?, &fs::read(path)?)
345}
346
347pub(super) fn config_backup_path(path: &Path, generation: usize) -> io::Result<PathBuf> {
348    let Some(file_name) = path.file_name() else {
349        return Err(io::Error::new(
350            io::ErrorKind::InvalidInput,
351            "config path has no file name",
352        ));
353    };
354    let mut backup_name = OsString::from(file_name);
355    backup_name.push(format!(".backup.{generation}"));
356    Ok(path.with_file_name(backup_name))
357}
358
359fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
360    #[cfg_attr(
361        not(unix),
362        expect(unused_mut, reason = "only the unix path mutates the options")
363    )]
364    let mut options = AtomicWriteFile::options();
365    #[cfg(unix)]
366    {
367        use atomic_write_file::unix::OpenOptionsExt as _;
368        use std::os::unix::fs::OpenOptionsExt as _;
369        options.preserve_mode(false).mode(0o600);
370    }
371    let mut file = options.open(path)?;
372    io::Write::write_all(&mut file, bytes)?;
373    file.commit()
374}