cranpose_services/preferences/
file.rs1#[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
14pub 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 pub fn new() -> Self {
46 Self {
47 path: None,
48 file: OnceLock::new(),
49 }
50 }
51
52 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 update_many(
174 &self,
175 values: &BTreeMap<String, String>,
176 removed: &[String],
177 ) -> Result<(), PreferencesError> {
178 self.mutate(|entries| {
179 let mut changed = false;
180 for key in removed {
181 changed |= entries.remove(key).is_some();
182 }
183 for (key, value) in values {
184 changed |= update(entries, key, value);
185 }
186 changed
187 })
188 }
189
190 fn flush(&self) -> Result<(), PreferencesError> {
191 self.with_entries(FileState::flush)
192 }
193
194 fn remove(&self, key: &str) -> Result<(), PreferencesError> {
195 self.mutate(|entries| entries.remove(key).is_some())
196 }
197
198 fn keys(&self) -> Vec<String> {
199 self.with_entries(|file| Ok(file.entries.keys().cloned().collect()))
200 .unwrap_or_default()
201 }
202
203 fn clear(&self) -> Result<(), PreferencesError> {
204 self.mutate(|entries| {
205 let changed = !entries.is_empty();
206 entries.clear();
207 changed
208 })
209 }
210}
211
212fn update(entries: &mut BTreeMap<String, String>, key: &str, value: &str) -> bool {
213 if entries.get(key).is_some_and(|old| old == value) {
214 return false;
215 }
216 entries.insert(key.to_owned(), value.to_owned());
217 true
218}