From 837592e48a2af1ece2c6f6985df1b0f8b0560a58 Mon Sep 17 00:00:00 2001 From: Christian Visintin Date: Mon, 8 Jun 2026 16:15:35 +0200 Subject: [PATCH] feat(config): move config dir to ~/.config/termscp on macOS and %USERPROFILE%\.termscp on Windows Resolve the config directory through a single per-platform config_dir() function instead of relying on dirs::config_dir everywhere: - macOS: ~/.config/termscp (was ~/Library/Application Support/termscp) - Windows: %USERPROFILE%\.termscp (was roaming %APPDATA%\termscp) - Linux/other: /termscp (unchanged) Existing users are migrated automatically on first run: when the new directory is absent and the legacy location exists, the whole config directory is moved to the new path. The cache directory stays at the platform-native location. Closes #431 --- src/system/environment.rs | 176 +++++++++++++++++++++++++++++++++----- 1 file changed, 154 insertions(+), 22 deletions(-) diff --git a/src/system/environment.rs b/src/system/environment.rs index 09b297b..d991a46 100644 --- a/src/system/environment.rs +++ b/src/system/environment.rs @@ -6,24 +6,89 @@ use std::path::{Path, PathBuf}; use std::sync::LazyLock; +/// termscp's configuration directory, including the `termscp` project subdirectory. +/// +/// See [`config_dir`] for the per-platform locations. #[cfg(not(test))] -static CONF_DIR: LazyLock> = LazyLock::new(dirs::config_dir); +static CONF_DIR: LazyLock> = LazyLock::new(config_dir); #[cfg(test)] -static CONF_DIR: LazyLock> = LazyLock::new(|| Some(std::env::temp_dir())); +static CONF_DIR: LazyLock> = + LazyLock::new(|| Some(std::env::temp_dir().join("termscp"))); +/// termscp's cache directory, including the `termscp` project subdirectory. +/// +/// The cache directory stays at the platform-native location (`dirs::cache_dir`) +/// on every platform. #[cfg(not(test))] -static CACHE_DIR: LazyLock> = LazyLock::new(dirs::cache_dir); +static CACHE_DIR: LazyLock> = + LazyLock::new(|| dirs::cache_dir().map(|dir| dir.join("termscp"))); #[cfg(test)] -static CACHE_DIR: LazyLock> = LazyLock::new(|| Some(std::env::temp_dir())); +static CACHE_DIR: LazyLock> = + LazyLock::new(|| Some(std::env::temp_dir().join("termscp"))); + +/// Resolves termscp's configuration directory for the current platform, +/// including the `termscp` project subdirectory. +/// +/// The location is platform-specific: +/// +/// - **Linux** (and other Unix): `$XDG_CONFIG_HOME/termscp` (usually `~/.config/termscp`) +/// - **macOS**: `~/.config/termscp` (instead of `~/Library/Application Support/termscp`) +/// - **Windows**: `%USERPROFILE%\.termscp` (instead of the roaming `%APPDATA%\termscp`) +#[cfg(not(test))] +fn config_dir() -> Option { + #[cfg(macos)] + { + // macOS: use ~/.config/termscp for consistency with Linux and easier + // CLI access, instead of ~/Library/Application Support/termscp. + dirs::home_dir().map(|home| home.join(".config").join("termscp")) + } + #[cfg(win)] + { + // Windows: use %USERPROFILE%\.termscp instead of the roaming %APPDATA%. + dirs::home_dir().map(|home| home.join(".termscp")) + } + #[cfg(not(any(macos, win)))] + { + // Linux and other platforms: keep the XDG-compliant location. + dirs::config_dir().map(|dir| dir.join("termscp")) + } +} + +/// Returns the legacy (pre-1.1.0) configuration directory, if the current +/// platform used a different location than the one returned by [`config_dir`]. +/// +/// Used to migrate an existing user configuration to the new location. +/// Returns `None` on platforms where the location did not change (e.g. Linux). +#[cfg(not(test))] +fn legacy_config_dir() -> Option { + #[cfg(any(macos, win))] + { + // Before 1.1.0 the config dir was always `dirs::config_dir()/termscp`. + dirs::config_dir().map(|dir| dir.join("termscp")) + } + #[cfg(not(any(macos, win)))] + { + None + } +} + +/// In tests we never want to touch the developer's real configuration, so there +/// is no legacy directory to migrate from. +#[cfg(test)] +fn legacy_config_dir() -> Option { + None +} /// Get termscp config directory path and initialize it. /// Returns None if it's not possible to initialize it pub fn init_config_dir() -> Result, String> { - if let Some(dir) = CONF_DIR.as_deref() { - init_dir(dir).map(Option::Some) - } else { - Ok(None) - } + let Some(dir) = CONF_DIR.as_deref() else { + return Ok(None); + }; + // Migrate an existing configuration from the legacy location, if necessary, + // before creating (and thus claiming) the new directory. + migrate_config_dir(legacy_config_dir().as_deref(), dir)?; + init_dir(dir).map(Option::Some) } /// Get termscp cache directory path and initialize it. @@ -36,21 +101,36 @@ pub fn init_cache_dir() -> Result, String> { } } -/// Init a termscp env dir +/// Moves the legacy configuration directory to the new location when a migration +/// is needed. +/// +/// A migration happens only when `new_dir` does not exist yet and `legacy` is +/// `Some` and points to an existing directory; otherwise this is a no-op. +fn migrate_config_dir(legacy: Option<&Path>, new_dir: &Path) -> Result<(), String> { + let Some(legacy) = legacy else { + return Ok(()); + }; + // Nothing to migrate if the new dir is already there or the legacy one is gone. + if new_dir.exists() || !legacy.exists() { + return Ok(()); + } + // Ensure the parent of the new dir exists before moving into it. + if let Some(parent) = new_dir.parent() { + std::fs::create_dir_all(parent).map_err(|err| err.to_string())?; + } + std::fs::rename(legacy, new_dir).map_err(|err| err.to_string()) +} + +/// Init a termscp env dir, creating it if it doesn't already exist. fn init_dir(p: &Path) -> Result { - // Get path of bookmarks - let mut p: PathBuf = p.to_path_buf(); - // Append termscp dir - p.push("termscp/"); - // If directory doesn't exist, create it - if p.exists() { - return Ok(p); - } - // directory doesn't exist; create dir recursively - match std::fs::create_dir_all(p.as_path()) { - Ok(_) => Ok(p), - Err(err) => Err(err.to_string()), + // If the directory already exists, there's nothing to do. + if p.is_dir() { + return Ok(p.to_path_buf()); } + // Directory doesn't exist; create it recursively. This fails if the path is + // already occupied by a non-directory file. + std::fs::create_dir_all(p).map_err(|err| err.to_string())?; + Ok(p.to_path_buf()) } /// Get paths for bookmarks client @@ -140,6 +220,58 @@ mod tests { assert!(std::fs::remove_file(conf_dir.as_path()).is_ok()); } + #[test] + #[serial] + fn should_migrate_legacy_config_dir() { + let base = std::env::temp_dir().join("termscp-migrate-test-move"); + let legacy = base.join("legacy"); + let new_dir = base.join("new"); + // Clean up any leftovers from a previous run + let _ = std::fs::remove_dir_all(&base); + // Set up a legacy dir holding a config file + std::fs::create_dir_all(&legacy).unwrap(); + let legacy_file = legacy.join("config.toml"); + std::fs::write(&legacy_file, b"hello").unwrap(); + // Migrate + assert!(migrate_config_dir(Some(legacy.as_path()), new_dir.as_path()).is_ok()); + // Legacy dir is gone, new dir holds the file + assert!(!legacy.exists()); + assert!(new_dir.join("config.toml").exists()); + // Cleanup + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + #[serial] + fn should_not_migrate_when_new_dir_exists() { + let base = std::env::temp_dir().join("termscp-migrate-test-keep"); + let legacy = base.join("legacy"); + let new_dir = base.join("new"); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::create_dir_all(&new_dir).unwrap(); + // Both exist: must be a no-op, legacy left untouched + assert!(migrate_config_dir(Some(legacy.as_path()), new_dir.as_path()).is_ok()); + assert!(legacy.exists()); + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + #[serial] + fn should_not_migrate_without_legacy_dir() { + let base = std::env::temp_dir().join("termscp-migrate-test-none"); + let new_dir = base.join("new"); + let _ = std::fs::remove_dir_all(&base); + // No legacy dir at all + assert!(migrate_config_dir(None, new_dir.as_path()).is_ok()); + assert!(!new_dir.exists()); + // Legacy provided but missing + let missing = base.join("missing"); + assert!(migrate_config_dir(Some(missing.as_path()), new_dir.as_path()).is_ok()); + assert!(!new_dir.exists()); + let _ = std::fs::remove_dir_all(&base); + } + #[test] #[serial] fn test_system_environment_get_bookmarks_paths() {