1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
//! The list of repositories the sidebar shows.
//!
//! **A separate file from `config.toml` on purpose.** That file is read-only to
//! the app -- persisting toggles would clobber a file someone hand-edited, and
//! silently change their defaults. This one is app-owned state that the app
//! writes. Two files, and neither rule needs qualifying.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct Registry {
#[serde(default)]
pub repos: Vec<String>,
}
impl Registry {
/// Returns whether the list changed, so a duplicate `a` can be reported
/// rather than looking like nothing happened.
pub fn add(&mut self, path: &str) -> bool {
if self.repos.iter().any(|p| p == path) {
return false;
}
self.repos.push(path.to_string());
self.repos.sort();
true
}
pub fn remove(&mut self, path: &str) -> bool {
let before = self.repos.len();
self.repos.retain(|p| p != path);
self.repos.len() != before
}
/// Parses registry text, reporting a problem rather than failing.
pub fn parse(text: &str) -> (Registry, Option<String>) {
if text.trim().is_empty() {
return (Registry::default(), None);
}
match toml::from_str::<Registry>(text) {
Ok(r) => (r, None),
Err(e) => (Registry::default(), Some(format!("repos.toml: {e}"))),
}
}
pub fn load() -> (Registry, Option<String>) {
let Some(p) = path() else {
return (Registry::default(), None);
};
match std::fs::read_to_string(&p) {
Ok(text) => Registry::parse(&text),
// Missing is the normal first-run state, and silent -- there is
// nothing to lose by treating it as an empty registry.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (Registry::default(), None),
// Anything else -- permissions, a directory sitting where the file
// should be, any other I/O failure -- is NOT "no registry yet." It
// means the real content could not be read, so reporting
// `Registry::default()` here without a problem would let the very
// next `save()` overwrite whatever is actually on disk with an
// empty (or one-entry) file, silently discarding it. Callers that
// persist must treat a `Some` here as a hard stop, not a status
// line -- see `add_and_save`/`remove_and_save`.
Err(e) => (Registry::default(), Some(format!("repos.toml: {e}"))),
}
}
/// Writes the registry **atomically**: to a temporary file beside the
/// target, then `rename` into place.
///
/// `std::fs::write` truncates first and writes second, so a crash, a full
/// disk or a second instance writing at the same moment can leave a
/// truncated -- or entirely empty -- `repos.toml` on disk. `parse` treats
/// empty text as a legitimately empty registry, silently, so the next `a`
/// would then persist a one-entry file over everything that was there.
/// That is the same silent-data-loss shape `load` and `add_and_save`
/// already guard their own halves of, and this is the third and last way
/// in.
///
/// `rename` is atomic within a filesystem, and the temporary deliberately
/// sits in the *same directory* as the target so it always is one -- a
/// temp under `/tmp` could land on a different volume, where `rename`
/// degrades to copy-then-delete and the guarantee is gone. A reader
/// therefore sees either the whole old file or the whole new one, never a
/// partial, which also closes the concurrent-instance race properly
/// rather than merely narrowing it the way re-reading first does.
pub fn save(&self) -> Result<(), String> {
let p = path().ok_or_else(|| "could not resolve a config directory".to_string())?;
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
let text = toml::to_string(self).map_err(|e| format!("could not serialise: {e}"))?;
let tmp = p.with_extension("toml.tmp");
std::fs::write(&tmp, text).map_err(|e| format!("{}: {e}", tmp.display()))?;
std::fs::rename(&tmp, &p).map_err(|e| {
// The old file is still intact -- the rename is what would have
// replaced it -- so the only thing to clean up is the temporary,
// which must not be left behind to be mistaken for a registry.
let _ = std::fs::remove_file(&tmp);
format!("{}: {e}", p.display())
})
}
/// Adds `path` against whatever is actually on disk right now, not
/// whatever `self` happens to hold, and only updates `self` once the
/// write has actually landed.
///
/// Two running dextui instances -- the ordinary case for a tool whose
/// whole point is several repos -- registering different paths around the
/// same moment would otherwise each trust its own in-memory snapshot and
/// blindly overwrite the other's write. Re-reading first narrows that
/// race to the gap between this read and this write, rather than the gap
/// since each process started. It also refuses to write anything at all
/// when the on-disk file cannot be read back honestly (see `load`),
/// rather than overwriting content this process never actually saw.
pub fn add_and_save(&mut self, path: &str) -> Result<bool, String> {
let (mut current, problem) = Registry::load();
if let Some(p) = problem {
return Err(p);
}
let changed = current.add(path);
if changed {
current.save()?;
*self = current;
}
Ok(changed)
}
/// Mirrors `add_and_save`. Returns `Err` -- rather than silently keeping
/// the in-memory removal -- when the change could not actually be
/// persisted, so the caller can say so instead of reporting success on an
/// entry that will simply reappear at the next launch.
pub fn remove_and_save(&mut self, path: &str) -> Result<bool, String> {
let (mut current, problem) = Registry::load();
if let Some(p) = problem {
return Err(p);
}
let changed = current.remove(path);
if changed {
current.save()?;
*self = current;
}
Ok(changed)
}
}
/// Beside `config.toml`, resolved the same way so both follow XDG.
pub fn path() -> Option<PathBuf> {
let base = match std::env::var_os("XDG_CONFIG_HOME") {
Some(x) if !x.is_empty() => PathBuf::from(x),
_ => PathBuf::from(std::env::var_os("HOME")?).join(".config"),
};
Some(base.join("dextui").join("repos.toml"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adding_a_repo_reports_that_it_changed() {
let mut r = Registry::default();
assert!(r.add("/x/one"));
assert_eq!(r.repos, vec!["/x/one".to_string()]);
}
/// Pressing `a` twice is not a mistake worth an error, but the caller needs
/// to know so it can say "already registered" instead of nothing at all.
#[test]
fn adding_a_known_repo_changes_nothing_and_says_so() {
let mut r = Registry::default();
r.add("/x/one");
assert!(!r.add("/x/one"));
assert_eq!(r.repos.len(), 1, "duplicated: {:?}", r.repos);
}
#[test]
fn removing_reports_whether_it_was_there() {
let mut r = Registry::default();
r.add("/x/one");
assert!(r.remove("/x/one"));
assert!(!r.remove("/x/one"));
assert!(r.repos.is_empty());
}
#[test]
fn a_registry_round_trips_through_toml() {
let mut r = Registry::default();
r.add("/x/one");
r.add("/x/two");
let text = toml::to_string(&r).unwrap();
let back: Registry = toml::from_str(&text).unwrap();
assert_eq!(back, r);
}
/// A bad file must not stop the app starting -- the same rule config already
/// follows, for the same reason.
#[test]
fn a_malformed_file_is_reported_and_not_fatal() {
let (reg, problem) = Registry::parse("repos = \"not a list\"");
assert_eq!(reg, Registry::default());
assert!(problem.is_some(), "a bad file must be reported");
}
/// `crate::test_support::with_isolated_registry`, not a copy of its own:
/// this module and `app.rs`'s own tests both mutate the same
/// process-wide `XDG_CONFIG_HOME`, and two independent locks -- one per
/// module -- would not actually exclude each other from it. Only one
/// shared lock, used by both, does.
use crate::test_support::with_isolated_registry;
/// The normal first-run state: nothing has ever been saved, so there is
/// nothing to lose by treating it as an empty registry, silently.
#[test]
fn a_missing_file_loads_as_empty_and_silent() {
with_isolated_registry("registry-missing", || {
let (reg, problem) = Registry::load();
assert_eq!(reg, Registry::default());
assert!(problem.is_none(), "a first run must not be reported as a problem");
});
}
/// Anything other than "the file does not exist yet" -- permissions, a
/// directory sitting where the file should be -- must be reported, or the
/// very next save would silently overwrite content this process never
/// actually read. A directory at the registry's path is a reliable,
/// portable way to provoke a non-`NotFound` read error without needing
/// real permission bits.
#[test]
fn an_unreadable_file_is_reported_not_treated_as_empty() {
with_isolated_registry("registry-unreadable", || {
let p = path().unwrap();
std::fs::create_dir_all(&p).unwrap(); // a dir where a file is expected
let (reg, problem) = Registry::load();
assert_eq!(reg, Registry::default());
assert!(problem.is_some(), "an unreadable file must be reported");
});
}
#[test]
fn add_and_save_persists_and_updates_self() {
with_isolated_registry("registry-add", || {
let mut r = Registry::default();
assert!(r.add_and_save("/x/one").unwrap());
assert_eq!(r.repos, vec!["/x/one".to_string()]);
let (on_disk, _) = Registry::load();
assert_eq!(on_disk.repos, vec!["/x/one".to_string()]);
});
}
/// The whole point: a second in-memory copy (standing in for a second
/// dextui process) must not be able to blindly overwrite what the first
/// one already wrote. `add_and_save` re-reads before writing, so both
/// additions -- made against two independent `Registry` values that never
/// saw each other's change -- must both survive.
#[test]
fn add_and_save_does_not_clobber_a_concurrent_write() {
with_isolated_registry("registry-concurrent", || {
let mut first = Registry::default();
let mut second = Registry::default(); // stands in for another process
assert!(first.add_and_save("/x/one").unwrap());
assert!(second.add_and_save("/x/two").unwrap());
let (on_disk, _) = Registry::load();
assert_eq!(
on_disk.repos,
vec!["/x/one".to_string(), "/x/two".to_string()],
"second's write must not have discarded first's"
);
});
}
#[test]
fn remove_and_save_persists_and_updates_self() {
with_isolated_registry("registry-remove", || {
let mut r = Registry::default();
r.add_and_save("/x/one").unwrap();
r.add_and_save("/x/two").unwrap();
assert!(r.remove_and_save("/x/one").unwrap());
assert_eq!(r.repos, vec!["/x/two".to_string()]);
let (on_disk, _) = Registry::load();
assert_eq!(on_disk.repos, vec!["/x/two".to_string()]);
});
}
/// A registry that failed to load must never be saved over -- the whole
/// point of reporting the problem in the first place.
#[test]
fn add_and_save_refuses_when_the_file_cannot_be_read() {
with_isolated_registry("registry-add-refuses", || {
let p = path().unwrap();
std::fs::create_dir_all(&p).unwrap(); // unreadable as a file
let mut r = Registry::default();
let err = r.add_and_save("/x/one").unwrap_err();
assert!(!err.is_empty());
// Nothing was written -- the directory is still a directory, not
// a registry file, and `self` was not mutated either.
assert!(r.repos.is_empty());
});
}
#[test]
fn remove_and_save_refuses_when_the_file_cannot_be_read() {
with_isolated_registry("registry-remove-refuses", || {
let p = path().unwrap();
std::fs::create_dir_all(&p).unwrap();
let mut r = Registry {
repos: vec!["/x/one".into()],
};
let err = r.remove_and_save("/x/one").unwrap_err();
assert!(!err.is_empty());
// The in-memory copy is left exactly as it was -- rolled back
// rather than half-applied -- so the caller's own state does not
// silently disagree with what is (or isn't) actually on disk.
assert_eq!(r.repos, vec!["/x/one".to_string()]);
});
}
/// The temporary is an implementation detail of the atomic write, and it
/// lives in the config directory beside the real file -- so a failure to
/// clean it up would leave a stray `repos.toml.tmp` sitting next to the
/// registry forever, looking like a half-finished write nobody can
/// explain.
#[test]
fn a_save_leaves_no_temporary_file_behind() {
with_isolated_registry("registry-no-tmp", || {
let mut r = Registry::default();
r.add_and_save("/x/one").unwrap();
r.add_and_save("/x/two").unwrap();
let dir = path().unwrap().parent().unwrap().to_path_buf();
let strays: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.ends_with(".tmp"))
.collect();
assert!(strays.is_empty(), "left behind: {strays:?}");
});
}
/// The failure this closes: a truncate-then-write left a window where the
/// file on disk was empty, and `parse` reads empty as a legitimately empty
/// registry. Writing through a rename means the target is only ever
/// replaced whole, so a registry that was on disk before a save is either
/// still entirely there or entirely superseded -- and in particular the
/// second save here cannot lose the first one's entry.
#[test]
fn an_existing_registry_survives_a_save_of_new_content() {
with_isolated_registry("registry-survives", || {
let p = path().unwrap();
let mut r = Registry::default();
r.add_and_save("/x/one").unwrap();
let before = std::fs::read_to_string(&p).unwrap();
assert!(!before.trim().is_empty(), "nothing was written at all");
r.add_and_save("/x/two").unwrap();
// Whole, parseable, and carrying both entries -- never the empty
// or half-written file the old path could leave.
let after = std::fs::read_to_string(&p).unwrap();
let (parsed, problem) = Registry::parse(&after);
assert!(problem.is_none(), "the saved file did not parse: {problem:?}");
assert_eq!(
parsed.repos,
vec!["/x/one".to_string(), "/x/two".to_string()],
"the earlier entry did not survive the second save"
);
});
}
#[test]
fn an_empty_file_is_normal_and_silent() {
let (reg, problem) = Registry::parse("");
assert_eq!(reg, Registry::default());
assert!(problem.is_none(), "an empty registry is not a problem");
}
}