oboron_cli_core/
migration.rs1use anyhow::{anyhow, bail, Context, Result};
12use std::path::{Path, PathBuf};
13
14const OLD_DIR: &str = ".ob";
15const NEW_DIR: &str = ".oboron";
16
17#[derive(Debug, Clone)]
21pub struct MigrationNotice {
22 pub from: PathBuf,
23 pub to: PathBuf,
24 pub symlink_created: bool,
29}
30
31pub 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
42fn 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 return Ok(None);
56 }
57 if !old_meta.is_dir() {
58 return Ok(None);
60 }
61 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 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 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 let meta = fs::symlink_metadata(&old).unwrap();
169 assert!(meta.file_type().is_symlink());
170 assert_eq!(fs::read_link(&old).unwrap(), new);
171 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 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 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 assert!(migrate_at(&old, &new).unwrap().is_some());
213 assert!(migrate_at(&old, &new).unwrap().is_none());
217 }
218}