diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 870f3c67..57ea9781 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -2026,18 +2026,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() { @@ -2050,7 +2066,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 { @@ -3295,6 +3314,83 @@ 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"), + } + } + } + } + + 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() + )); + let legacy_dir = home.join(LEGACY_APP_DIR); + let primary_dir = home.join(CODEWHALE_APP_DIR); + fs::create_dir_all(&legacy_dir).expect("legacy dir"); + fs::write( + legacy_dir.join(CONFIG_FILE_NAME), + "provider = \"deepseek\"\n", + ) + .expect("legacy config"); + + 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::remove_var("CODEWHALE_HOME"); + } + + let migration = migrate_config_if_needed() + .expect("migration") + .expect("legacy config should be copied"); + + assert_eq!(migration.legacy_path, legacy_dir.join(CONFIG_FILE_NAME)); + 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 376514f8..f6934d03 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -5068,8 +5068,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();