Skip to main content

txgate_core/
config_loader.rs

1//! Configuration loader for the `TxGate` signing service.
2//!
3//! This module provides utilities for loading, saving, and managing configuration
4//! files from the filesystem. It handles path expansion (e.g., `~` to home directory)
5//! and provides sensible defaults when configuration files don't exist.
6//!
7//! # Default Location
8//!
9//! Configuration is stored at `~/.txgate/config.toml` by default.
10//!
11//! # Examples
12//!
13//! ## Loading configuration with defaults
14//!
15//! ```no_run
16//! use txgate_core::config_loader::load_config;
17//!
18//! // Load config from default location, using defaults if file doesn't exist
19//! let config = load_config().expect("failed to load config");
20//! ```
21//!
22//! ## Using `ConfigLoader` for more control
23//!
24//! ```no_run
25//! use txgate_core::config_loader::ConfigLoader;
26//! use std::path::PathBuf;
27//!
28//! // Create loader with default base directory (~/.txgate)
29//! let loader = ConfigLoader::new().expect("failed to create loader");
30//!
31//! // Check if config exists
32//! if loader.exists() {
33//!     let config = loader.load().expect("failed to load config");
34//!     println!("Loaded config with timeout: {}s", config.server.timeout_secs);
35//! } else {
36//!     // Write default configuration
37//!     loader.write_default().expect("failed to write default config");
38//! }
39//! ```
40//!
41//! ## Custom base directory
42//!
43//! ```no_run
44//! use txgate_core::config_loader::ConfigLoader;
45//! use std::path::PathBuf;
46//!
47//! let loader = ConfigLoader::with_base_dir(PathBuf::from("/custom/txgate"));
48//! let config = loader.load().expect("failed to load config");
49//! ```
50
51use crate::config::Config;
52use crate::error::ConfigError;
53use std::fs;
54use std::path::{Path, PathBuf};
55
56/// The default configuration file name.
57const CONFIG_FILE_NAME: &str = "config.toml";
58
59/// The default base directory name within the home directory.
60const BASE_DIR_NAME: &str = ".txgate";
61
62/// Configuration loader that handles reading and writing configuration files.
63///
64/// The `ConfigLoader` manages configuration file operations including:
65/// - Loading configuration from TOML files
66/// - Saving configuration to TOML files
67/// - Creating default configuration files
68/// - Path expansion for `~` (home directory)
69///
70/// # Examples
71///
72/// ```no_run
73/// use txgate_core::config_loader::ConfigLoader;
74///
75/// // Create with default base directory (~/.txgate)
76/// let loader = ConfigLoader::new().expect("failed to create loader");
77///
78/// // Load configuration (returns defaults if file doesn't exist)
79/// let config = loader.load().expect("failed to load config");
80/// ```
81#[derive(Debug, Clone)]
82pub struct ConfigLoader {
83    /// Base directory for `TxGate` files (default: ~/.txgate).
84    base_dir: PathBuf,
85}
86
87impl ConfigLoader {
88    /// Creates a new `ConfigLoader` with the default base directory (`~/.txgate`).
89    ///
90    /// # Errors
91    ///
92    /// Returns [`ConfigError::NoHomeDirectory`] if the home directory cannot be determined.
93    ///
94    /// # Examples
95    ///
96    /// ```no_run
97    /// use txgate_core::config_loader::ConfigLoader;
98    ///
99    /// let loader = ConfigLoader::new().expect("failed to create loader");
100    /// ```
101    pub fn new() -> Result<Self, ConfigError> {
102        let base_dir = default_base_dir()?;
103        Ok(Self { base_dir })
104    }
105
106    /// Creates a `ConfigLoader` with a custom base directory.
107    ///
108    /// # Arguments
109    ///
110    /// * `base_dir` - The base directory for `TxGate` files
111    ///
112    /// # Examples
113    ///
114    /// ```
115    /// use txgate_core::config_loader::ConfigLoader;
116    /// use std::path::PathBuf;
117    ///
118    /// let loader = ConfigLoader::with_base_dir(PathBuf::from("/custom/txgate"));
119    /// ```
120    #[must_use]
121    pub const fn with_base_dir(base_dir: PathBuf) -> Self {
122        Self { base_dir }
123    }
124
125    /// Returns the path to the configuration file.
126    ///
127    /// # Examples
128    ///
129    /// ```no_run
130    /// use txgate_core::config_loader::ConfigLoader;
131    ///
132    /// let loader = ConfigLoader::new().expect("failed to create loader");
133    /// let path = loader.config_path();
134    /// // path is something like "/home/user/.txgate/config.toml"
135    /// ```
136    #[must_use]
137    pub fn config_path(&self) -> PathBuf {
138        self.base_dir.join(CONFIG_FILE_NAME)
139    }
140
141    /// Returns the base directory for `TxGate` files.
142    ///
143    /// # Examples
144    ///
145    /// ```no_run
146    /// use txgate_core::config_loader::ConfigLoader;
147    ///
148    /// let loader = ConfigLoader::new().expect("failed to create loader");
149    /// let base = loader.base_dir();
150    /// // base is something like "/home/user/.txgate"
151    /// ```
152    #[must_use]
153    pub fn base_dir(&self) -> &Path {
154        &self.base_dir
155    }
156
157    /// Loads configuration from the file.
158    ///
159    /// If the configuration file doesn't exist, returns the default configuration.
160    /// If the file exists but contains invalid TOML, returns a parse error.
161    ///
162    /// # Errors
163    ///
164    /// Returns [`ConfigError::ParseFailed`] if the file contains invalid TOML.
165    /// Returns [`ConfigError::Io`] if there's an I/O error reading the file.
166    ///
167    /// # Examples
168    ///
169    /// ```no_run
170    /// use txgate_core::config_loader::ConfigLoader;
171    ///
172    /// let loader = ConfigLoader::new().expect("failed to create loader");
173    /// let config = loader.load().expect("failed to load config");
174    /// ```
175    pub fn load(&self) -> Result<Config, ConfigError> {
176        let config_path = self.config_path();
177
178        if !config_path.exists() {
179            return Ok(Config::default());
180        }
181
182        Self::load_from_path(&config_path)
183    }
184
185    /// Loads configuration from the file, failing if the file doesn't exist.
186    ///
187    /// Unlike [`load`](Self::load), this method returns an error if the
188    /// configuration file is not found.
189    ///
190    /// # Errors
191    ///
192    /// Returns [`ConfigError::FileNotFound`] if the configuration file doesn't exist.
193    /// Returns [`ConfigError::ParseFailed`] if the file contains invalid TOML.
194    /// Returns [`ConfigError::Io`] if there's an I/O error reading the file.
195    ///
196    /// # Examples
197    ///
198    /// ```no_run
199    /// use txgate_core::config_loader::ConfigLoader;
200    ///
201    /// let loader = ConfigLoader::new().expect("failed to create loader");
202    /// match loader.load_required() {
203    ///     Ok(config) => println!("Config loaded: {:?}", config),
204    ///     Err(e) => eprintln!("Config required but not found: {}", e),
205    /// }
206    /// ```
207    pub fn load_required(&self) -> Result<Config, ConfigError> {
208        let config_path = self.config_path();
209
210        if !config_path.exists() {
211            return Err(ConfigError::file_not_found(
212                config_path.display().to_string(),
213            ));
214        }
215
216        Self::load_from_path(&config_path)
217    }
218
219    /// Saves configuration to the file.
220    ///
221    /// Creates the base directory if it doesn't exist.
222    ///
223    /// # Errors
224    ///
225    /// Returns [`ConfigError::Io`] if there's an I/O error writing the file.
226    /// Returns [`ConfigError::ParseFailed`] if the configuration cannot be serialized.
227    ///
228    /// # Examples
229    ///
230    /// ```no_run
231    /// use txgate_core::config_loader::ConfigLoader;
232    /// use txgate_core::config::Config;
233    ///
234    /// let loader = ConfigLoader::new().expect("failed to create loader");
235    /// let config = Config::builder()
236    ///     .timeout_secs(60)
237    ///     .build();
238    ///
239    /// loader.save(&config).expect("failed to save config");
240    /// ```
241    pub fn save(&self, config: &Config) -> Result<(), ConfigError> {
242        // Ensure base directory exists
243        self.ensure_base_dir()?;
244
245        let config_path = self.config_path();
246
247        let toml_str = toml::to_string_pretty(config).map_err(|e| {
248            ConfigError::parse_failed(format!("failed to serialize configuration: {e}"))
249        })?;
250
251        fs::write(&config_path, toml_str).map_err(|e| {
252            ConfigError::io(
253                format!("failed to write configuration to {}", config_path.display()),
254                e,
255            )
256        })?;
257
258        Ok(())
259    }
260
261    /// Writes the default configuration file.
262    ///
263    /// Creates the base directory if it doesn't exist.
264    /// Uses the formatted default TOML from [`Config::default_toml`].
265    ///
266    /// # Errors
267    ///
268    /// Returns [`ConfigError::Io`] if there's an I/O error writing the file.
269    ///
270    /// # Examples
271    ///
272    /// ```no_run
273    /// use txgate_core::config_loader::ConfigLoader;
274    ///
275    /// let loader = ConfigLoader::new().expect("failed to create loader");
276    /// loader.write_default().expect("failed to write default config");
277    /// ```
278    pub fn write_default(&self) -> Result<(), ConfigError> {
279        // Ensure base directory exists
280        self.ensure_base_dir()?;
281
282        let config_path = self.config_path();
283        let default_toml = Config::default_toml();
284
285        fs::write(&config_path, default_toml).map_err(|e| {
286            ConfigError::io(
287                format!(
288                    "failed to write default configuration to {}",
289                    config_path.display()
290                ),
291                e,
292            )
293        })?;
294
295        Ok(())
296    }
297
298    /// Checks if the configuration file exists.
299    ///
300    /// # Examples
301    ///
302    /// ```no_run
303    /// use txgate_core::config_loader::ConfigLoader;
304    ///
305    /// let loader = ConfigLoader::new().expect("failed to create loader");
306    /// if loader.exists() {
307    ///     println!("Config file found");
308    /// } else {
309    ///     println!("Config file not found, will use defaults");
310    /// }
311    /// ```
312    #[must_use]
313    pub fn exists(&self) -> bool {
314        self.config_path().exists()
315    }
316
317    /// Ensures the base directory exists, creating it if necessary.
318    fn ensure_base_dir(&self) -> Result<(), ConfigError> {
319        if !self.base_dir.exists() {
320            fs::create_dir_all(&self.base_dir).map_err(|e| {
321                ConfigError::io(
322                    format!(
323                        "failed to create base directory {}",
324                        self.base_dir.display()
325                    ),
326                    e,
327                )
328            })?;
329        }
330        Ok(())
331    }
332
333    /// Loads configuration from a specific path.
334    fn load_from_path(path: &Path) -> Result<Config, ConfigError> {
335        let content = fs::read_to_string(path)
336            .map_err(|e| ConfigError::io(format!("failed to read {}", path.display()), e))?;
337
338        let config: Config = toml::from_str(&content).map_err(|e| {
339            ConfigError::parse_failed(format!("invalid TOML in {}: {e}", path.display()))
340        })?;
341
342        Ok(config)
343    }
344}
345
346/// Expands `~` in paths to the home directory.
347///
348/// If the path starts with `~`, it is replaced with the user's home directory.
349/// Otherwise, the path is returned unchanged (converted to `PathBuf`).
350///
351/// # Errors
352///
353/// Returns [`ConfigError::NoHomeDirectory`] if the path starts with `~` and
354/// the home directory cannot be determined.
355///
356/// # Examples
357///
358/// ```no_run
359/// use txgate_core::config_loader::expand_path;
360///
361/// // Expands ~ to home directory
362/// let path = expand_path("~/.txgate/config.toml").expect("failed to expand path");
363/// // path is something like "/home/user/.txgate/config.toml"
364///
365/// // Absolute paths are unchanged
366/// let path = expand_path("/etc/txgate/config.toml").expect("failed to expand path");
367/// assert_eq!(path.to_string_lossy(), "/etc/txgate/config.toml");
368/// ```
369pub fn expand_path(path: &str) -> Result<PathBuf, ConfigError> {
370    if let Some(rest) = path.strip_prefix("~/") {
371        let home = dirs::home_dir().ok_or_else(ConfigError::no_home_directory)?;
372        Ok(home.join(rest))
373    } else if path == "~" {
374        dirs::home_dir().ok_or_else(ConfigError::no_home_directory)
375    } else {
376        Ok(PathBuf::from(path))
377    }
378}
379
380/// Returns the default base directory for `TxGate` files (`~/.txgate`).
381///
382/// # Errors
383///
384/// Returns [`ConfigError::NoHomeDirectory`] if the home directory cannot be determined.
385///
386/// # Examples
387///
388/// ```no_run
389/// use txgate_core::config_loader::default_base_dir;
390///
391/// let base_dir = default_base_dir().expect("failed to get base dir");
392/// // base_dir is something like "/home/user/.txgate"
393/// ```
394pub fn default_base_dir() -> Result<PathBuf, ConfigError> {
395    let home = dirs::home_dir().ok_or_else(ConfigError::no_home_directory)?;
396    Ok(home.join(BASE_DIR_NAME))
397}
398
399/// Loads configuration from the default location with defaults for missing values.
400///
401/// This is a convenience function that:
402/// 1. Creates a [`ConfigLoader`] with the default base directory
403/// 2. Loads the configuration (returning defaults if file doesn't exist)
404///
405/// # Errors
406///
407/// Returns [`ConfigError::NoHomeDirectory`] if the home directory cannot be determined.
408/// Returns [`ConfigError::ParseFailed`] if the configuration file contains invalid TOML.
409/// Returns [`ConfigError::Io`] if there's an I/O error reading the file.
410///
411/// # Examples
412///
413/// ```no_run
414/// use txgate_core::config_loader::load_config;
415///
416/// let config = load_config().expect("failed to load config");
417/// println!("Socket path: {}", config.server.socket_path);
418/// ```
419pub fn load_config() -> Result<Config, ConfigError> {
420    let loader = ConfigLoader::new()?;
421    loader.load()
422}
423
424/// Loads configuration from the default location and expands all paths.
425///
426/// This is a convenience function that:
427/// 1. Loads the configuration using [`load_config`]
428/// 2. Expands `~` in `server.socket_path` to the full path
429/// 3. Expands `~` in `keys.directory` to the full path
430///
431/// # Errors
432///
433/// Returns [`ConfigError::NoHomeDirectory`] if the home directory cannot be determined.
434/// Returns [`ConfigError::ParseFailed`] if the configuration file contains invalid TOML.
435/// Returns [`ConfigError::Io`] if there's an I/O error reading the file.
436///
437/// # Examples
438///
439/// ```no_run
440/// use txgate_core::config_loader::load_config_with_expanded_paths;
441///
442/// let config = load_config_with_expanded_paths().expect("failed to load config");
443/// // socket_path is something like "/home/user/.txgate/txgate.sock"
444/// println!("Socket path: {}", config.server.socket_path);
445/// ```
446pub fn load_config_with_expanded_paths() -> Result<Config, ConfigError> {
447    let mut config = load_config()?;
448
449    // Expand socket path
450    let expanded_socket = expand_path(&config.server.socket_path)?;
451    config.server.socket_path = expanded_socket.to_string_lossy().to_string();
452
453    // Expand keys directory
454    let expanded_keys_dir = expand_path(&config.keys.directory)?;
455    config.keys.directory = expanded_keys_dir.to_string_lossy().to_string();
456
457    Ok(config)
458}
459
460#[cfg(test)]
461mod tests {
462    #![allow(
463        clippy::expect_used,
464        clippy::unwrap_used,
465        clippy::panic,
466        clippy::indexing_slicing,
467        clippy::similar_names,
468        clippy::redundant_clone,
469        clippy::manual_string_new,
470        clippy::needless_raw_string_hashes,
471        clippy::needless_collect,
472        clippy::unreadable_literal
473    )]
474
475    use super::*;
476    use std::fs;
477    use tempfile::TempDir;
478
479    // -------------------------------------------------------------------------
480    // expand_path tests
481    // -------------------------------------------------------------------------
482
483    #[test]
484    fn test_expand_path_with_tilde_prefix() {
485        let result = expand_path("~/.txgate/config.toml");
486        assert!(result.is_ok());
487
488        let path = result.expect("should succeed");
489        let home = dirs::home_dir().expect("home dir should exist");
490        assert_eq!(path, home.join(".txgate/config.toml"));
491    }
492
493    #[test]
494    fn test_expand_path_with_tilde_only() {
495        let result = expand_path("~");
496        assert!(result.is_ok());
497
498        let path = result.expect("should succeed");
499        let home = dirs::home_dir().expect("home dir should exist");
500        assert_eq!(path, home);
501    }
502
503    #[test]
504    fn test_expand_path_with_absolute_path() {
505        let result = expand_path("/etc/txgate/config.toml");
506        assert!(result.is_ok());
507
508        let path = result.expect("should succeed");
509        assert_eq!(path, PathBuf::from("/etc/txgate/config.toml"));
510    }
511
512    #[test]
513    fn test_expand_path_with_relative_path() {
514        let result = expand_path("txgate/config.toml");
515        assert!(result.is_ok());
516
517        let path = result.expect("should succeed");
518        assert_eq!(path, PathBuf::from("txgate/config.toml"));
519    }
520
521    #[test]
522    fn test_expand_path_with_embedded_tilde() {
523        // Tilde in middle of path should not be expanded
524        let result = expand_path("/path/to/~/config.toml");
525        assert!(result.is_ok());
526
527        let path = result.expect("should succeed");
528        assert_eq!(path, PathBuf::from("/path/to/~/config.toml"));
529    }
530
531    // -------------------------------------------------------------------------
532    // default_base_dir tests
533    // -------------------------------------------------------------------------
534
535    #[test]
536    fn test_default_base_dir() {
537        let result = default_base_dir();
538        assert!(result.is_ok());
539
540        let path = result.expect("should succeed");
541        let home = dirs::home_dir().expect("home dir should exist");
542        assert_eq!(path, home.join(".txgate"));
543    }
544
545    // -------------------------------------------------------------------------
546    // ConfigLoader::new tests
547    // -------------------------------------------------------------------------
548
549    #[test]
550    fn test_config_loader_new() {
551        let result = ConfigLoader::new();
552        assert!(result.is_ok());
553
554        let loader = result.expect("should succeed");
555        let expected_base = default_base_dir().expect("should get default base dir");
556        assert_eq!(loader.base_dir(), expected_base);
557    }
558
559    // -------------------------------------------------------------------------
560    // ConfigLoader::with_base_dir tests
561    // -------------------------------------------------------------------------
562
563    #[test]
564    fn test_config_loader_with_base_dir() {
565        let custom_path = PathBuf::from("/custom/txgate");
566        let loader = ConfigLoader::with_base_dir(custom_path.clone());
567        assert_eq!(loader.base_dir(), custom_path);
568    }
569
570    // -------------------------------------------------------------------------
571    // ConfigLoader::config_path tests
572    // -------------------------------------------------------------------------
573
574    #[test]
575    fn test_config_loader_config_path() {
576        let base = PathBuf::from("/test/base");
577        let loader = ConfigLoader::with_base_dir(base);
578        assert_eq!(
579            loader.config_path(),
580            PathBuf::from("/test/base/config.toml")
581        );
582    }
583
584    // -------------------------------------------------------------------------
585    // ConfigLoader::exists tests
586    // -------------------------------------------------------------------------
587
588    #[test]
589    fn test_config_loader_exists_false() {
590        let temp_dir = TempDir::new().expect("failed to create temp dir");
591        let loader = ConfigLoader::with_base_dir(temp_dir.path().to_path_buf());
592
593        assert!(!loader.exists());
594    }
595
596    #[test]
597    fn test_config_loader_exists_true() {
598        let temp_dir = TempDir::new().expect("failed to create temp dir");
599        let config_path = temp_dir.path().join("config.toml");
600        fs::write(&config_path, "# test config").expect("failed to write test file");
601
602        let loader = ConfigLoader::with_base_dir(temp_dir.path().to_path_buf());
603        assert!(loader.exists());
604    }
605
606    // -------------------------------------------------------------------------
607    // ConfigLoader::load tests
608    // -------------------------------------------------------------------------
609
610    #[test]
611    fn test_load_with_missing_file_returns_defaults() {
612        let temp_dir = TempDir::new().expect("failed to create temp dir");
613        let loader = ConfigLoader::with_base_dir(temp_dir.path().to_path_buf());
614
615        let result = loader.load();
616        assert!(result.is_ok());
617
618        let config = result.expect("should succeed");
619        assert_eq!(config, Config::default());
620    }
621
622    #[test]
623    fn test_load_with_valid_toml() {
624        let temp_dir = TempDir::new().expect("failed to create temp dir");
625        let config_path = temp_dir.path().join("config.toml");
626
627        let toml_content = r#"
628[server]
629socket_path = "/custom/socket.sock"
630timeout_secs = 60
631
632[keys]
633directory = "/custom/keys"
634default_key = "production"
635
636[policy]
637whitelist_enabled = true
638whitelist = ["0xABC"]
639"#;
640        fs::write(&config_path, toml_content).expect("failed to write test file");
641
642        let loader = ConfigLoader::with_base_dir(temp_dir.path().to_path_buf());
643        let result = loader.load();
644        assert!(result.is_ok());
645
646        let config = result.expect("should succeed");
647        assert_eq!(config.server.socket_path, "/custom/socket.sock");
648        assert_eq!(config.server.timeout_secs, 60);
649        assert_eq!(config.keys.directory, "/custom/keys");
650        assert_eq!(config.keys.default_key, "production");
651        assert!(config.policy.whitelist_enabled);
652        assert_eq!(config.policy.whitelist, vec!["0xABC"]);
653    }
654
655    #[test]
656    fn test_load_with_partial_toml_uses_defaults() {
657        let temp_dir = TempDir::new().expect("failed to create temp dir");
658        let config_path = temp_dir.path().join("config.toml");
659
660        let toml_content = r#"
661[server]
662timeout_secs = 120
663"#;
664        fs::write(&config_path, toml_content).expect("failed to write test file");
665
666        let loader = ConfigLoader::with_base_dir(temp_dir.path().to_path_buf());
667        let result = loader.load();
668        assert!(result.is_ok());
669
670        let config = result.expect("should succeed");
671        // Specified value
672        assert_eq!(config.server.timeout_secs, 120);
673        // Default values
674        assert_eq!(config.server.socket_path, "~/.txgate/txgate.sock");
675        assert_eq!(config.keys.directory, "~/.txgate/keys");
676        assert_eq!(config.keys.default_key, "default");
677    }
678
679    #[test]
680    fn test_load_with_invalid_toml_returns_parse_error() {
681        let temp_dir = TempDir::new().expect("failed to create temp dir");
682        let config_path = temp_dir.path().join("config.toml");
683
684        let invalid_toml = "this is not valid toml [[[";
685        fs::write(&config_path, invalid_toml).expect("failed to write test file");
686
687        let loader = ConfigLoader::with_base_dir(temp_dir.path().to_path_buf());
688        let result = loader.load();
689        assert!(result.is_err());
690
691        let err = result.expect_err("should fail");
692        assert!(matches!(err, ConfigError::ParseFailed { .. }));
693    }
694
695    #[test]
696    fn test_load_with_empty_file_returns_defaults() {
697        let temp_dir = TempDir::new().expect("failed to create temp dir");
698        let config_path = temp_dir.path().join("config.toml");
699
700        fs::write(&config_path, "").expect("failed to write test file");
701
702        let loader = ConfigLoader::with_base_dir(temp_dir.path().to_path_buf());
703        let result = loader.load();
704        assert!(result.is_ok());
705
706        let config = result.expect("should succeed");
707        assert_eq!(config, Config::default());
708    }
709
710    // -------------------------------------------------------------------------
711    // ConfigLoader::load_required tests
712    // -------------------------------------------------------------------------
713
714    #[test]
715    fn test_load_required_with_missing_file_returns_error() {
716        let temp_dir = TempDir::new().expect("failed to create temp dir");
717        let loader = ConfigLoader::with_base_dir(temp_dir.path().to_path_buf());
718
719        let result = loader.load_required();
720        assert!(result.is_err());
721
722        let err = result.expect_err("should fail");
723        assert!(matches!(err, ConfigError::FileNotFound { .. }));
724    }
725
726    #[test]
727    fn test_load_required_with_existing_file() {
728        let temp_dir = TempDir::new().expect("failed to create temp dir");
729        let config_path = temp_dir.path().join("config.toml");
730
731        fs::write(&config_path, "[server]\ntimeout_secs = 45").expect("failed to write test file");
732
733        let loader = ConfigLoader::with_base_dir(temp_dir.path().to_path_buf());
734        let result = loader.load_required();
735        assert!(result.is_ok());
736
737        let config = result.expect("should succeed");
738        assert_eq!(config.server.timeout_secs, 45);
739    }
740
741    // -------------------------------------------------------------------------
742    // ConfigLoader::save tests
743    // -------------------------------------------------------------------------
744
745    #[test]
746    fn test_save_creates_directory_and_file() {
747        let temp_dir = TempDir::new().expect("failed to create temp dir");
748        let base_dir = temp_dir.path().join("nested/txgate");
749        let loader = ConfigLoader::with_base_dir(base_dir.clone());
750
751        let config = Config::builder()
752            .socket_path("/custom/socket.sock")
753            .timeout_secs(90)
754            .build();
755
756        let result = loader.save(&config);
757        assert!(result.is_ok());
758
759        // Verify file was created
760        assert!(loader.exists());
761
762        // Verify content
763        let content = fs::read_to_string(loader.config_path()).expect("failed to read file");
764        assert!(content.contains("socket_path = \"/custom/socket.sock\""));
765        assert!(content.contains("timeout_secs = 90"));
766    }
767
768    #[test]
769    fn test_save_and_load_roundtrip() {
770        let temp_dir = TempDir::new().expect("failed to create temp dir");
771        let loader = ConfigLoader::with_base_dir(temp_dir.path().to_path_buf());
772
773        let original = Config::builder()
774            .socket_path("/test/socket.sock")
775            .timeout_secs(75)
776            .keys_directory("/test/keys")
777            .default_key("test-key")
778            .build();
779
780        loader.save(&original).expect("save should succeed");
781
782        let loaded = loader.load().expect("load should succeed");
783        assert_eq!(original, loaded);
784    }
785
786    // -------------------------------------------------------------------------
787    // ConfigLoader::write_default tests
788    // -------------------------------------------------------------------------
789
790    #[test]
791    fn test_write_default_creates_valid_config() {
792        let temp_dir = TempDir::new().expect("failed to create temp dir");
793        let loader = ConfigLoader::with_base_dir(temp_dir.path().to_path_buf());
794
795        let result = loader.write_default();
796        assert!(result.is_ok());
797
798        // Verify file exists
799        assert!(loader.exists());
800
801        // Verify it can be loaded and matches defaults
802        let loaded = loader.load().expect("load should succeed");
803        assert_eq!(loaded.server.socket_path, "~/.txgate/txgate.sock");
804        assert_eq!(loaded.server.timeout_secs, 30);
805        assert_eq!(loaded.keys.directory, "~/.txgate/keys");
806        assert_eq!(loaded.keys.default_key, "default");
807    }
808
809    #[test]
810    fn test_write_default_creates_nested_directory() {
811        let temp_dir = TempDir::new().expect("failed to create temp dir");
812        let base_dir = temp_dir.path().join("deeply/nested/txgate/dir");
813        let loader = ConfigLoader::with_base_dir(base_dir.clone());
814
815        let result = loader.write_default();
816        assert!(result.is_ok());
817
818        assert!(base_dir.exists());
819        assert!(loader.exists());
820    }
821
822    // -------------------------------------------------------------------------
823    // load_config tests
824    // -------------------------------------------------------------------------
825
826    #[test]
827    fn test_load_config_function() {
828        // This test just verifies the function doesn't panic
829        // Actual file loading depends on system state
830        let result = load_config();
831        // Should succeed even if file doesn't exist (returns defaults)
832        assert!(result.is_ok());
833    }
834
835    // -------------------------------------------------------------------------
836    // load_config_with_expanded_paths tests
837    // -------------------------------------------------------------------------
838
839    #[test]
840    fn test_load_config_with_expanded_paths() {
841        let result = load_config_with_expanded_paths();
842        assert!(result.is_ok());
843
844        let config = result.expect("should succeed");
845
846        // Socket path should be expanded (not start with ~)
847        assert!(!config.server.socket_path.starts_with('~'));
848
849        // Keys directory should be expanded (not start with ~)
850        assert!(!config.keys.directory.starts_with('~'));
851
852        // They should contain the home directory
853        let home = dirs::home_dir().expect("home dir should exist");
854        assert!(config
855            .server
856            .socket_path
857            .starts_with(home.to_string_lossy().as_ref()));
858        assert!(config
859            .keys
860            .directory
861            .starts_with(home.to_string_lossy().as_ref()));
862    }
863
864    // -------------------------------------------------------------------------
865    // Edge case tests
866    // -------------------------------------------------------------------------
867
868    #[test]
869    fn test_config_loader_clone() {
870        let loader = ConfigLoader::with_base_dir(PathBuf::from("/test/path"));
871        let cloned = loader.clone();
872        assert_eq!(loader.base_dir(), cloned.base_dir());
873    }
874
875    #[test]
876    fn test_config_loader_debug() {
877        let loader = ConfigLoader::with_base_dir(PathBuf::from("/test/path"));
878        let debug_str = format!("{loader:?}");
879        assert!(debug_str.contains("ConfigLoader"));
880        assert!(debug_str.contains("/test/path"));
881    }
882
883    #[test]
884    fn test_save_overwrites_existing_file() {
885        let temp_dir = TempDir::new().expect("failed to create temp dir");
886        let loader = ConfigLoader::with_base_dir(temp_dir.path().to_path_buf());
887
888        // Save first config
889        let config1 = Config::builder().timeout_secs(10).build();
890        loader.save(&config1).expect("first save should succeed");
891
892        // Save second config (overwrite)
893        let config2 = Config::builder().timeout_secs(20).build();
894        loader.save(&config2).expect("second save should succeed");
895
896        // Load and verify it's the second config
897        let loaded = loader.load().expect("load should succeed");
898        assert_eq!(loaded.server.timeout_secs, 20);
899    }
900}