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))]
226 let _ = permissions;
227 options.open(to).map(drop)
228}
229
230fn remove_if_empty(folder: &Path, report: &mut Migration) {
232 let empty = fs::read_dir(folder).is_ok_and(|mut entries| entries.next().is_none());
233 if empty && let Err(error) = fs::remove_dir(folder) {
234 report.warn(folder, format_args!("is empty but could not be removed ({error})"));
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 fn temp_dir(name: &str) -> PathBuf {
244 let dir = std::env::temp_dir().join(format!("quvyta-migrate-{name}-{}", std::process::id()));
245 let _ = fs::remove_dir_all(&dir);
246 fs::create_dir_all(&dir).expect("a temporary folder");
247 dir
248 }
249
250 fn write(path: &Path, text: &str) {
251 fs::create_dir_all(path.parent().expect("a folder")).expect("folders");
252 fs::write(path, text).expect("written");
253 }
254
255 fn read(path: &Path) -> String {
256 fs::read_to_string(path).unwrap_or_else(|error| panic!("{}: {error}", path.display()))
257 }
258
259 struct Layout {
261 root: PathBuf,
262 legacy: PathBuf,
263 file: PathBuf,
264 dir: PathBuf,
265 }
266
267 impl Layout {
268 fn new(name: &str) -> Self {
269 let root = temp_dir(name);
270 let legacy = root.join("quvyta-packages");
271 let family = root.join("quvyta");
272 Self { file: family.join("packages.conf"), dir: family.join("packages"), legacy, root }
273 }
274
275 fn adopt(&self) -> Migration {
276 adopt(&self.legacy, &self.file, &self.dir)
277 }
278 }
279
280 impl Drop for Layout {
281 fn drop(&mut self) {
282 let _ = fs::remove_dir_all(&self.root);
283 }
284 }
285
286 #[test]
287 fn settings_become_the_app_file_and_the_rest_moves_under_the_app_folder() {
288 let layout = Layout::new("plain");
289 write(&layout.legacy.join("settings.toml"), "theme = \"iris\"\n");
290 write(&layout.legacy.join("history.toml"), "one\n");
291 write(&layout.legacy.join("profiles/work/settings.toml"), "nested\n");
292
293 let report = layout.adopt();
294 assert!(report.is_clean(), "{:?}", report.diagnostics());
295 assert_eq!(read(&layout.file), "theme = \"iris\"\n");
296 assert_eq!(read(&layout.dir.join("history.toml")), "one\n");
297 assert_eq!(read(&layout.dir.join("profiles/work/settings.toml")), "nested\n", "only the top file is special");
298 assert_eq!(report.moved().len(), 3);
299 assert!(report.moved().contains(&(layout.legacy.join("settings.toml"), layout.file.clone())));
300 assert!(!layout.legacy.exists(), "the emptied old folders are gone");
301 }
302
303 #[test]
304 fn a_second_run_finds_nothing_and_changes_nothing() {
305 let layout = Layout::new("twice");
306 write(&layout.legacy.join("settings.toml"), "a = 1\n");
307 assert_eq!(layout.adopt().moved().len(), 1);
308 let again = layout.adopt();
309 assert_eq!(again, Migration::default());
310 assert_eq!(read(&layout.file), "a = 1\n");
311
312 let missing = adopt(&layout.root.join("never-there"), &layout.file, &layout.dir);
313 assert_eq!(missing, Migration::default(), "a missing old folder is no problem");
314 }
315
316 #[test]
317 fn when_the_old_folder_is_the_app_folder_only_the_settings_move() {
318 let root = temp_dir("in-place");
319 let dir = root.join("quvyta").join("focus");
320 let file = root.join("quvyta").join("focus.conf");
321 write(&dir.join("settings.toml"), "slide = true\n");
322 write(&dir.join("blocks.toml"), "blocks\n");
323
324 let report = adopt(&dir, &file, &dir);
325 assert!(report.is_clean(), "{:?}", report.diagnostics());
326 assert_eq!(report.moved(), &[(dir.join("settings.toml"), file.clone())]);
327 assert_eq!(read(&file), "slide = true\n");
328 assert_eq!(read(&dir.join("blocks.toml")), "blocks\n", "the other files are already in place");
329 assert!(dir.is_dir(), "the application's folder stays");
330
331 assert_eq!(adopt(&dir, &file, &dir), Migration::default());
333 fs::remove_file(dir.join("blocks.toml")).expect("remove");
335 assert_eq!(adopt(&dir, &file, &dir), Migration::default());
336 assert!(dir.is_dir());
337 fs::remove_dir_all(&root).expect("clean");
338 }
339
340 #[test]
341 fn a_taken_place_keeps_both_files() {
342 let layout = Layout::new("conflict");
343 write(&layout.legacy.join("settings.toml"), "old\n");
344 write(&layout.legacy.join("notes.txt"), "moves\n");
345 write(&layout.file, "new\n");
346
347 let report = layout.adopt();
348 assert_eq!(read(&layout.file), "new\n", "never overwritten");
349 assert_eq!(read(&layout.legacy.join("settings.toml")), "old\n", "never lost");
350 assert_eq!(read(&layout.dir.join("notes.txt")), "moves\n", "the rest still moves");
351 assert_eq!(report.diagnostics().len(), 1, "{:?}", report.diagnostics());
352 let message = &report.diagnostics()[0].message;
353 assert!(message.contains("settings.toml") && message.contains("already exists"), "{message}");
354 assert!(layout.legacy.is_dir(), "a folder with a file left in it stays");
355
356 let again = layout.adopt();
358 assert!(again.moved().is_empty());
359 assert_eq!(again.diagnostics(), report.diagnostics());
360 }
361
362 #[cfg(unix)]
363 #[test]
364 fn a_symbolic_link_is_left_alone() {
365 let layout = Layout::new("link");
366 write(&layout.root.join("dotfiles/settings.toml"), "linked\n");
367 fs::create_dir_all(&layout.legacy).expect("folder");
368 std::os::unix::fs::symlink(layout.root.join("dotfiles/settings.toml"), layout.legacy.join("settings.toml"))
369 .expect("link");
370
371 let report = layout.adopt();
372 assert!(report.moved().is_empty());
373 assert!(report.diagnostics()[0].message.contains("symbolic link"), "{:?}", report.diagnostics());
374 assert!(fs::symlink_metadata(layout.legacy.join("settings.toml")).expect("still there").is_symlink());
375 assert!(!layout.file.exists());
376 assert_eq!(read(&layout.root.join("dotfiles/settings.toml")), "linked\n");
377
378 let linked = layout.root.join("linked-folder");
380 std::os::unix::fs::symlink(&layout.legacy, &linked).expect("link");
381 let report = adopt(&linked, &layout.file, &layout.dir);
382 assert!(report.moved().is_empty() && !report.is_clean());
383 }
384
385 #[cfg(unix)]
386 #[test]
387 fn permissions_come_along() {
388 use std::os::unix::fs::PermissionsExt as _;
389 let layout = Layout::new("mode");
390 let secret = layout.legacy.join("tokens/api.toml");
391 write(&secret, "token = \"s\"\n");
392 fs::set_permissions(&secret, fs::Permissions::from_mode(0o600)).expect("private");
393 write(&layout.legacy.join("settings.toml"), "a = 1\n");
394 fs::set_permissions(layout.legacy.join("settings.toml"), fs::Permissions::from_mode(0o640)).expect("mode");
395
396 assert!(layout.adopt().is_clean());
397 let mode = |path: &Path| fs::metadata(path).expect("moved").permissions().mode() & 0o777;
398 assert_eq!(mode(&layout.dir.join("tokens/api.toml")), 0o600);
399 assert_eq!(mode(&layout.file), 0o640);
400 }
401
402 #[cfg(unix)]
403 #[test]
404 fn an_unreadable_file_stays_and_is_reported() {
405 use std::os::unix::fs::PermissionsExt as _;
406 let layout = Layout::new("unreadable");
407 let locked = layout.legacy.join("locked.toml");
408 write(&locked, "x\n");
409 write(&layout.legacy.join("settings.toml"), "a = 1\n");
410 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("lock");
411 if fs::read(&locked).is_ok() {
412 return;
414 }
415
416 let report = layout.adopt();
417 assert_eq!(report.moved().len(), 1, "the readable file still moves");
418 assert_eq!(report.diagnostics().len(), 1, "{:?}", report.diagnostics());
419 assert!(report.diagnostics()[0].message.contains("could not be read"), "{:?}", report.diagnostics());
420 assert!(locked.exists() && !layout.dir.join("locked.toml").exists(), "the file stays, no copy is left");
421 assert!(layout.legacy.is_dir());
422 fs::set_permissions(&locked, fs::Permissions::from_mode(0o600)).expect("unlock for cleanup");
423 }
424
425 #[test]
426 fn folders_that_hold_each_other_are_refused() {
427 let root = temp_dir("overlap");
428 let family = root.join("quvyta");
429 write(&family.join("settings.toml"), "a = 1\n");
430 let report = adopt(&family, &family.join("code.conf"), &family.join("code"));
431 assert!(report.moved().is_empty());
432 assert!(report.diagnostics()[0].message.contains("overlaps"), "{:?}", report.diagnostics());
433 assert_eq!(read(&family.join("settings.toml")), "a = 1\n");
434 fs::remove_dir_all(&root).expect("clean");
435 }
436
437 #[test]
438 fn an_old_file_in_place_of_a_folder_is_reported() {
439 let root = temp_dir("not-a-folder");
440 write(&root.join("old"), "a file\n");
441 let report = adopt(&root.join("old"), &root.join("new.conf"), &root.join("new"));
442 assert!(report.diagnostics()[0].message.contains("not a folder"), "{:?}", report.diagnostics());
443 assert_eq!(read(&root.join("old")), "a file\n");
444 fs::remove_dir_all(&root).expect("clean");
445 }
446}