Skip to main content

handshake_core/config/
migration.rs

1//! Adopts the legacy config tree without rewriting user files or following imported symlinks.
2use 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    // An established Handshake tree is authoritative, including a user-managed directory symlink.
29    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        // Preserve the user-managed root's identity: copying its children would rebase ../ imports
57        // and relative symlinks. The resolved target survives removal of the old root link.
58        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            // Preserve link targets and relative layout, rather than copying third-party trees.
90            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}