1use std::path::{Path, PathBuf};
12
13pub fn replace(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
18 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
19 let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
20 let temporary = path.with_file_name(format!(".{name}.{}-{}.new", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));
21 let written = std::fs::write(&temporary, bytes).and_then(|()| std::fs::rename(&temporary, path));
22 if written.is_err() {
23 let _ = std::fs::remove_file(&temporary);
24 }
25 written
26}
27
28pub fn has_lock(path: &Path) -> bool {
33 lock_path(path).exists()
34}
35
36fn lock_path(path: &Path) -> PathBuf {
37 let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
38 path.with_file_name(format!(".{name}.lock"))
39}
40
41thread_local! {
42 static HELD: std::cell::RefCell<Vec<PathBuf>> = const { std::cell::RefCell::new(Vec::new()) };
45}
46
47pub fn with_lock<T>(path: &Path, f: impl FnOnce() -> T) -> T {
52 let lock = lock_path(path);
53 if HELD.with(|held| held.borrow().contains(&lock)) {
54 return f();
55 }
56 struct Holding(PathBuf);
57 impl Drop for Holding {
58 fn drop(&mut self) {
59 HELD.with(|held| held.borrow_mut().retain(|p| *p != self.0));
60 }
61 }
62 let mut options = std::fs::OpenOptions::new();
63 options.create(true).truncate(false).write(true);
64 #[cfg(unix)]
66 std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
67 let file = path.parent().and_then(|parent| std::fs::create_dir_all(parent).ok()).and_then(|()| options.open(lock_path(path)).ok());
68 let locked = file.as_ref().is_some_and(|f| f.lock().is_ok());
69 HELD.with(|held| held.borrow_mut().push(lock.clone()));
70 let holding = Holding(lock);
71 let out = f();
72 drop(holding);
73 if locked && let Some(file) = &file {
74 let _ = file.unlock();
75 }
76 out
77}
78
79#[derive(Clone, Default)]
85pub struct Read(Option<std::sync::Arc<toml::Table>>);
86
87impl Read {
88 pub fn of(text: &str) -> Read {
90 Read(text.parse::<toml::Table>().ok().map(std::sync::Arc::new))
91 }
92}
93
94impl PartialEq for Read {
95 fn eq(&self, _: &Read) -> bool {
96 true
97 }
98}
99
100impl Eq for Read {}
101
102impl std::fmt::Debug for Read {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 f.write_str("Read")
105 }
106}
107
108pub fn save_merged<T: serde::Serialize + serde::de::DeserializeOwned>(path: &Path, read: &Read, state: &T) -> Result<(), String> {
117 save_merged_by(path, read, state, |_, _| {})
118}
119
120pub fn save_merged_by<T: serde::Serialize + serde::de::DeserializeOwned>(path: &Path, read: &Read, state: &T, settle: impl FnOnce(&mut toml::Table, Option<&toml::Table>)) -> Result<(), String> {
124 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
125 let new = match toml::Value::try_from(state).map_err(|e| e.to_string())? {
126 toml::Value::Table(table) => table,
127 _ => return Err(format!("{}: not a table", path.display())),
128 };
129 let known: Option<toml::Table> = read.0.as_ref().and_then(|base| T::deserialize(toml::Value::Table((**base).clone())).ok()).and_then(|t| toml::Value::try_from(&t).ok()).and_then(|v| match v {
132 toml::Value::Table(table) => Some(table),
133 _ => None,
134 });
135 with_lock(path, || {
136 let current = std::fs::read_to_string(path).ok().and_then(|text| text.parse::<toml::Table>().ok());
137 let mut table = match (&read.0, ¤t) {
138 (Some(base), Some(current)) => {
139 let mut merged = current.clone();
140 merge(&mut merged, base, &new, known.as_ref());
141 merged
142 }
143 _ => new.clone(),
144 };
145 settle(&mut table, current.as_ref());
146 if let Some(parent) = path.parent() {
147 std::fs::create_dir_all(parent).map_err(fail)?;
148 }
149 let temporary = path.with_file_name(format!(".{}.sioul.tmp", path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()));
150 std::fs::write(&temporary, toml::to_string(&table).map_err(|e| e.to_string())?).map_err(fail)?;
151 std::fs::rename(&temporary, path).map_err(fail)
152 })
153}
154
155static EMPTY: std::sync::LazyLock<toml::Table> = std::sync::LazyLock::new(toml::Table::new);
156
157fn merge(current: &mut toml::Table, base: &toml::Table, new: &toml::Table, known: Option<&toml::Table>) {
166 let keys: std::collections::BTreeSet<String> = base.keys().chain(new.keys()).filter(|key| new.contains_key(*key) || known.is_none_or(|known| known.contains_key(*key)) || !base.contains_key(*key)).cloned().collect();
167 let no_list = toml::Value::Array(Vec::new());
168 let no_table = toml::Value::Table(toml::Table::new());
169 for key in keys {
170 let (before, after) = match (base.get(&key), new.get(&key)) {
171 (Some(before @ toml::Value::Array(_)), None) => (Some(before), Some(&no_list)),
172 (None, Some(after @ toml::Value::Array(_))) => (Some(&no_list), Some(after)),
173 (Some(before @ toml::Value::Table(_)), None) => (Some(before), Some(&no_table)),
174 (None, Some(after @ toml::Value::Table(_))) => (Some(&no_table), Some(after)),
175 pair => pair,
176 };
177 match (before, after) {
178 (before, after) if before == after => {}
179 (Some(toml::Value::Table(before)), Some(toml::Value::Table(after))) => match current.get_mut(&key) {
180 Some(toml::Value::Table(inner)) => {
181 merge(inner, before, after, known.map(|k| k.get(&key).and_then(toml::Value::as_table).unwrap_or(&*EMPTY)));
182 if inner.is_empty() && !new.contains_key(&key) {
183 current.remove(&key);
184 }
185 }
186 _ if after.is_empty() && !new.contains_key(&key) => {}
187 _ => {
188 current.insert(key, toml::Value::Table(after.clone()));
189 }
190 },
191 (Some(toml::Value::Array(before)), Some(toml::Value::Array(after))) => match current.get_mut(&key) {
192 Some(toml::Value::Array(inner)) => {
193 inner.retain(|item| !before.contains(item) || after.contains(item));
194 for item in after.iter().filter(|item| !before.contains(item)) {
195 if !inner.contains(item) {
196 inner.push(item.clone());
197 }
198 }
199 if inner.is_empty() && !new.contains_key(&key) {
200 current.remove(&key);
201 }
202 }
203 _ if after.is_empty() && !new.contains_key(&key) => {}
204 _ => {
205 current.insert(key, toml::Value::Array(after.clone()));
206 }
207 },
208 (_, Some(after)) => {
209 current.insert(key, after.clone());
210 }
211 (_, None) => {
212 current.remove(&key);
213 }
214 }
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
225 fn a_save_keeps_what_another_writer_changed_since_it_was_read() {
226 let dir = std::env::temp_dir().join(format!("sioul-merge-{}", std::process::id()));
227 let _ = std::fs::remove_dir_all(&dir);
228 std::fs::create_dir_all(&dir).unwrap();
229 let path = dir.join("quiet.toml");
230 std::fs::write(&path, "work_until = 10
231lighter = [2026-10-01]
232").unwrap();
233 let read = Read::of(&std::fs::read_to_string(&path).unwrap());
234 std::fs::write(&path, "work_until = 10
236rest_until = 20
237lighter = [2026-10-01, 2026-10-02]
238").unwrap();
239 let mine: toml::Table = "work_until = 11
241lighter = [2026-10-01, 2026-10-03]
242".parse().unwrap();
243 save_merged(&path, &read, &mine).unwrap();
244 let now: toml::Table = std::fs::read_to_string(&path).unwrap().parse().unwrap();
245 assert_eq!(now["work_until"].as_integer(), Some(11));
246 assert_eq!(now["rest_until"].as_integer(), Some(20), "the other writer's field stays");
247 assert_eq!(now["lighter"].as_array().unwrap().len(), 3, "both days added stay: {now:?}");
248 let _ = std::fs::remove_dir_all(&dir);
249 }
250
251 #[test]
256 fn an_emptied_or_new_list_keeps_what_another_writer_added() {
257 let dir = std::env::temp_dir().join(format!("sioul-merge-empty-{}", std::process::id()));
258 let _ = std::fs::remove_dir_all(&dir);
259 std::fs::create_dir_all(&dir).unwrap();
260 let path = dir.join("quiet.toml");
261 std::fs::write(&path, "lighter = [2026-10-12]\n").unwrap();
262 let read = Read::of(&std::fs::read_to_string(&path).unwrap());
263 std::fs::write(&path, "lighter = [2026-10-12, 2026-10-14]\n").unwrap();
264 save_merged(&path, &read, &toml::Table::new()).unwrap();
265 assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "lighter = [2026-10-14]");
266 let read = Read::of(&std::fs::read_to_string(&path).unwrap());
268 save_merged(&path, &read, &toml::Table::new()).unwrap();
269 assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "");
270 std::fs::remove_file(&path).unwrap();
272 let read = Read::of(&std::fs::read_to_string(&path).unwrap_or_default());
273 std::fs::write(&path, "ignored = [\"a\"]\n[seen]\nx = 1\n").unwrap();
274 let mine: toml::Table = "ignored = [\"b\"]\n[seen]\ny = 2\n".parse().unwrap();
275 save_merged(&path, &read, &mine).unwrap();
276 let now: toml::Table = std::fs::read_to_string(&path).unwrap().parse().unwrap();
277 assert_eq!(now["ignored"].as_array().unwrap().len(), 2, "{now:?}");
278 assert_eq!((now["seen"].get("x").and_then(toml::Value::as_integer), now["seen"].get("y").and_then(toml::Value::as_integer)), (Some(1), Some(2)), "{now:?}");
279 let _ = std::fs::remove_dir_all(&dir);
280 }
281
282 #[test]
285 fn a_field_a_newer_sioul_wrote_stays() {
286 #[derive(serde::Serialize, serde::Deserialize)]
287 struct Older {
288 #[serde(default)]
289 work_until: Option<i64>,
290 }
291 let dir = std::env::temp_dir().join(format!("sioul-merge-newer-{}", std::process::id()));
292 let _ = std::fs::remove_dir_all(&dir);
293 std::fs::create_dir_all(&dir).unwrap();
294 let path = dir.join("quiet.toml");
295 std::fs::write(&path, "work_until = 10\nnewer = \"kept\"\n[table]\nnewer = 1\n").unwrap();
296 let read = Read::of(&std::fs::read_to_string(&path).unwrap());
297 save_merged(&path, &read, &Older { work_until: Some(11) }).unwrap();
298 let now: toml::Table = std::fs::read_to_string(&path).unwrap().parse().unwrap();
299 assert_eq!((now["work_until"].as_integer(), now["newer"].as_str(), now.get("table").is_some()), (Some(11), Some("kept"), true), "{now:?}");
300 let _ = std::fs::remove_dir_all(&dir);
301 }
302
303 #[test]
304 fn writers_take_turns() {
305 let dir = std::env::temp_dir().join(format!("sioul-lock-{}", std::process::id()));
306 let _ = std::fs::remove_dir_all(&dir);
307 std::fs::create_dir_all(&dir).unwrap();
308 let path = dir.join("count.txt");
309 std::fs::write(&path, "0").unwrap();
310 let threads: Vec<_> = (0..8)
312 .map(|_| {
313 let path = path.clone();
314 std::thread::spawn(move || {
315 for _ in 0..100 {
316 with_lock(&path, || {
317 let n: u32 = std::fs::read_to_string(&path).unwrap().trim().parse().unwrap();
318 std::thread::yield_now();
319 std::fs::write(&path, (n + 1).to_string()).unwrap();
320 });
321 }
322 })
323 })
324 .collect();
325 for thread in threads {
326 thread.join().unwrap();
327 }
328 assert_eq!(std::fs::read_to_string(&path).unwrap(), "800");
329 assert!(dir.join(".count.txt.lock").exists(), "hidden, so never shared");
330 let _ = std::fs::remove_dir_all(&dir);
331 }
332}