1use std::fs;
10use std::io;
11use std::path::{Path, PathBuf};
12
13use super::FILE_NAME;
14use super::atomic::atomic_write;
15use crate::diagnostics::Diagnostic;
16
17#[derive(Debug, Clone, Default, PartialEq)]
20pub struct Migration {
21 moved: Vec<(PathBuf, PathBuf)>,
22 diagnostics: Vec<Diagnostic>,
23}
24
25impl Migration {
26 #[must_use]
28 pub fn moved(&self) -> &[(PathBuf, PathBuf)] {
29 &self.moved
30 }
31
32 #[must_use]
36 pub fn diagnostics(&self) -> &[Diagnostic] {
37 &self.diagnostics
38 }
39
40 #[must_use]
42 pub fn is_clean(&self) -> bool {
43 self.diagnostics.is_empty()
44 }
45
46 pub(super) fn without_folder() -> Self {
48 let mut report = Self::default();
49 report.diagnostics.push(Diagnostic::warning(None, "no config directory found; nothing is adopted"));
50 report
51 }
52
53 fn warn(&mut self, path: &Path, message: impl std::fmt::Display) {
54 self.diagnostics.push(Diagnostic::warning(None, format!("{}: {message}", path.display())));
55 }
56
57 fn fail(&mut self, path: &Path, message: impl std::fmt::Display) {
58 self.diagnostics.push(Diagnostic::error(None, format!("{}: {message}", path.display())));
59 }
60}
61
62pub(super) fn adopt(legacy: &Path, app_file: &Path, app_dir: &Path) -> Migration {
65 let mut report = Migration::default();
66 match fs::symlink_metadata(legacy) {
67 Err(error) if error.kind() == io::ErrorKind::NotFound => return report,
68 Err(error) => {
69 report.fail(legacy, format_args!("could not be read ({error}); nothing is adopted"));
70 return report;
71 }
72 Ok(meta) if meta.file_type().is_symlink() => {
73 report.warn(legacy, "a symbolic link is not followed; nothing is adopted from it");
74 return report;
75 }
76 Ok(meta) if !meta.is_dir() => {
77 report.warn(legacy, "not a folder; nothing is adopted from it");
78 return report;
79 }
80 Ok(_) => {}
81 }
82 let (old, new) = (real(legacy), real(app_dir));
83 if old == new {
84 let settings = legacy.join(FILE_NAME);
86 match fs::symlink_metadata(&settings) {
87 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
88 Err(error) => report.fail(&settings, format_args!("could not be read ({error}); it stays where it is")),
89 Ok(meta) => adopt_entry(&settings, meta.file_type(), app_file, &mut report),
90 }
91 return report;
92 }
93 if new.starts_with(&old) || old.starts_with(&new) {
94 report.warn(legacy, format_args!("overlaps {}; nothing is adopted", app_dir.display()));
96 return report;
97 }
98 adopt_folder(legacy, Path::new(""), app_file, app_dir, &mut report);
99 remove_if_empty(legacy, &mut report);
100 report
101}
102
103fn real(path: &Path) -> PathBuf {
106 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
107}
108
109fn adopt_folder(folder: &Path, relative: &Path, app_file: &Path, app_dir: &Path, report: &mut Migration) {
111 let entries = match fs::read_dir(folder) {
112 Ok(entries) => entries,
113 Err(error) => {
114 report.fail(folder, format_args!("could not be listed ({error}); what is in it stays"));
115 return;
116 }
117 };
118 let mut entries: Vec<_> = entries
119 .filter_map(|entry| match entry {
120 Ok(entry) => Some(entry),
121 Err(error) => {
122 report.fail(folder, format_args!("could not be listed completely ({error}); the rest stays"));
123 None
124 }
125 })
126 .collect();
127 entries.sort_by_key(fs::DirEntry::file_name);
129 for entry in entries {
130 let path = entry.path();
131 let name = entry.file_name();
132 let kind = match entry.file_type() {
133 Ok(kind) => kind,
134 Err(error) => {
135 report.fail(&path, format_args!("could not be read ({error}); it stays where it is"));
136 continue;
137 }
138 };
139 if kind.is_dir() {
140 adopt_folder(&path, &relative.join(&name), app_file, app_dir, report);
141 remove_if_empty(&path, report);
142 continue;
143 }
144 let target = if relative.as_os_str().is_empty() && name == FILE_NAME {
145 app_file.to_path_buf()
146 } else {
147 app_dir.join(relative).join(&name)
148 };
149 adopt_entry(&path, kind, &target, report);
150 }
151}
152
153fn adopt_entry(from: &Path, kind: fs::FileType, to: &Path, report: &mut Migration) {
155 if kind.is_symlink() {
156 report.warn(from, "a symbolic link is not moved; it stays where it is");
157 } else if !kind.is_file() {
158 report.warn(from, "not a regular file; it stays where it is");
159 } else if move_file(from, to, report) {
160 report.moved.push((from.to_path_buf(), to.to_path_buf()));
161 }
162}
163
164fn move_file(from: &Path, to: &Path, report: &mut Migration) -> bool {
167 if fs::symlink_metadata(to).is_ok() {
168 report.warn(from, format_args!("{} already exists; this file stays and nothing is merged", to.display()));
169 return false;
170 }
171 let (contents, permissions) = match fs::read(from).and_then(|contents| Ok((contents, fs::metadata(from)?))) {
172 Ok((contents, meta)) => (contents, meta.permissions()),
173 Err(error) => {
174 report.fail(from, format_args!("could not be read ({error}); it stays where it is"));
175 return false;
176 }
177 };
178 match claim(to, &permissions) {
179 Ok(()) => {}
180 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
181 report.warn(from, format_args!("{} already exists; this file stays and nothing is merged", to.display()));
182 return false;
183 }
184 Err(error) => {
185 report.fail(from, format_args!("could not be copied to {} ({error}); it stays where it is", to.display()));
186 return false;
187 }
188 }
189 let copied = (|| {
190 atomic_write(to, &contents)?;
191 fs::set_permissions(to, permissions)?;
192 if fs::read(to)? != contents {
193 return Err(io::Error::other("the copy reads back different"));
194 }
195 Ok(())
196 })();
197 if let Err(error) = copied {
198 let _ = fs::remove_file(to);
200 report.fail(from, format_args!("could not be copied to {} ({error}); it stays where it is", to.display()));
201 return false;
202 }
203 if let Err(error) = fs::remove_file(from) {
204 report.fail(from, format_args!("copied to {} but could not be removed ({error}); both stay", to.display()));
205 return false;
206 }
207 true
208}
209
210fn claim(to: &Path, permissions: &fs::Permissions) -> io::Result<()> {
215 if let Some(parent) = to.parent() {
216 fs::create_dir_all(parent)?;
217 }
218 let mut options = fs::OpenOptions::new();
219 options.write(true).create_new(true);
220 #[cfg(unix)]
221 {
222 use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
223 options.mode(permissions.mode());
224 }
225 #[cfg(not(unix))]
228 let _ = permissions;
229 options.open(to).map(drop)
230}
231
232fn remove_if_empty(folder: &Path, report: &mut Migration) {
234 let empty = fs::read_dir(folder).is_ok_and(|mut entries| entries.next().is_none());
235 if empty && let Err(error) = fs::remove_dir(folder) {
236 report.warn(folder, format_args!("is empty but could not be removed ({error})"));
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 fn temp_dir(name: &str) -> PathBuf {
246 let dir = std::env::temp_dir().join(format!("quvyta-migrate-{name}-{}", std::process::id()));
247 let _ = fs::remove_dir_all(&dir);
248 fs::create_dir_all(&dir).expect("a temporary folder");
249 dir
250 }
251
252 fn write(path: &Path, text: &str) {
253 fs::create_dir_all(path.parent().expect("a folder")).expect("folders");
254 fs::write(path, text).expect("written");
255 }
256
257 fn read(path: &Path) -> String {
258 fs::read_to_string(path).unwrap_or_else(|error| panic!("{}: {error}", path.display()))
259 }
260
261 struct Layout {
263 root: PathBuf,
264 legacy: PathBuf,
265 file: PathBuf,
266 dir: PathBuf,
267 }
268
269 impl Layout {
270 fn new(name: &str) -> Self {
271 let root = temp_dir(name);
272 let legacy = root.join("quvyta-packages");
273 let family = root.join("quvyta");
274 Self { file: family.join("packages.conf"), dir: family.join("packages"), legacy, root }
275 }
276
277 fn adopt(&self) -> Migration {
278 adopt(&self.legacy, &self.file, &self.dir)
279 }
280 }
281
282 impl Drop for Layout {
283 fn drop(&mut self) {
284 let _ = fs::remove_dir_all(&self.root);
285 }
286 }
287
288 #[test]
289 fn settings_become_the_app_file_and_the_rest_moves_under_the_app_folder() {
290 let layout = Layout::new("plain");
291 write(&layout.legacy.join("settings.toml"), "theme = \"iris\"\n");
292 write(&layout.legacy.join("history.toml"), "one\n");
293 write(&layout.legacy.join("profiles/work/settings.toml"), "nested\n");
294
295 let report = layout.adopt();
296 assert!(report.is_clean(), "{:?}", report.diagnostics());
297 assert_eq!(read(&layout.file), "theme = \"iris\"\n");
298 assert_eq!(read(&layout.dir.join("history.toml")), "one\n");
299 assert_eq!(read(&layout.dir.join("profiles/work/settings.toml")), "nested\n", "only the top file is special");
300 assert_eq!(report.moved().len(), 3);
301 assert!(report.moved().contains(&(layout.legacy.join("settings.toml"), layout.file.clone())));
302 assert!(!layout.legacy.exists(), "the emptied old folders are gone");
303 }
304
305 #[test]
306 fn a_second_run_finds_nothing_and_changes_nothing() {
307 let layout = Layout::new("twice");
308 write(&layout.legacy.join("settings.toml"), "a = 1\n");
309 assert_eq!(layout.adopt().moved().len(), 1);
310 let again = layout.adopt();
311 assert_eq!(again, Migration::default());
312 assert_eq!(read(&layout.file), "a = 1\n");
313
314 let missing = adopt(&layout.root.join("never-there"), &layout.file, &layout.dir);
315 assert_eq!(missing, Migration::default(), "a missing old folder is no problem");
316 }
317
318 #[test]
319 fn when_the_old_folder_is_the_app_folder_only_the_settings_move() {
320 let root = temp_dir("in-place");
321 let dir = root.join("quvyta").join("focus");
322 let file = root.join("quvyta").join("focus.conf");
323 write(&dir.join("settings.toml"), "slide = true\n");
324 write(&dir.join("blocks.toml"), "blocks\n");
325
326 let report = adopt(&dir, &file, &dir);
327 assert!(report.is_clean(), "{:?}", report.diagnostics());
328 assert_eq!(report.moved(), &[(dir.join("settings.toml"), file.clone())]);
329 assert_eq!(read(&file), "slide = true\n");
330 assert_eq!(read(&dir.join("blocks.toml")), "blocks\n", "the other files are already in place");
331 assert!(dir.is_dir(), "the application's folder stays");
332
333 assert_eq!(adopt(&dir, &file, &dir), Migration::default());
335 fs::remove_file(dir.join("blocks.toml")).expect("remove");
337 assert_eq!(adopt(&dir, &file, &dir), Migration::default());
338 assert!(dir.is_dir());
339 fs::remove_dir_all(&root).expect("clean");
340 }
341
342 #[test]
343 fn a_taken_place_keeps_both_files() {
344 let layout = Layout::new("conflict");
345 write(&layout.legacy.join("settings.toml"), "old\n");
346 write(&layout.legacy.join("notes.txt"), "moves\n");
347 write(&layout.file, "new\n");
348
349 let report = layout.adopt();
350 assert_eq!(read(&layout.file), "new\n", "never overwritten");
351 assert_eq!(read(&layout.legacy.join("settings.toml")), "old\n", "never lost");
352 assert_eq!(read(&layout.dir.join("notes.txt")), "moves\n", "the rest still moves");
353 assert_eq!(report.diagnostics().len(), 1, "{:?}", report.diagnostics());
354 let message = &report.diagnostics()[0].message;
355 assert!(message.contains("settings.toml") && message.contains("already exists"), "{message}");
356 assert!(layout.legacy.is_dir(), "a folder with a file left in it stays");
357
358 let again = layout.adopt();
360 assert!(again.moved().is_empty());
361 assert_eq!(again.diagnostics(), report.diagnostics());
362 }
363
364 #[cfg(unix)]
365 #[test]
366 fn a_symbolic_link_is_left_alone() {
367 let layout = Layout::new("link");
368 write(&layout.root.join("dotfiles/settings.toml"), "linked\n");
369 fs::create_dir_all(&layout.legacy).expect("folder");
370 std::os::unix::fs::symlink(layout.root.join("dotfiles/settings.toml"), layout.legacy.join("settings.toml"))
371 .expect("link");
372
373 let report = layout.adopt();
374 assert!(report.moved().is_empty());
375 assert!(report.diagnostics()[0].message.contains("symbolic link"), "{:?}", report.diagnostics());
376 assert!(fs::symlink_metadata(layout.legacy.join("settings.toml")).expect("still there").is_symlink());
377 assert!(!layout.file.exists());
378 assert_eq!(read(&layout.root.join("dotfiles/settings.toml")), "linked\n");
379
380 let linked = layout.root.join("linked-folder");
382 std::os::unix::fs::symlink(&layout.legacy, &linked).expect("link");
383 let report = adopt(&linked, &layout.file, &layout.dir);
384 assert!(report.moved().is_empty() && !report.is_clean());
385 }
386
387 #[cfg(unix)]
388 #[test]
389 fn permissions_come_along() {
390 use std::os::unix::fs::PermissionsExt as _;
391 let layout = Layout::new("mode");
392 let secret = layout.legacy.join("tokens/api.toml");
393 write(&secret, "token = \"s\"\n");
394 fs::set_permissions(&secret, fs::Permissions::from_mode(0o600)).expect("private");
395 write(&layout.legacy.join("settings.toml"), "a = 1\n");
396 fs::set_permissions(layout.legacy.join("settings.toml"), fs::Permissions::from_mode(0o640)).expect("mode");
397
398 assert!(layout.adopt().is_clean());
399 let mode = |path: &Path| fs::metadata(path).expect("moved").permissions().mode() & 0o777;
400 assert_eq!(mode(&layout.dir.join("tokens/api.toml")), 0o600);
401 assert_eq!(mode(&layout.file), 0o640);
402 }
403
404 #[cfg(unix)]
405 #[test]
406 fn an_unreadable_file_stays_and_is_reported() {
407 use std::os::unix::fs::PermissionsExt as _;
408 let layout = Layout::new("unreadable");
409 let locked = layout.legacy.join("locked.toml");
410 write(&locked, "x\n");
411 write(&layout.legacy.join("settings.toml"), "a = 1\n");
412 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("lock");
413 if fs::read(&locked).is_ok() {
414 return;
416 }
417
418 let report = layout.adopt();
419 assert_eq!(report.moved().len(), 1, "the readable file still moves");
420 assert_eq!(report.diagnostics().len(), 1, "{:?}", report.diagnostics());
421 assert!(report.diagnostics()[0].message.contains("could not be read"), "{:?}", report.diagnostics());
422 assert!(locked.exists() && !layout.dir.join("locked.toml").exists(), "the file stays, no copy is left");
423 assert!(layout.legacy.is_dir());
424 fs::set_permissions(&locked, fs::Permissions::from_mode(0o600)).expect("unlock for cleanup");
425 }
426
427 #[test]
428 fn folders_that_hold_each_other_are_refused() {
429 let root = temp_dir("overlap");
430 let family = root.join("quvyta");
431 write(&family.join("settings.toml"), "a = 1\n");
432 let report = adopt(&family, &family.join("code.conf"), &family.join("code"));
433 assert!(report.moved().is_empty());
434 assert!(report.diagnostics()[0].message.contains("overlaps"), "{:?}", report.diagnostics());
435 assert_eq!(read(&family.join("settings.toml")), "a = 1\n");
436 fs::remove_dir_all(&root).expect("clean");
437 }
438
439 #[test]
440 fn an_old_file_in_place_of_a_folder_is_reported() {
441 let root = temp_dir("not-a-folder");
442 write(&root.join("old"), "a file\n");
443 let report = adopt(&root.join("old"), &root.join("new.conf"), &root.join("new"));
444 assert!(report.diagnostics()[0].message.contains("not a folder"), "{:?}", report.diagnostics());
445 assert_eq!(read(&root.join("old")), "a file\n");
446 fs::remove_dir_all(&root).expect("clean");
447 }
448}