diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index a09569a39..20601cf42 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1964,18 +1964,34 @@ pub fn default_config_path() -> Result { Ok(primary) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigMigration { + pub legacy_path: PathBuf, + pub primary_path: PathBuf, +} + +impl ConfigMigration { + pub fn user_notice(&self) -> String { + format!( + "Migrated legacy config from {} to {}. Use the .codewhale path for future edits; the .deepseek file remains only as a compatibility fallback.", + self.legacy_path.display(), + self.primary_path.display() + ) + } +} + /// v0.8.44: one-time migration from `~/.deepseek/config.toml` to /// `~/.codewhale/config.toml`. Called on first launch after the config /// is loaded; copies the legacy file if the primary doesn't exist yet. /// Never overwrites an existing primary config. -pub fn migrate_config_if_needed() -> Result<()> { +pub fn migrate_config_if_needed() -> Result> { let primary = codewhale_home()?.join(CONFIG_FILE_NAME); if primary.exists() { - return Ok(()); + return Ok(None); } let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME); if !legacy.exists() { - return Ok(()); + return Ok(None); } // Copy the config to the new home. if let Some(parent) = primary.parent() { @@ -1988,7 +2004,10 @@ pub fn migrate_config_if_needed() -> Result<()> { legacy.display(), primary.display() ); - Ok(()) + Ok(Some(ConfigMigration { + legacy_path: legacy, + primary_path: primary, + })) } fn parse_bool(raw: &str) -> Result { @@ -3112,6 +3131,111 @@ unix_socket_path = "/tmp/cw-hooks.sock" ); } + #[test] + fn migrate_config_reports_copied_legacy_path() { + let _lock = env_lock(); + struct HomeEnvGuard { + home: Option, + userprofile: Option, + codewhale_home: Option, + } + + impl Drop for HomeEnvGuard { + fn drop(&mut self) { + // Safety: test-only environment mutation is serialized by env_lock(). + unsafe { + match self.home.take() { + Some(value) => env::set_var("HOME", value), + None => env::remove_var("HOME"), + } + match self.userprofile.take() { + Some(value) => env::set_var("USERPROFILE", value), + None => env::remove_var("USERPROFILE"), + } + match self.codewhale_home.take() { + Some(value) => env::set_var("CODEWHALE_HOME", value), + None => env::remove_var("CODEWHALE_HOME"), + } + } + } + } + + struct LegacyConfigGuard { + path: PathBuf, + original: Option>, + } + + impl LegacyConfigGuard { + fn install(path: PathBuf, contents: &[u8]) -> Self { + let original = fs::read(&path).ok(); + fs::create_dir_all(path.parent().expect("legacy config parent")) + .expect("legacy dir"); + fs::write(&path, contents).expect("legacy config"); + Self { path, original } + } + } + + impl Drop for LegacyConfigGuard { + fn drop(&mut self) { + if let Some(original) = self.original.take() { + let _ = fs::write(&self.path, original); + } else { + let _ = fs::remove_file(&self.path); + if let Some(parent) = self.path.parent() { + let _ = fs::remove_dir(parent); + } + } + } + } + + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let home = std::env::temp_dir().join(format!( + "codewhale-config-migration-{}-{unique}", + std::process::id() + )); + #[cfg(windows)] + let legacy_dir = legacy_deepseek_home().expect("legacy home"); + #[cfg(not(windows))] + let legacy_dir = home.join(LEGACY_APP_DIR); + let primary_dir = home.join(CODEWHALE_APP_DIR); + let legacy_config = legacy_dir.join(CONFIG_FILE_NAME); + let _legacy = + LegacyConfigGuard::install(legacy_config.clone(), b"provider = \"deepseek\"\n"); + + let _env = HomeEnvGuard { + home: env::var_os("HOME"), + userprofile: env::var_os("USERPROFILE"), + codewhale_home: env::var_os("CODEWHALE_HOME"), + }; + // Safety: test-only environment mutation is serialized by env_lock(). + unsafe { + env::set_var("HOME", &home); + env::set_var("USERPROFILE", &home); + env::set_var("CODEWHALE_HOME", &primary_dir); + } + + let migration = migrate_config_if_needed() + .expect("migration") + .expect("legacy config should be copied"); + + assert_eq!(migration.legacy_path, legacy_config); + assert_eq!(migration.primary_path, primary_dir.join(CONFIG_FILE_NAME)); + let notice = migration.user_notice(); + assert!(notice.contains(&legacy_dir.join(CONFIG_FILE_NAME).display().to_string())); + assert!(notice.contains(&primary_dir.join(CONFIG_FILE_NAME).display().to_string())); + assert!(notice.contains(".codewhale path for future edits")); + assert!(notice.contains(".deepseek file remains only as a compatibility fallback")); + assert_eq!( + fs::read_to_string(primary_dir.join(CONFIG_FILE_NAME)).expect("primary config"), + "provider = \"deepseek\"\n" + ); + + let _ = fs::remove_dir_all(home); + } + #[test] fn normalize_config_file_path_rejects_traversal() { let err = normalize_config_file_path(PathBuf::from("../config.toml")) diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 9feaaac46..70dc86b3d 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -4985,8 +4985,12 @@ async fn run_interactive( // v0.8.44: migrate config from ~/.deepseek/ to ~/.codewhale/ on first // launch. Non-fatal — existing installs keep working either way. - if let Err(err) = codewhale_config::migrate_config_if_needed() { - logging::warn(format!("Config migration skipped: {err}")); + match codewhale_config::migrate_config_if_needed() { + Ok(Some(migration)) => { + eprintln!("{}", migration.user_notice()); + } + Ok(None) => {} + Err(err) => logging::warn(format!("Config migration skipped: {err}")), } let model = config.default_model();