handshake_core/config/
migration.rs1use std::fs::{self, File};
3use std::io;
4use std::path::{Path, PathBuf};
5
6use thiserror::Error;
7
8#[derive(Debug, Error)]
9#[error(
10 "Could not adopt config from {legacy} into {destination}: {source}. The legacy source is retained; repair the paths and retry."
11)]
12pub struct ConfigMigrationError {
13 legacy: PathBuf,
14 destination: PathBuf,
15 #[source]
16 source: io::Error,
17}
18
19pub(super) fn adopt(legacy: &Path, destination: &Path) -> Result<(), ConfigMigrationError> {
20 adopt_tree(legacy, destination).map_err(|source| ConfigMigrationError {
21 legacy: legacy.into(),
22 destination: destination.into(),
23 source,
24 })
25}
26
27fn adopt_tree(legacy: &Path, destination: &Path) -> io::Result<()> {
28 match fs::symlink_metadata(destination) {
30 Ok(_) => return Ok(()),
31 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
32 Err(error) => return Err(error),
33 }
34 let root_is_link = match fs::symlink_metadata(legacy) {
35 Ok(metadata) => metadata.file_type().is_symlink(),
36 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
37 Err(error) => return Err(error),
38 };
39 let source = legacy.canonicalize()?;
40 if !source.is_dir() {
41 return Err(io::Error::other("legacy config root is not a directory"));
42 }
43 let parent = destination
44 .parent()
45 .ok_or_else(|| io::Error::other("config root needs a parent"))?;
46 fs::create_dir_all(parent)?;
47 if !root_is_link && parent.canonicalize()?.starts_with(&source) {
48 return Err(io::Error::other(
49 "new config root must be outside the legacy tree",
50 ));
51 }
52 let copy = tempfile::Builder::new()
53 .prefix(".handshake-config-")
54 .tempdir_in(parent)?;
55 let publication = if root_is_link {
56 let link = copy.path().join("root");
59 std::os::unix::fs::symlink(&source, &link)?;
60 link
61 } else {
62 copy_tree(&source, copy.path())?;
63 copy.path().to_owned()
64 };
65 match rustix::fs::renameat_with(
66 rustix::fs::CWD,
67 &publication,
68 rustix::fs::CWD,
69 destination,
70 rustix::fs::RenameFlags::NOREPLACE,
71 ) {
72 Ok(()) => {
73 if !root_is_link {
74 let _published = copy.keep();
75 }
76 }
77 Err(rustix::io::Errno::EXIST) => return Ok(()),
78 Err(error) => return Err(error.into()),
79 }
80 File::open(parent)?.sync_all()
81}
82
83fn copy_tree(source: &Path, destination: &Path) -> io::Result<()> {
84 for entry in fs::read_dir(source)? {
85 let entry = entry?;
86 let target = destination.join(entry.file_name());
87 let kind = entry.file_type()?;
88 if kind.is_symlink() {
89 std::os::unix::fs::symlink(fs::read_link(entry.path())?, target)?;
91 } else if kind.is_dir() {
92 fs::create_dir(&target)?;
93 copy_tree(&entry.path(), &target)?;
94 } else if kind.is_file() {
95 fs::copy(entry.path(), &target)?;
96 File::open(&target)?.sync_all()?;
97 } else {
98 return Err(io::Error::other(
99 "config contains a special file; migrate it manually",
100 ));
101 }
102 }
103 File::open(destination)?.sync_all()
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use crate::config::{Config, FontSize};
110
111 #[test]
112 fn adopts_imports_workflows_custom_themes_and_links_once_without_editing_sources() {
113 let root = tempfile::tempdir().unwrap();
114 let legacy = root.path().join("twine");
115 let destination = root.path().join("handshake");
116 fs::create_dir_all(legacy.join("parts")).unwrap();
117 fs::create_dir(legacy.join("themes")).unwrap();
118 fs::create_dir(legacy.join("workflows")).unwrap();
119 let config = "import = 'parts/settings.toml'\n[theme]\ndark = 'twine_dark'\n";
120 fs::write(legacy.join("config.toml"), config).unwrap();
121 fs::write(
122 legacy.join("parts/settings.toml"),
123 "[terminal]\nfont_size = 17.5\n",
124 )
125 .unwrap();
126 fs::write(
127 legacy.join("themes/twine_dark.toml"),
128 "[terminal.colors]\nbackground = '#123456'\n",
129 )
130 .unwrap();
131 let workflow = crate::BuiltinType::Adversarial.definition();
132 fs::write(
133 legacy.join("workflows/mine.toml"),
134 toml::to_string(&workflow).unwrap(),
135 )
136 .unwrap();
137 std::os::unix::fs::symlink("parts/settings.toml", legacy.join("linked.toml")).unwrap();
138 adopt(&legacy, &destination).unwrap();
139 let loaded = Config::load(&destination.join("config.toml"));
140 assert_eq!(loaded.diagnostics, []);
141 assert_eq!(loaded.config.terminal.font_size, FontSize(17.5));
142 assert_eq!(
143 loaded.config.terminal.palettes.dark.background.as_str(),
144 "#123456"
145 );
146 assert_eq!(
147 fs::read_to_string(legacy.join("config.toml")).unwrap(),
148 config
149 );
150 let restored: crate::WorkflowTypeDefinition =
151 toml::from_str(&fs::read_to_string(destination.join("workflows/mine.toml")).unwrap())
152 .unwrap();
153 assert_eq!(restored, workflow);
154 assert_eq!(
155 fs::read_link(destination.join("linked.toml")).unwrap(),
156 Path::new("parts/settings.toml")
157 );
158 fs::write(
159 destination.join("config.toml"),
160 "[terminal]\nfont_size = 22\n",
161 )
162 .unwrap();
163 fs::write(legacy.join("config.toml"), "invalid").unwrap();
164 adopt(&legacy, &destination).unwrap();
165 assert_eq!(
166 Config::load(&destination.join("config.toml"))
167 .config
168 .terminal
169 .font_size,
170 FontSize(22.0)
171 );
172 }
173
174 #[test]
175 fn user_managed_root_links_keep_external_relative_imports_at_the_same_location() {
176 let root = tempfile::tempdir().unwrap();
177 let xdg = root.path().join("xdg");
178 let files = root.path().join("dotfiles/settings");
179 fs::create_dir_all(&xdg).unwrap();
180 fs::create_dir_all(&files).unwrap();
181 fs::write(files.join("config.toml"), "import = '../shared.toml'\n").unwrap();
182 fs::write(
183 root.path().join("dotfiles/shared.toml"),
184 "[terminal]\nfont_size = 19\n",
185 )
186 .unwrap();
187 let legacy = xdg.join("twine");
188 let destination = xdg.join("handshake");
189 std::os::unix::fs::symlink(&files, &legacy).unwrap();
190 adopt(&legacy, &destination).unwrap();
191 assert!(destination.is_symlink());
192 let loaded = Config::load(&destination.join("config.toml"));
193 assert_eq!(loaded.diagnostics, []);
194 assert_eq!(loaded.config.terminal.font_size, FontSize(19.0));
195 fs::remove_file(&legacy).unwrap();
196 assert_eq!(
197 Config::load(&destination.join("config.toml"))
198 .config
199 .terminal
200 .font_size,
201 FontSize(19.0)
202 );
203 }
204
205 #[test]
206 fn failed_copy_and_interrupted_private_tree_do_not_establish_a_destination() {
207 let root = tempfile::tempdir().unwrap();
208 let legacy = root.path().join("twine");
209 let destination = root.path().join("handshake");
210 fs::write(&legacy, b"unavailable").unwrap();
211 assert!(adopt(&legacy, &destination).is_err());
212 assert!(!destination.exists());
213 fs::remove_file(&legacy).unwrap();
214 fs::create_dir(&legacy).unwrap();
215 fs::write(legacy.join("config.toml"), "[terminal]\nfont_size = 18\n").unwrap();
216 fs::create_dir(root.path().join(".handshake-config-interrupted")).unwrap();
217 adopt(&legacy, &destination).unwrap();
218 assert_eq!(
219 Config::load(&destination.join("config.toml"))
220 .config
221 .terminal
222 .font_size,
223 FontSize(18.0)
224 );
225 }
226}