use anyhow::{anyhow, bail, Context, Result};
use std::path::{Path, PathBuf};
const OLD_DIR: &str = ".ob";
const NEW_DIR: &str = ".oboron";
#[derive(Debug, Clone)]
pub struct MigrationNotice {
pub from: PathBuf,
pub to: PathBuf,
pub symlink_created: bool,
}
pub fn ensure_config_root_migrated() -> Result<Option<MigrationNotice>> {
let home = dirs::home_dir()
.ok_or_else(|| anyhow!("could not locate home directory"))?;
migrate_at(&home.join(OLD_DIR), &home.join(NEW_DIR))
}
fn migrate_at(old: &Path, new: &Path) -> Result<Option<MigrationNotice>> {
let old_meta = match std::fs::symlink_metadata(old) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(e).with_context(|| format!("stat {}", old.display()));
}
};
if old_meta.file_type().is_symlink() {
return Ok(None);
}
if !old_meta.is_dir() {
return Ok(None);
}
match std::fs::symlink_metadata(new) {
Ok(_) => bail!(
"found both {} and {} — refusing to auto-migrate \
ambiguous state; move {} contents into {} manually \
and remove {}",
old.display(),
new.display(),
old.display(),
new.display(),
old.display(),
),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return Err(e).with_context(|| format!("stat {}", new.display()));
}
}
std::fs::rename(old, new)
.with_context(|| format!("rename {} → {}", old.display(), new.display()))?;
let symlink_created = create_compat_symlink(old, new);
Ok(Some(MigrationNotice {
from: old.to_path_buf(),
to: new.to_path_buf(),
symlink_created,
}))
}
#[cfg(unix)]
fn create_compat_symlink(link: &Path, target: &Path) -> bool {
std::os::unix::fs::symlink(target, link).is_ok()
}
#[cfg(not(unix))]
fn create_compat_symlink(_link: &Path, _target: &Path) -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
struct TmpDir(PathBuf);
impl TmpDir {
fn new(label: &str) -> Self {
let id = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let p = std::env::temp_dir()
.join(format!("oboron-mig-{label}-{id}-{}", std::process::id()));
fs::create_dir_all(&p).expect("create tmp dir");
Self(p)
}
fn path(&self) -> &Path { &self.0 }
}
impl Drop for TmpDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn neither_dir_exists_is_noop() {
let t = TmpDir::new("neither");
let old = t.path().join(".ob");
let new = t.path().join(".oboron");
assert!(migrate_at(&old, &new).unwrap().is_none());
assert!(!old.exists());
assert!(!new.exists());
}
#[test]
fn only_new_dir_is_noop() {
let t = TmpDir::new("only-new");
let old = t.path().join(".ob");
let new = t.path().join(".oboron");
fs::create_dir(&new).unwrap();
fs::write(new.join("config.json"), "{}").unwrap();
assert!(migrate_at(&old, &new).unwrap().is_none());
assert!(!old.exists());
assert!(new.is_dir());
assert!(new.join("config.json").is_file());
}
#[test]
fn only_old_dir_migrates() {
let t = TmpDir::new("only-old");
let old = t.path().join(".ob");
let new = t.path().join(".oboron");
fs::create_dir(&old).unwrap();
fs::write(old.join("config.json"), r#"{"profile":"x"}"#).unwrap();
let notice = migrate_at(&old, &new).unwrap().expect("expected migration");
assert_eq!(notice.from, old);
assert_eq!(notice.to, new);
#[cfg(unix)]
assert!(notice.symlink_created);
assert!(new.is_dir());
assert!(new.join("config.json").is_file());
#[cfg(unix)]
{
let meta = fs::symlink_metadata(&old).unwrap();
assert!(meta.file_type().is_symlink());
assert_eq!(fs::read_link(&old).unwrap(), new);
assert!(old.join("config.json").is_file());
}
}
#[test]
fn both_dirs_present_errors() {
let t = TmpDir::new("both");
let old = t.path().join(".ob");
let new = t.path().join(".oboron");
fs::create_dir(&old).unwrap();
fs::create_dir(&new).unwrap();
let err = migrate_at(&old, &new).unwrap_err();
assert!(err.to_string().contains("ambiguous"));
assert!(old.is_dir());
assert!(new.is_dir());
}
#[cfg(unix)]
#[test]
fn old_path_already_symlink_is_noop() {
let t = TmpDir::new("symlink");
let old = t.path().join(".ob");
let new = t.path().join(".oboron");
fs::create_dir(&new).unwrap();
std::os::unix::fs::symlink(&new, &old).unwrap();
assert!(migrate_at(&old, &new).unwrap().is_none());
let meta = fs::symlink_metadata(&old).unwrap();
assert!(meta.file_type().is_symlink());
}
#[test]
fn idempotent_under_repeated_calls() {
let t = TmpDir::new("idempotent");
let old = t.path().join(".ob");
let new = t.path().join(".oboron");
fs::create_dir(&old).unwrap();
fs::write(old.join("a"), "x").unwrap();
assert!(migrate_at(&old, &new).unwrap().is_some());
assert!(migrate_at(&old, &new).unwrap().is_none());
}
}