//! Per-account advisory file locking to prevent concurrent access. //! //! Uses `flock` (via `fs2`) for automatic lock release on process crash/SIGKILL. //! Lock file: `~/.local/share/tele-tui/accounts/{name}/tele-tui.lock` use fs2::FileExt; use std::fs::{self, File}; use std::path::PathBuf; /// Returns the lock file path for a given account. /// /// Path: `{data_dir}/tele-tui/accounts/{name}/tele-tui.lock` pub fn account_lock_path(account_name: &str) -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); path.push("tele-tui"); path.push("accounts"); path.push(account_name); path.push("tele-tui.lock"); path } /// Acquires an exclusive advisory lock for the given account. /// /// Creates the lock file and parent directories if needed. /// Returns the open `File` handle — the lock is held as long as this handle exists. /// /// # Errors /// /// Returns an error message if the lock is already held by another process /// or if the lock file cannot be created. pub fn acquire_lock(account_name: &str) -> Result { let lock_path = account_lock_path(account_name); // Ensure parent directory exists if let Some(parent) = lock_path.parent() { fs::create_dir_all(parent).map_err(|e| { format!( "Не удалось создать директорию для lock-файла: {}", e ) })?; } let file = File::create(&lock_path).map_err(|e| { format!("Не удалось создать lock-файл {}: {}", lock_path.display(), e) })?; file.try_lock_exclusive().map_err(|_| { format!( "Аккаунт '{}' уже используется другим экземпляром tele-tui.\n\ Lock-файл: {}", account_name, lock_path.display() ) })?; Ok(file) } /// Explicitly releases the lock by unlocking and dropping the file handle. /// /// Used during account switching to release the old account's lock /// before acquiring the new one. pub fn release_lock(lock_file: File) { let _ = lock_file.unlock(); drop(lock_file); } #[cfg(test)] mod tests { use super::*; #[test] fn test_lock_path_structure() { let path = account_lock_path("default"); let path_str = path.to_string_lossy(); assert!(path_str.contains("tele-tui")); assert!(path_str.contains("accounts")); assert!(path_str.contains("default")); assert!(path_str.ends_with("tele-tui.lock")); } #[test] fn test_lock_path_per_account() { let path1 = account_lock_path("work"); let path2 = account_lock_path("personal"); assert_ne!(path1, path2); assert!(path1.to_string_lossy().contains("work")); assert!(path2.to_string_lossy().contains("personal")); } #[test] fn test_acquire_and_release() { let name = "test-lock-acquire-release"; let lock = acquire_lock(name).expect("first acquire should succeed"); // Second acquire should fail (same process, exclusive lock) let result = acquire_lock(name); assert!(result.is_err(), "second acquire should fail"); assert!( result.unwrap_err().contains("уже используется"), "error should mention already in use" ); // Release and re-acquire release_lock(lock); let lock2 = acquire_lock(name).expect("acquire after release should succeed"); // Cleanup release_lock(lock2); let _ = fs::remove_file(account_lock_path(name)); } #[test] fn test_lock_released_on_drop() { let name = "test-lock-drop"; { let _lock = acquire_lock(name).expect("acquire should succeed"); // _lock dropped here } // After drop, lock should be free let lock = acquire_lock(name).expect("acquire after drop should succeed"); release_lock(lock); let _ = fs::remove_file(account_lock_path(name)); } }