Skip to main content

cranpose_services/preferences/
file.rs

1#[cfg(unix)]
2use std::fs::File;
3use std::{
4    collections::BTreeMap,
5    fs,
6    io::Write,
7    path::{Path, PathBuf},
8    sync::{Arc, Mutex, OnceLock, Weak},
9};
10
11use super::{PreferencesError, PreferencesStore, encode, parse};
12use crate::host::application_directories;
13
14/// File-backed preferences with coordinated, atomic writes within this process.
15///
16/// Instances opening the same canonical path share their values and writer.
17/// Reads include pending changes after a failed write; [`PreferencesStore::flush`]
18/// retries them. A successful write synchronizes the file before replacing it.
19/// Concurrent access from other processes is not supported.
20pub struct FilePreferences {
21    path: Option<PathBuf>,
22    file: OnceLock<Arc<Mutex<FileState>>>,
23}
24
25struct FileState {
26    path: PathBuf,
27    entries: BTreeMap<String, String>,
28    dirty: bool,
29}
30
31type Files = BTreeMap<PathBuf, Weak<Mutex<FileState>>>;
32
33fn io(error: std::io::Error) -> PreferencesError {
34    PreferencesError::Io(error.to_string())
35}
36
37impl Default for FilePreferences {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl FilePreferences {
44    /// Lazily opens `preferences` in the application's config directory.
45    pub fn new() -> Self {
46        Self {
47            path: None,
48            file: OnceLock::new(),
49        }
50    }
51
52    /// Lazily opens an explicit preferences file, creating its parent directory.
53    pub fn at_path(path: impl Into<PathBuf>) -> Self {
54        Self {
55            path: Some(path.into()),
56            file: OnceLock::new(),
57        }
58    }
59
60    fn file(&self) -> Result<&Mutex<FileState>, PreferencesError> {
61        if let Some(file) = self.file.get() {
62            return Ok(file);
63        }
64        let path = match &self.path {
65            Some(path) => path.clone(),
66            None => application_directories()
67                .map_err(|error| PreferencesError::Io(error.to_string()))?
68                .config
69                .join("preferences"),
70        };
71        let path = canonical_file(&path)?;
72        static FILES: OnceLock<Mutex<Files>> = OnceLock::new();
73        let mut files = FILES
74            .get_or_init(Mutex::default)
75            .lock()
76            .map_err(|_| PreferencesError::Io("preferences registry poisoned".into()))?;
77        files.retain(|_, file| file.strong_count() != 0);
78        let file = match files.get(&path).and_then(Weak::upgrade) {
79            Some(file) => file,
80            None => {
81                let text = match fs::read_to_string(&path) {
82                    Ok(text) => text,
83                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
84                    Err(error) => return Err(io(error)),
85                };
86                let file = Arc::new(Mutex::new(FileState {
87                    path: path.clone(),
88                    entries: parse(&text),
89                    dirty: false,
90                }));
91                files.insert(path, Arc::downgrade(&file));
92                file
93            }
94        };
95        let _ = self.file.set(file);
96        self.file
97            .get()
98            .map(Arc::as_ref)
99            .ok_or_else(|| PreferencesError::Io("preferences initialization failed".into()))
100    }
101
102    fn with_entries<R>(
103        &self,
104        body: impl FnOnce(&mut FileState) -> Result<R, PreferencesError>,
105    ) -> Result<R, PreferencesError> {
106        let mut file = self
107            .file()?
108            .lock()
109            .map_err(|_| PreferencesError::Io("preferences lock poisoned".into()))?;
110        body(&mut file)
111    }
112
113    fn mutate(
114        &self,
115        body: impl FnOnce(&mut BTreeMap<String, String>) -> bool,
116    ) -> Result<(), PreferencesError> {
117        self.with_entries(|file| {
118            file.dirty |= body(&mut file.entries);
119            file.flush()
120        })
121    }
122}
123
124fn canonical_file(path: &Path) -> Result<PathBuf, PreferencesError> {
125    if path.try_exists().map_err(io)? {
126        return fs::canonicalize(path).map_err(io);
127    }
128    let name = path
129        .file_name()
130        .ok_or_else(|| PreferencesError::Io("preferences path must name a file".into()))?;
131    let parent = path
132        .parent()
133        .filter(|parent| !parent.as_os_str().is_empty())
134        .unwrap_or_else(|| Path::new("."));
135    fs::create_dir_all(parent).map_err(io)?;
136    Ok(fs::canonicalize(parent).map_err(io)?.join(name))
137}
138
139impl FileState {
140    fn flush(&mut self) -> Result<(), PreferencesError> {
141        if !self.dirty {
142            return Ok(());
143        }
144        let parent = self
145            .path
146            .parent()
147            .ok_or_else(|| PreferencesError::Io("preferences parent is missing".into()))?;
148        let mut file = tempfile::NamedTempFile::new_in(parent).map_err(io)?;
149        file.write_all(encode(&self.entries).as_bytes())
150            .map_err(io)?;
151        file.as_file().sync_all().map_err(io)?;
152        file.persist(&self.path).map_err(|error| io(error.error))?;
153        #[cfg(unix)]
154        File::open(parent)
155            .and_then(|directory| directory.sync_all())
156            .map_err(io)?;
157        self.dirty = false;
158        Ok(())
159    }
160}
161
162impl PreferencesStore for FilePreferences {
163    fn get(&self, key: &str) -> Option<String> {
164        self.with_entries(|file| Ok(file.entries.get(key).cloned()))
165            .ok()
166            .flatten()
167    }
168
169    fn set(&self, key: &str, value: &str) -> Result<(), PreferencesError> {
170        self.mutate(|entries| update(entries, key, value))
171    }
172
173    fn set_many(&self, values: &BTreeMap<String, String>) -> Result<(), PreferencesError> {
174        self.mutate(|entries| {
175            let mut changed = false;
176            for (key, value) in values {
177                changed |= update(entries, key, value);
178            }
179            changed
180        })
181    }
182
183    fn flush(&self) -> Result<(), PreferencesError> {
184        self.with_entries(FileState::flush)
185    }
186
187    fn remove(&self, key: &str) -> Result<(), PreferencesError> {
188        self.mutate(|entries| entries.remove(key).is_some())
189    }
190
191    fn keys(&self) -> Vec<String> {
192        self.with_entries(|file| Ok(file.entries.keys().cloned().collect()))
193            .unwrap_or_default()
194    }
195
196    fn clear(&self) -> Result<(), PreferencesError> {
197        self.mutate(|entries| {
198            let changed = !entries.is_empty();
199            entries.clear();
200            changed
201        })
202    }
203}
204
205fn update(entries: &mut BTreeMap<String, String>, key: &str, value: &str) -> bool {
206    if entries.get(key).is_some_and(|old| old == value) {
207        return false;
208    }
209    entries.insert(key.to_owned(), value.to_owned());
210    true
211}