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::{Mutex, OnceLock, 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: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
21
22/// Failure loading or persisting `config.toml`.
23#[derive(Debug, Error)]
24pub enum ConfigError {
25    /// The platform config directory could not be resolved.
26    #[error("could not resolve config path: {0}")]
27    Path(#[from] PathsError),
28    /// Reading the config file from disk failed.
29    #[error("could not read config at {path}: {source}")]
30    Read {
31        /// The config file the read targeted.
32        path: PathBuf,
33        /// The underlying I/O error.
34        #[source]
35        source: io::Error,
36    },
37    /// The file is not valid TOML for its declared schema.
38    #[error("could not parse config at {path}: {source}")]
39    Parse {
40        /// The config file that failed to parse.
41        path: PathBuf,
42        /// The underlying TOML deserialization error, including line/column.
43        #[source]
44        source: Box<toml::de::Error>,
45    },
46    /// A field from an older schema was used with a version where it is invalid.
47    #[error("config at {path} uses obsolete field {field} with schema_version {version}")]
48    ObsoleteField {
49        /// The config file containing the field.
50        path: PathBuf,
51        /// Dotted path to the obsolete field.
52        field: String,
53        /// Schema version declared by the file.
54        version: u32,
55    },
56    /// The file changed after it was loaded, so overwriting it would lose edits.
57    #[error("config at {path} changed on disk; restart OpenLogi to reload it")]
58    Conflict {
59        /// The concurrently modified config file.
60        path: PathBuf,
61    },
62    /// Writing the updated config back to disk failed.
63    #[error("could not write config at {path}: {source}")]
64    Write {
65        /// The config file the write targeted.
66        path: PathBuf,
67        /// The underlying I/O error.
68        #[source]
69        source: io::Error,
70    },
71    /// The in-memory config could not be serialized to TOML.
72    #[error("could not serialize config: {0}")]
73    Serialize(#[from] toml::ser::Error),
74    /// A generated or previously parsed TOML document could not be edited.
75    #[error("could not preserve config formatting at {path}: {source}")]
76    Edit {
77        /// The config file whose document was being updated.
78        path: PathBuf,
79        /// The TOML editing parser error.
80        #[source]
81        source: Box<toml_edit::TomlError>,
82    },
83    /// The file declares a schema outside the supported version range.
84    #[error("config at {path} has unsupported schema_version {found}")]
85    UnsupportedSchemaVersion {
86        /// The config file carrying the unsupported version.
87        path: PathBuf,
88        /// The schema version the file declared.
89        found: u32,
90    },
91}
92
93/// A loaded config file plus the exact source revision it came from.
94///
95/// Saving compares that source with the current file before writing, so an
96/// editor or another process cannot be overwritten by a stale GUI snapshot.
97/// Existing comments and formatting are retained for keys that still exist.
98#[derive(Debug, Clone)]
99pub struct ConfigFile {
100    path: PathBuf,
101    source: Option<String>,
102}
103
104#[derive(Deserialize)]
105struct ConfigHeader {
106    schema_version: u32,
107}
108
109impl ConfigFile {
110    /// Load the default user config, returning a writable default when the
111    /// file does not exist yet.
112    pub fn load_or_default() -> Result<(Config, Self), ConfigError> {
113        Self::load_from_path(&paths::config_path()?)
114    }
115
116    /// Load `path`, retaining its source revision for conflict-safe saves.
117    pub fn load_from_path(path: &Path) -> Result<(Config, Self), ConfigError> {
118        match fs::read_to_string(path) {
119            Ok(source) => {
120                let config = parse_config(path, &source)?;
121                Ok((
122                    config,
123                    Self {
124                        path: path.to_path_buf(),
125                        source: Some(source),
126                    },
127                ))
128            }
129            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok((
130                Config::default(),
131                Self {
132                    path: path.to_path_buf(),
133                    source: None,
134                },
135            )),
136            Err(source) => Err(ConfigError::Read {
137                path: path.to_path_buf(),
138                source,
139            }),
140        }
141    }
142
143    /// Save `config` only if the file still matches the loaded revision.
144    pub fn save(&mut self, config: &Config) -> Result<(), ConfigError> {
145        let current = match fs::read_to_string(&self.path) {
146            Ok(source) => Some(source),
147            Err(error) if error.kind() == io::ErrorKind::NotFound => None,
148            Err(source) => {
149                return Err(ConfigError::Read {
150                    path: self.path.clone(),
151                    source,
152                });
153            }
154        };
155        if current != self.source {
156            return Err(ConfigError::Conflict {
157                path: self.path.clone(),
158            });
159        }
160
161        if let Some(parent) = self.path.parent() {
162            fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
163                path: self.path.clone(),
164                source,
165            })?;
166        }
167        let body = render_config(config, self.source.as_deref(), &self.path)?;
168        backup_config_once(&self.path).map_err(|source| ConfigError::Write {
169            path: self.path.clone(),
170            source,
171        })?;
172        write_atomic(&self.path, body.as_bytes()).map_err(|source| ConfigError::Write {
173            path: self.path.clone(),
174            source,
175        })?;
176        self.source = Some(body);
177        Ok(())
178    }
179}
180
181impl Config {
182    /// Loads the config from the default user path, returning a default when
183    /// the file does not exist yet.
184    pub fn load_or_default() -> Result<Self, ConfigError> {
185        ConfigFile::load_or_default().map(|(config, _)| config)
186    }
187
188    /// Load from `path` without retaining a writable source revision.
189    pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
190        ConfigFile::load_from_path(path).map(|(config, _)| config)
191    }
192
193    /// Atomically save to the default path. Long-lived writers should retain
194    /// and use [`ConfigFile`] so concurrent edits can be detected.
195    pub fn save_atomic(&self) -> Result<(), ConfigError> {
196        if self.ephemeral {
197            return Ok(());
198        }
199        self.save_to_path(&paths::config_path()?)
200    }
201
202    /// Atomically save to `path`, preserving comments in its current content.
203    /// Used by tests and one-shot tools; long-lived writers should use
204    /// [`ConfigFile::save`].
205    pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
206        let (_, mut file) = ConfigFile::load_from_path(path)?;
207        file.save(self)
208    }
209}
210
211fn parse_config(path: &Path, source: &str) -> Result<Config, ConfigError> {
212    let header: ConfigHeader = toml::from_str(source).map_err(|source| ConfigError::Parse {
213        path: path.to_path_buf(),
214        source: Box::new(source),
215    })?;
216    if header.schema_version == 0 || header.schema_version > SCHEMA_VERSION {
217        return Err(ConfigError::UnsupportedSchemaVersion {
218            path: path.to_path_buf(),
219            found: header.schema_version,
220        });
221    }
222    reject_obsolete_fields(path, source, header.schema_version)?;
223    let mut config: Config = toml::from_str(source).map_err(|source| ConfigError::Parse {
224        path: path.to_path_buf(),
225        source: Box::new(source),
226    })?;
227    if header.schema_version <= 3 {
228        config.migrate_owner_locked_gestures();
229    }
230    config.schema_version = SCHEMA_VERSION;
231    Ok(config)
232}
233
234fn reject_obsolete_fields(path: &Path, source: &str, version: u32) -> Result<(), ConfigError> {
235    let value: toml::Value = toml::from_str(source).map_err(|source| ConfigError::Parse {
236        path: path.to_path_buf(),
237        source: Box::new(source),
238    })?;
239    let Some(devices) = value.get("devices").and_then(toml::Value::as_table) else {
240        return Ok(());
241    };
242    for (device_key, value) in devices {
243        let Some(device) = value.as_table() else {
244            continue;
245        };
246        for (field, last_version) in [
247            ("button_bindings", 1),
248            ("gesture_bindings", 1),
249            ("gesture_owner", 3),
250        ] {
251            if version > last_version && device.contains_key(field) {
252                return Err(ConfigError::ObsoleteField {
253                    path: path.to_path_buf(),
254                    field: format!("devices.{device_key}.{field}"),
255                    version,
256                });
257            }
258        }
259    }
260    Ok(())
261}
262
263fn render_config(
264    config: &Config,
265    original: Option<&str>,
266    path: &Path,
267) -> Result<String, ConfigError> {
268    let generated = toml::to_string_pretty(config)?;
269    let Some(original) = original else {
270        return Ok(generated);
271    };
272    let mut document = original
273        .parse::<DocumentMut>()
274        .map_err(|source| ConfigError::Edit {
275            path: path.to_path_buf(),
276            source: Box::new(source),
277        })?;
278    let generated = generated
279        .parse::<DocumentMut>()
280        .map_err(|source| ConfigError::Edit {
281            path: path.to_path_buf(),
282            source: Box::new(source),
283        })?;
284    reconcile_table(document.as_table_mut(), generated.as_table());
285    Ok(document.to_string())
286}
287
288fn reconcile_table(current: &mut Table, generated: &Table) {
289    let stale: Vec<String> = current
290        .iter()
291        .filter(|(key, _)| generated.get(key).is_none())
292        .map(|(key, _)| key.to_string())
293        .collect();
294    for key in stale {
295        current.remove(&key);
296    }
297    for (key, generated_item) in generated {
298        if let Some(current_item) = current.get_mut(key) {
299            reconcile_item(current_item, generated_item);
300        } else {
301            current.insert(key, generated_item.clone());
302        }
303    }
304}
305
306fn reconcile_item(current: &mut Item, generated: &Item) {
307    if let (Some(current), Some(generated)) = (current.as_table_mut(), generated.as_table()) {
308        reconcile_table(current, generated);
309        return;
310    }
311    let decor = current.as_value().map(|value| value.decor().clone());
312    *current = generated.clone();
313    if let (Some(decor), Some(value)) = (decor, current.as_value_mut()) {
314        *value.decor_mut() = decor;
315    }
316}
317
318fn backup_config_once(path: &Path) -> io::Result<()> {
319    let backed_up = BACKED_UP_CONFIGS.get_or_init(|| Mutex::new(HashSet::new()));
320    let mut backed_up = backed_up.lock().unwrap_or_else(PoisonError::into_inner);
321    if backed_up.contains(path) {
322        return Ok(());
323    }
324    match fs::metadata(path) {
325        Ok(_) => backup_existing_config(path)?,
326        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
327        Err(error) => return Err(error),
328    }
329    backed_up.insert(path.to_path_buf());
330    Ok(())
331}
332
333pub(super) fn backup_existing_config(path: &Path) -> io::Result<()> {
334    for generation in (1..CONFIG_BACKUP_GENERATIONS).rev() {
335        let source = config_backup_path(path, generation)?;
336        match fs::read(&source) {
337            Ok(bytes) => write_atomic(&config_backup_path(path, generation + 1)?, &bytes)?,
338            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
339            Err(error) => return Err(error),
340        }
341    }
342    write_atomic(&config_backup_path(path, 1)?, &fs::read(path)?)
343}
344
345pub(super) fn config_backup_path(path: &Path, generation: usize) -> io::Result<PathBuf> {
346    let Some(file_name) = path.file_name() else {
347        return Err(io::Error::new(
348            io::ErrorKind::InvalidInput,
349            "config path has no file name",
350        ));
351    };
352    let mut backup_name = OsString::from(file_name);
353    backup_name.push(format!(".backup.{generation}"));
354    Ok(path.with_file_name(backup_name))
355}
356
357fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
358    #[cfg_attr(
359        not(unix),
360        expect(unused_mut, reason = "only the unix path mutates the options")
361    )]
362    let mut options = AtomicWriteFile::options();
363    #[cfg(unix)]
364    {
365        use atomic_write_file::unix::OpenOptionsExt as _;
366        use std::os::unix::fs::OpenOptionsExt as _;
367        options.preserve_mode(false).mode(0o600);
368    }
369    let mut file = options.open(path)?;
370    io::Write::write_all(&mut file, bytes)?;
371    file.commit()
372}