Skip to main content

oboron_cli_core/
migration.rs

1//! One-time migration of the legacy `~/.ob/` config directory to
2//! `~/.oboron/`.
3//!
4//! Older releases of `oboron-cli` stored their config at `~/.ob/`.
5//! The current tooling uses `~/.oboron/`. [`ensure_config_root_migrated`]
6//! detects a leftover `~/.ob/` real-directory, renames it to
7//! `~/.oboron/`, and leaves a `~/.ob` → `~/.oboron` symlink so any
8//! older binary still installed on the system reads/writes the same
9//! data via the legacy path.
10
11use anyhow::{anyhow, bail, Context, Result};
12use std::path::{Path, PathBuf};
13
14const OLD_DIR: &str = ".ob";
15const NEW_DIR: &str = ".oboron";
16
17/// Returned by [`ensure_config_root_migrated`] when an actual migration
18/// took place. Callers print this to stderr so users know their config
19/// dir moved.
20#[derive(Debug, Clone)]
21pub struct MigrationNotice {
22    pub from: PathBuf,
23    pub to: PathBuf,
24    /// `true` if the backward-compat symlink at the old path was
25    /// successfully created. `false` on platforms where symlink
26    /// creation isn't supported or failed (e.g. Windows without
27    /// privilege); the rename itself still succeeded.
28    pub symlink_created: bool,
29}
30
31/// If `~/.ob/` exists as a real directory and `~/.oboron/` doesn't,
32/// rename it to `~/.oboron/` and create a `~/.ob` → `~/.oboron`
33/// symlink for backward compatibility with older binaries. No-op
34/// otherwise (already migrated, fresh install, or `~/.ob` is a
35/// symlink we put there ourselves).
36pub fn ensure_config_root_migrated() -> Result<Option<MigrationNotice>> {
37    let home = dirs::home_dir()
38        .ok_or_else(|| anyhow!("could not locate home directory"))?;
39    migrate_at(&home.join(OLD_DIR), &home.join(NEW_DIR))
40}
41
42/// Same logic as [`ensure_config_root_migrated`] but with explicit
43/// paths, for unit testing.
44fn migrate_at(old: &Path, new: &Path) -> Result<Option<MigrationNotice>> {
45    let old_meta = match std::fs::symlink_metadata(old) {
46        Ok(m) => m,
47        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
48        Err(e) => {
49            return Err(e).with_context(|| format!("stat {}", old.display()));
50        }
51    };
52    if old_meta.file_type().is_symlink() {
53        // Already a symlink — assume it's the compat link we (or the
54        // user) put there. Nothing to do.
55        return Ok(None);
56    }
57    if !old_meta.is_dir() {
58        // `~/.ob` exists but isn't a directory. Don't touch it.
59        return Ok(None);
60    }
61    // `~/.ob` is a real directory. Check `~/.oboron`.
62    match std::fs::symlink_metadata(new) {
63        Ok(_) => bail!(
64            "found both {} and {} — refusing to auto-migrate \
65             ambiguous state; move {} contents into {} manually \
66             and remove {}",
67            old.display(),
68            new.display(),
69            old.display(),
70            new.display(),
71            old.display(),
72        ),
73        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
74        Err(e) => {
75            return Err(e).with_context(|| format!("stat {}", new.display()));
76        }
77    }
78    // Rename `~/.ob` → `~/.oboron`. Atomic on the same filesystem.
79    std::fs::rename(old, new)
80        .with_context(|| format!("rename {} → {}", old.display(), new.display()))?;
81    let symlink_created = create_compat_symlink(old, new);
82    Ok(Some(MigrationNotice {
83        from: old.to_path_buf(),
84        to: new.to_path_buf(),
85        symlink_created,
86    }))
87}
88
89#[cfg(unix)]
90fn create_compat_symlink(link: &Path, target: &Path) -> bool {
91    std::os::unix::fs::symlink(target, link).is_ok()
92}
93
94#[cfg(not(unix))]
95fn create_compat_symlink(_link: &Path, _target: &Path) -> bool {
96    false
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use std::fs;
103
104    /// Create a fresh temp directory for one test. Cleaned up on
105    /// drop via `Drop` on the returned guard.
106    struct TmpDir(PathBuf);
107    impl TmpDir {
108        fn new(label: &str) -> Self {
109            let id = std::time::SystemTime::now()
110                .duration_since(std::time::UNIX_EPOCH)
111                .unwrap()
112                .as_nanos();
113            let p = std::env::temp_dir()
114                .join(format!("oboron-mig-{label}-{id}-{}", std::process::id()));
115            fs::create_dir_all(&p).expect("create tmp dir");
116            Self(p)
117        }
118        fn path(&self) -> &Path { &self.0 }
119    }
120    impl Drop for TmpDir {
121        fn drop(&mut self) {
122            let _ = fs::remove_dir_all(&self.0);
123        }
124    }
125
126    #[test]
127    fn neither_dir_exists_is_noop() {
128        let t = TmpDir::new("neither");
129        let old = t.path().join(".ob");
130        let new = t.path().join(".oboron");
131        assert!(migrate_at(&old, &new).unwrap().is_none());
132        assert!(!old.exists());
133        assert!(!new.exists());
134    }
135
136    #[test]
137    fn only_new_dir_is_noop() {
138        let t = TmpDir::new("only-new");
139        let old = t.path().join(".ob");
140        let new = t.path().join(".oboron");
141        fs::create_dir(&new).unwrap();
142        fs::write(new.join("config.json"), "{}").unwrap();
143        assert!(migrate_at(&old, &new).unwrap().is_none());
144        assert!(!old.exists());
145        assert!(new.is_dir());
146        assert!(new.join("config.json").is_file());
147    }
148
149    #[test]
150    fn only_old_dir_migrates() {
151        let t = TmpDir::new("only-old");
152        let old = t.path().join(".ob");
153        let new = t.path().join(".oboron");
154        fs::create_dir(&old).unwrap();
155        fs::write(old.join("config.json"), r#"{"profile":"x"}"#).unwrap();
156
157        let notice = migrate_at(&old, &new).unwrap().expect("expected migration");
158        assert_eq!(notice.from, old);
159        assert_eq!(notice.to, new);
160        #[cfg(unix)]
161        assert!(notice.symlink_created);
162
163        assert!(new.is_dir());
164        assert!(new.join("config.json").is_file());
165        #[cfg(unix)]
166        {
167            // Old path is now a symlink to new.
168            let meta = fs::symlink_metadata(&old).unwrap();
169            assert!(meta.file_type().is_symlink());
170            assert_eq!(fs::read_link(&old).unwrap(), new);
171            // And reading through the symlink finds the file.
172            assert!(old.join("config.json").is_file());
173        }
174    }
175
176    #[test]
177    fn both_dirs_present_errors() {
178        let t = TmpDir::new("both");
179        let old = t.path().join(".ob");
180        let new = t.path().join(".oboron");
181        fs::create_dir(&old).unwrap();
182        fs::create_dir(&new).unwrap();
183        let err = migrate_at(&old, &new).unwrap_err();
184        assert!(err.to_string().contains("ambiguous"));
185        // Both still present, untouched.
186        assert!(old.is_dir());
187        assert!(new.is_dir());
188    }
189
190    #[cfg(unix)]
191    #[test]
192    fn old_path_already_symlink_is_noop() {
193        let t = TmpDir::new("symlink");
194        let old = t.path().join(".ob");
195        let new = t.path().join(".oboron");
196        fs::create_dir(&new).unwrap();
197        std::os::unix::fs::symlink(&new, &old).unwrap();
198        assert!(migrate_at(&old, &new).unwrap().is_none());
199        // Symlink still there.
200        let meta = fs::symlink_metadata(&old).unwrap();
201        assert!(meta.file_type().is_symlink());
202    }
203
204    #[test]
205    fn idempotent_under_repeated_calls() {
206        let t = TmpDir::new("idempotent");
207        let old = t.path().join(".ob");
208        let new = t.path().join(".oboron");
209        fs::create_dir(&old).unwrap();
210        fs::write(old.join("a"), "x").unwrap();
211        // First call migrates.
212        assert!(migrate_at(&old, &new).unwrap().is_some());
213        // Second call is a no-op (old is now a symlink on Unix; on
214        // non-Unix the rename succeeded but no symlink was made, so
215        // the second call sees a missing old path — also a no-op).
216        assert!(migrate_at(&old, &new).unwrap().is_none());
217    }
218}