|
7 | 7 | //! File: `~/.ironclaw/.env` (standard dotenvy format) |
8 | 8 |
|
9 | 9 | use std::path::PathBuf; |
| 10 | +use std::sync::LazyLock; |
| 11 | + |
| 12 | +const IRONCLAW_BASE_DIR_ENV: &str = "IRONCLAW_BASE_DIR"; |
| 13 | + |
| 14 | +/// Lazily computed IronClaw base directory, cached for the lifetime of the process. |
| 15 | +static IRONCLAW_BASE_DIR: LazyLock<PathBuf> = LazyLock::new(compute_ironclaw_base_dir); |
| 16 | + |
| 17 | +/// Compute the IronClaw base directory from environment. |
| 18 | +/// |
| 19 | +/// This is the underlying implementation used by both the public |
| 20 | +/// `ironclaw_base_dir()` function (which caches the result) and tests |
| 21 | +/// (which need to verify different configurations). |
| 22 | +fn compute_ironclaw_base_dir() -> PathBuf { |
| 23 | + std::env::var(IRONCLAW_BASE_DIR_ENV) |
| 24 | + .map(PathBuf::from) |
| 25 | + .map(|path| { |
| 26 | + if path.as_os_str().is_empty() { |
| 27 | + default_base_dir() |
| 28 | + } else if path.to_string_lossy().contains('\0') { |
| 29 | + eprintln!("Warning: IRONCLAW_BASE_DIR contains null bytes, using default"); |
| 30 | + default_base_dir() |
| 31 | + } else { |
| 32 | + path |
| 33 | + } |
| 34 | + }) |
| 35 | + .unwrap_or_else(|_| default_base_dir()) |
| 36 | +} |
| 37 | + |
| 38 | +/// Get the default IronClaw base directory (~/.ironclaw). |
| 39 | +/// |
| 40 | +/// Logs a warning if the home directory cannot be determined and falls back to |
| 41 | +/// the current directory. |
| 42 | +fn default_base_dir() -> PathBuf { |
| 43 | + if let Some(home) = dirs::home_dir() { |
| 44 | + home.join(".ironclaw") |
| 45 | + } else { |
| 46 | + eprintln!("Warning: Could not determine home directory, using current directory"); |
| 47 | + PathBuf::from(".").join(".ironclaw") |
| 48 | + } |
| 49 | +} |
| 50 | + |
| 51 | +/// Get the IronClaw base directory. |
| 52 | +/// |
| 53 | +/// Override with `IRONCLAW_BASE_DIR` environment variable. |
| 54 | +/// Defaults to `~/.ironclaw` (or `./.ironclaw` if home directory cannot be determined). |
| 55 | +/// |
| 56 | +/// Thread-safe: the value is computed once and cached in a `LazyLock`. |
| 57 | +/// |
| 58 | +/// # Environment Variable Behavior |
| 59 | +/// - If `IRONCLAW_BASE_DIR` is set to a non-empty path, that path is used. |
| 60 | +/// - If `IRONCLAW_BASE_DIR` is set to an empty string, it is treated as unset. |
| 61 | +/// - If `IRONCLAW_BASE_DIR` contains null bytes, a warning is printed and the default is used. |
| 62 | +/// - If the home directory cannot be determined, a warning is printed and the current directory is used. |
| 63 | +/// |
| 64 | +/// # Returns |
| 65 | +/// A `PathBuf` pointing to the base directory. The path is not validated |
| 66 | +/// for existence. |
| 67 | +pub fn ironclaw_base_dir() -> PathBuf { |
| 68 | + IRONCLAW_BASE_DIR.clone() |
| 69 | +} |
10 | 70 |
|
11 | 71 | /// Path to the IronClaw-specific `.env` file: `~/.ironclaw/.env`. |
12 | 72 | pub fn ironclaw_env_path() -> PathBuf { |
13 | | - dirs::home_dir() |
14 | | - .unwrap_or_else(|| PathBuf::from(".")) |
15 | | - .join(".ironclaw") |
16 | | - .join(".env") |
| 73 | + ironclaw_base_dir().join(".env") |
17 | 74 | } |
18 | 75 |
|
19 | 76 | /// Load env vars from `~/.ironclaw/.env` (in addition to the standard `.env`). |
@@ -185,9 +242,7 @@ pub async fn migrate_disk_to_db( |
185 | 242 | store: &dyn crate::db::Database, |
186 | 243 | user_id: &str, |
187 | 244 | ) -> Result<(), MigrationError> { |
188 | | - let ironclaw_dir = dirs::home_dir() |
189 | | - .unwrap_or_else(|| PathBuf::from(".")) |
190 | | - .join(".ironclaw"); |
| 245 | + let ironclaw_dir = ironclaw_base_dir(); |
191 | 246 | let legacy_settings_path = ironclaw_dir.join("settings.json"); |
192 | 247 |
|
193 | 248 | if !legacy_settings_path.exists() { |
@@ -321,8 +376,11 @@ pub enum MigrationError { |
321 | 376 | #[cfg(test)] |
322 | 377 | mod tests { |
323 | 378 | use super::*; |
| 379 | + use std::sync::Mutex; |
324 | 380 | use tempfile::tempdir; |
325 | 381 |
|
| 382 | + static ENV_MUTEX: Mutex<()> = Mutex::new(()); |
| 383 | + |
326 | 384 | #[test] |
327 | 385 | fn test_save_and_load_database_url() { |
328 | 386 | let dir = tempdir().unwrap(); |
@@ -580,4 +638,101 @@ INJECTED="pwned"#; |
580 | 638 | assert!(onboard.is_some(), "ONBOARD_COMPLETED must be present"); |
581 | 639 | assert_eq!(onboard.unwrap().1, "true"); |
582 | 640 | } |
| 641 | + |
| 642 | + #[test] |
| 643 | + fn test_ironclaw_base_dir_default() { |
| 644 | + // This test must run first (or in isolation) before the LazyLock is initialized. |
| 645 | + // It verifies that when IRONCLAW_BASE_DIR is not set, the default path is used. |
| 646 | + let _guard = ENV_MUTEX.lock().unwrap(); |
| 647 | + let old_val = std::env::var("IRONCLAW_BASE_DIR").ok(); |
| 648 | + unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") }; |
| 649 | + |
| 650 | + // Force re-evaluation by calling the computation function directly |
| 651 | + let path = compute_ironclaw_base_dir(); |
| 652 | + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); |
| 653 | + assert_eq!(path, home.join(".ironclaw")); |
| 654 | + |
| 655 | + if let Some(val) = old_val { |
| 656 | + unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) }; |
| 657 | + } |
| 658 | + } |
| 659 | + |
| 660 | + #[test] |
| 661 | + fn test_ironclaw_base_dir_env_override() { |
| 662 | + // This test verifies that when IRONCLAW_BASE_DIR is set, |
| 663 | + // the custom path is used. Must run before LazyLock is initialized. |
| 664 | + let _guard = ENV_MUTEX.lock().unwrap(); |
| 665 | + let old_val = std::env::var("IRONCLAW_BASE_DIR").ok(); |
| 666 | + unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/custom/ironclaw/path") }; |
| 667 | + |
| 668 | + // Force re-evaluation by calling the computation function directly |
| 669 | + let path = compute_ironclaw_base_dir(); |
| 670 | + assert_eq!(path, std::path::PathBuf::from("/custom/ironclaw/path")); |
| 671 | + |
| 672 | + if let Some(val) = old_val { |
| 673 | + unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) }; |
| 674 | + } else { |
| 675 | + unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") }; |
| 676 | + } |
| 677 | + } |
| 678 | + |
| 679 | + #[test] |
| 680 | + fn test_ironclaw_env_path_uses_base_dir() { |
| 681 | + // Verifies that ironclaw_env_path correctly joins .env to the base dir. |
| 682 | + // Uses compute_ironclaw_base_dir directly to avoid LazyLock caching. |
| 683 | + let _guard = ENV_MUTEX.lock().unwrap(); |
| 684 | + let old_val = std::env::var("IRONCLAW_BASE_DIR").ok(); |
| 685 | + unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/my/custom/dir") }; |
| 686 | + |
| 687 | + // Test the path construction logic directly |
| 688 | + let base_path = compute_ironclaw_base_dir(); |
| 689 | + let env_path = base_path.join(".env"); |
| 690 | + assert_eq!(env_path, std::path::PathBuf::from("/my/custom/dir/.env")); |
| 691 | + |
| 692 | + if let Some(val) = old_val { |
| 693 | + unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) }; |
| 694 | + } else { |
| 695 | + unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") }; |
| 696 | + } |
| 697 | + } |
| 698 | + |
| 699 | + #[test] |
| 700 | + fn test_ironclaw_base_dir_empty_env() { |
| 701 | + // Verifies that empty IRONCLAW_BASE_DIR falls back to default. |
| 702 | + let _guard = ENV_MUTEX.lock().unwrap(); |
| 703 | + let old_val = std::env::var("IRONCLAW_BASE_DIR").ok(); |
| 704 | + unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "") }; |
| 705 | + |
| 706 | + // Force re-evaluation by calling the computation function directly |
| 707 | + let path = compute_ironclaw_base_dir(); |
| 708 | + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); |
| 709 | + assert_eq!(path, home.join(".ironclaw")); |
| 710 | + |
| 711 | + if let Some(val) = old_val { |
| 712 | + unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) }; |
| 713 | + } else { |
| 714 | + unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") }; |
| 715 | + } |
| 716 | + } |
| 717 | + |
| 718 | + #[test] |
| 719 | + fn test_ironclaw_base_dir_special_chars() { |
| 720 | + // Verifies that paths with special characters are handled correctly. |
| 721 | + let _guard = ENV_MUTEX.lock().unwrap(); |
| 722 | + let old_val = std::env::var("IRONCLAW_BASE_DIR").ok(); |
| 723 | + unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/tmp/test_with-special.chars") }; |
| 724 | + |
| 725 | + // Force re-evaluation by calling the computation function directly |
| 726 | + let path = compute_ironclaw_base_dir(); |
| 727 | + assert_eq!( |
| 728 | + path, |
| 729 | + std::path::PathBuf::from("/tmp/test_with-special.chars") |
| 730 | + ); |
| 731 | + |
| 732 | + if let Some(val) = old_val { |
| 733 | + unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) }; |
| 734 | + } else { |
| 735 | + unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") }; |
| 736 | + } |
| 737 | + } |
583 | 738 | } |
0 commit comments