ipfrs_cli/
config.rs

1//! Configuration management for IPFRS CLI
2//!
3//! Handles reading, writing, and validating IPFRS configuration files.
4
5#![allow(dead_code)]
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use std::sync::OnceLock;
11
12/// Global cached configuration
13static CACHED_CONFIG: OnceLock<Config> = OnceLock::new();
14
15/// Main configuration structure
16#[derive(Debug, Clone, Serialize, Deserialize, Default)]
17pub struct Config {
18    /// General settings
19    #[serde(default)]
20    pub general: GeneralConfig,
21
22    /// Storage settings
23    #[serde(default)]
24    pub storage: StorageConfig,
25
26    /// Network settings
27    #[serde(default)]
28    pub network: NetworkConfig,
29
30    /// Gateway settings
31    #[serde(default)]
32    pub gateway: GatewayConfig,
33
34    /// API settings
35    #[serde(default)]
36    pub api: ApiConfig,
37
38    /// Shell settings
39    #[serde(default)]
40    pub shell: ShellConfig,
41}
42
43/// General configuration options
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct GeneralConfig {
46    /// Data directory path
47    #[serde(default = "default_data_dir")]
48    pub data_dir: PathBuf,
49
50    /// Log level (error, warn, info, debug, trace)
51    #[serde(default = "default_log_level")]
52    pub log_level: String,
53
54    /// Enable colored output
55    #[serde(default = "default_true")]
56    pub color: bool,
57
58    /// Output format (text, json)
59    #[serde(default = "default_format")]
60    pub format: String,
61}
62
63impl Default for GeneralConfig {
64    fn default() -> Self {
65        Self {
66            data_dir: default_data_dir(),
67            log_level: default_log_level(),
68            color: true,
69            format: default_format(),
70        }
71    }
72}
73
74/// Storage configuration options
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct StorageConfig {
77    /// Block store path (relative to data_dir)
78    #[serde(default = "default_blocks_path")]
79    pub blocks_path: String,
80
81    /// Maximum cache size in bytes
82    #[serde(default = "default_cache_size")]
83    pub cache_size: u64,
84
85    /// Enable write-ahead logging
86    #[serde(default = "default_true")]
87    pub wal_enabled: bool,
88
89    /// Garbage collection interval in seconds
90    #[serde(default = "default_gc_interval")]
91    pub gc_interval: u64,
92}
93
94impl Default for StorageConfig {
95    fn default() -> Self {
96        Self {
97            blocks_path: default_blocks_path(),
98            cache_size: default_cache_size(),
99            wal_enabled: true,
100            gc_interval: default_gc_interval(),
101        }
102    }
103}
104
105/// Network configuration options
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct NetworkConfig {
108    /// Listen addresses
109    #[serde(default = "default_listen_addrs")]
110    pub listen_addrs: Vec<String>,
111
112    /// Bootstrap peers
113    #[serde(default)]
114    pub bootstrap_peers: Vec<String>,
115
116    /// Maximum number of connections
117    #[serde(default = "default_max_connections")]
118    pub max_connections: u32,
119
120    /// Enable DHT
121    #[serde(default = "default_true")]
122    pub dht_enabled: bool,
123
124    /// Enable mDNS discovery
125    #[serde(default = "default_true")]
126    pub mdns_enabled: bool,
127
128    /// Connection timeout in seconds
129    #[serde(default = "default_timeout")]
130    pub timeout: u64,
131}
132
133impl Default for NetworkConfig {
134    fn default() -> Self {
135        Self {
136            listen_addrs: default_listen_addrs(),
137            bootstrap_peers: Vec::new(),
138            max_connections: default_max_connections(),
139            dht_enabled: true,
140            mdns_enabled: true,
141            timeout: default_timeout(),
142        }
143    }
144}
145
146/// Gateway configuration options
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct GatewayConfig {
149    /// Gateway listen address
150    #[serde(default = "default_gateway_addr")]
151    pub listen_addr: String,
152
153    /// Enable CORS
154    #[serde(default = "default_true")]
155    pub cors_enabled: bool,
156
157    /// CORS allowed origins
158    #[serde(default)]
159    pub cors_origins: Vec<String>,
160
161    /// Enable TLS
162    #[serde(default)]
163    pub tls_enabled: bool,
164
165    /// TLS certificate path
166    #[serde(default)]
167    pub tls_cert_path: Option<String>,
168
169    /// TLS key path
170    #[serde(default)]
171    pub tls_key_path: Option<String>,
172}
173
174impl Default for GatewayConfig {
175    fn default() -> Self {
176        Self {
177            listen_addr: default_gateway_addr(),
178            cors_enabled: true,
179            cors_origins: Vec::new(),
180            tls_enabled: false,
181            tls_cert_path: None,
182            tls_key_path: None,
183        }
184    }
185}
186
187/// API configuration options
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct ApiConfig {
190    /// API listen address (for local daemon)
191    #[serde(default = "default_api_addr")]
192    pub listen_addr: String,
193
194    /// Remote API URL (for connecting to remote daemon)
195    /// Format: http://hostname:port or https://hostname:port
196    /// Overrides listen_addr when set
197    #[serde(default)]
198    pub remote_url: Option<String>,
199
200    /// Enable authentication
201    #[serde(default)]
202    pub auth_enabled: bool,
203
204    /// API token (if auth is enabled)
205    #[serde(default)]
206    pub api_token: Option<String>,
207
208    /// Connection timeout for remote API in seconds
209    #[serde(default = "default_api_timeout")]
210    pub timeout: u64,
211}
212
213impl Default for ApiConfig {
214    fn default() -> Self {
215        Self {
216            listen_addr: default_api_addr(),
217            remote_url: None,
218            auth_enabled: false,
219            api_token: None,
220            timeout: default_api_timeout(),
221        }
222    }
223}
224
225// Default value functions
226fn default_data_dir() -> PathBuf {
227    PathBuf::from(".ipfrs")
228}
229
230fn default_log_level() -> String {
231    "info".to_string()
232}
233
234fn default_true() -> bool {
235    true
236}
237
238fn default_format() -> String {
239    "text".to_string()
240}
241
242fn default_blocks_path() -> String {
243    "blocks".to_string()
244}
245
246fn default_cache_size() -> u64 {
247    100 * 1024 * 1024 // 100MB
248}
249
250fn default_gc_interval() -> u64 {
251    3600 // 1 hour
252}
253
254fn default_listen_addrs() -> Vec<String> {
255    vec![
256        "/ip4/0.0.0.0/tcp/4001".to_string(),
257        "/ip6/::/tcp/4001".to_string(),
258    ]
259}
260
261fn default_max_connections() -> u32 {
262    256
263}
264
265fn default_timeout() -> u64 {
266    30
267}
268
269fn default_gateway_addr() -> String {
270    "127.0.0.1:8080".to_string()
271}
272
273fn default_api_addr() -> String {
274    "127.0.0.1:5001".to_string()
275}
276
277fn default_api_timeout() -> u64 {
278    60 // 60 seconds for remote API calls
279}
280
281/// Shell configuration options
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct ShellConfig {
284    /// User-defined command aliases
285    #[serde(default)]
286    pub aliases: std::collections::HashMap<String, String>,
287
288    /// Enable command hints
289    #[serde(default = "default_true")]
290    pub hints_enabled: bool,
291
292    /// Enable syntax highlighting
293    #[serde(default = "default_true")]
294    pub highlighting_enabled: bool,
295
296    /// Maximum history entries
297    #[serde(default = "default_history_size")]
298    pub history_size: usize,
299}
300
301impl Default for ShellConfig {
302    fn default() -> Self {
303        Self {
304            aliases: std::collections::HashMap::new(),
305            hints_enabled: true,
306            highlighting_enabled: true,
307            history_size: default_history_size(),
308        }
309    }
310}
311
312fn default_history_size() -> usize {
313    1000
314}
315
316impl Config {
317    /// Load configuration from default locations (with caching)
318    ///
319    /// This method uses a global cache to avoid repeated disk reads.
320    /// The config is loaded once and reused for subsequent calls.
321    ///
322    /// Search order:
323    /// 1. .ipfrs/config.toml (current directory)
324    /// 2. ~/.ipfrs/config.toml (user home)
325    /// 3. /etc/ipfrs/config.toml (system)
326    pub fn load() -> Result<Self> {
327        Ok(CACHED_CONFIG
328            .get_or_init(|| Self::load_uncached().unwrap_or_default())
329            .clone())
330    }
331
332    /// Load configuration from default locations without caching
333    ///
334    /// Use this when you need to force a fresh load (e.g., after config changes).
335    ///
336    /// Search order:
337    /// 1. .ipfrs/config.toml (current directory)
338    /// 2. ~/.ipfrs/config.toml (user home)
339    /// 3. /etc/ipfrs/config.toml (system)
340    ///
341    /// Environment variables (override config file):
342    /// - IPFRS_PATH: Data directory
343    /// - IPFRS_LOG_LEVEL: Log level
344    /// - IPFRS_API_URL: Remote API URL
345    /// - IPFRS_API_TOKEN: API authentication token
346    pub fn load_uncached() -> Result<Self> {
347        // Try local config first
348        let local_config = PathBuf::from(".ipfrs/config.toml");
349        let mut config = if local_config.exists() {
350            Self::load_from(&local_config)?
351        } else if let Some(home) = dirs::home_dir() {
352            // Try user home config
353            let user_config = home.join(".ipfrs/config.toml");
354            if user_config.exists() {
355                Self::load_from(&user_config)?
356            } else {
357                // Try system config
358                let system_config = PathBuf::from("/etc/ipfrs/config.toml");
359                if system_config.exists() {
360                    Self::load_from(&system_config)?
361                } else {
362                    Self::default()
363                }
364            }
365        } else {
366            Self::default()
367        };
368
369        // Apply environment variable overrides
370        config.apply_env_overrides();
371
372        Ok(config)
373    }
374
375    /// Apply environment variable overrides to configuration
376    ///
377    /// Supported environment variables:
378    /// - IPFRS_PATH: Data directory
379    /// - IPFRS_LOG_LEVEL: Log level (error, warn, info, debug, trace)
380    /// - IPFRS_API_URL: Remote API URL (http://host:port or https://host:port)
381    /// - IPFRS_API_TOKEN: API authentication token
382    fn apply_env_overrides(&mut self) {
383        use std::env;
384
385        // Data directory
386        if let Ok(path) = env::var("IPFRS_PATH") {
387            self.general.data_dir = PathBuf::from(path);
388        }
389
390        // Log level
391        if let Ok(level) = env::var("IPFRS_LOG_LEVEL") {
392            self.general.log_level = level;
393        }
394
395        // Remote API URL
396        if let Ok(url) = env::var("IPFRS_API_URL") {
397            self.api.remote_url = Some(url);
398        }
399
400        // API token
401        if let Ok(token) = env::var("IPFRS_API_TOKEN") {
402            self.api.api_token = Some(token);
403            self.api.auth_enabled = true;
404        }
405    }
406
407    /// Get the effective API URL
408    ///
409    /// Returns the remote URL if set, otherwise returns the local listen address
410    /// formatted as http://address
411    pub fn api_url(&self) -> String {
412        self.api
413            .remote_url
414            .clone()
415            .unwrap_or_else(|| format!("http://{}", self.api.listen_addr))
416    }
417
418    /// Check if connecting to a remote daemon
419    pub fn is_remote(&self) -> bool {
420        self.api.remote_url.is_some()
421    }
422
423    /// Clear the cached configuration
424    ///
425    /// This forces the next `load()` call to reload from disk.
426    /// Note: Due to OnceLock limitations, this doesn't actually clear the cache
427    /// but is provided for API consistency. Use `load_uncached()` instead.
428    pub fn clear_cache() {
429        // OnceLock doesn't support clearing, but we provide this for API consistency
430        // Users should use load_uncached() if they need a fresh load
431    }
432
433    /// Load configuration from a specific path
434    pub fn load_from(path: &Path) -> Result<Self> {
435        let content = std::fs::read_to_string(path)
436            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
437
438        let config: Config = toml::from_str(&content)
439            .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
440
441        Ok(config)
442    }
443
444    /// Save configuration to a file
445    pub fn save(&self, path: &Path) -> Result<()> {
446        let content = toml::to_string_pretty(self).context("Failed to serialize config")?;
447
448        // Ensure parent directory exists
449        if let Some(parent) = path.parent() {
450            std::fs::create_dir_all(parent)
451                .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
452        }
453
454        std::fs::write(path, content)
455            .with_context(|| format!("Failed to write config file: {}", path.display()))?;
456
457        Ok(())
458    }
459
460    /// Get the default configuration file path
461    ///
462    /// Returns the path to the user's configuration file (~/.ipfrs/config.toml)
463    pub fn default_path() -> Result<PathBuf> {
464        let home = dirs::home_dir()
465            .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
466        Ok(home.join(".ipfrs/config.toml"))
467    }
468
469    /// Generate default config file with comments
470    pub fn generate_default_config() -> String {
471        r#"# IPFRS Configuration File
472# Generated by ipfrs init
473
474[general]
475# Data directory path
476data_dir = ".ipfrs"
477# Log level: error, warn, info, debug, trace
478log_level = "info"
479# Enable colored output
480color = true
481# Default output format: text, json
482format = "text"
483
484[storage]
485# Block store path (relative to data_dir)
486blocks_path = "blocks"
487# Maximum cache size in bytes (default: 100MB)
488cache_size = 104857600
489# Enable write-ahead logging
490wal_enabled = true
491# Garbage collection interval in seconds
492gc_interval = 3600
493
494[network]
495# Listen addresses
496listen_addrs = [
497    "/ip4/0.0.0.0/tcp/4001",
498    "/ip6/::/tcp/4001"
499]
500# Bootstrap peers (add your own or use public IPFS bootstrap)
501bootstrap_peers = []
502# Maximum number of connections
503max_connections = 256
504# Enable DHT for peer/content discovery
505dht_enabled = true
506# Enable mDNS for local peer discovery
507mdns_enabled = true
508# Connection timeout in seconds
509timeout = 30
510
511[gateway]
512# HTTP Gateway listen address
513listen_addr = "127.0.0.1:8080"
514# Enable CORS
515cors_enabled = true
516# CORS allowed origins (empty = all)
517cors_origins = []
518# Enable TLS
519tls_enabled = false
520# TLS certificate path
521# tls_cert_path = "/path/to/cert.pem"
522# TLS key path
523# tls_key_path = "/path/to/key.pem"
524
525[api]
526# API server listen address (for local daemon)
527listen_addr = "127.0.0.1:5001"
528# Remote API URL (for connecting to remote daemon)
529# Format: http://hostname:port or https://hostname:port
530# When set, overrides listen_addr for client commands
531# Example: remote_url = "http://192.168.1.100:5001"
532# remote_url = ""
533# Connection timeout for remote API in seconds
534timeout = 60
535# Enable API authentication
536auth_enabled = false
537# API token (required if auth_enabled = true or connecting to authenticated remote)
538# api_token = "your-secret-token"
539
540[shell]
541# Enable command hints (suggestions as you type)
542hints_enabled = true
543# Enable syntax highlighting
544highlighting_enabled = true
545# Maximum number of history entries
546history_size = 1000
547# User-defined command aliases
548# Example: aliases = { "myalias" = "full command here" }
549# [shell.aliases]
550# ll = "ls -la"
551# gs = "git status"
552"#
553        .to_string()
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    #[test]
562    fn test_default_config() {
563        let config = Config::default();
564        assert_eq!(config.general.log_level, "info");
565        assert!(config.general.color);
566        assert_eq!(config.storage.blocks_path, "blocks");
567    }
568
569    #[test]
570    fn test_config_serialization() {
571        let config = Config::default();
572        let toml_str = toml::to_string_pretty(&config).unwrap();
573        let parsed: Config = toml::from_str(&toml_str).unwrap();
574        assert_eq!(parsed.general.log_level, config.general.log_level);
575    }
576
577    #[test]
578    fn test_generate_default_config() {
579        let config_str = Config::generate_default_config();
580        assert!(config_str.contains("[general]"));
581        assert!(config_str.contains("[storage]"));
582        assert!(config_str.contains("[network]"));
583        assert!(config_str.contains("[shell]"));
584    }
585
586    #[test]
587    fn test_shell_config_default() {
588        let shell_config = super::ShellConfig::default();
589        assert!(shell_config.hints_enabled);
590        assert!(shell_config.highlighting_enabled);
591        assert_eq!(shell_config.history_size, 1000);
592        assert!(shell_config.aliases.is_empty());
593    }
594
595    #[test]
596    fn test_shell_config_serialization() {
597        let mut shell_config = super::ShellConfig::default();
598        shell_config
599            .aliases
600            .insert("ll".to_string(), "ls -la".to_string());
601        shell_config
602            .aliases
603            .insert("gs".to_string(), "git status".to_string());
604
605        let toml_str = toml::to_string_pretty(&shell_config).unwrap();
606        let parsed: super::ShellConfig = toml::from_str(&toml_str).unwrap();
607
608        assert_eq!(parsed.hints_enabled, shell_config.hints_enabled);
609        assert_eq!(parsed.history_size, shell_config.history_size);
610        assert_eq!(parsed.aliases.len(), 2);
611        assert_eq!(parsed.aliases.get("ll"), Some(&"ls -la".to_string()));
612        assert_eq!(parsed.aliases.get("gs"), Some(&"git status".to_string()));
613    }
614
615    #[test]
616    fn test_config_caching() {
617        // Load config twice - should return the same instance (via caching)
618        let config1 = Config::load().unwrap();
619        let config2 = Config::load().unwrap();
620
621        // Both should have the same default values
622        assert_eq!(config1.general.log_level, config2.general.log_level);
623        assert_eq!(config1.storage.cache_size, config2.storage.cache_size);
624    }
625
626    #[test]
627    fn test_config_uncached_load() {
628        // Load uncached config multiple times
629        let config1 = Config::load_uncached().unwrap();
630        let config2 = Config::load_uncached().unwrap();
631
632        // Both should have the same default values
633        assert_eq!(config1.general.log_level, config2.general.log_level);
634        assert_eq!(config1.storage.cache_size, config2.storage.cache_size);
635    }
636
637    #[test]
638    fn test_clear_cache() {
639        // Test that clear_cache doesn't panic (it's a no-op currently)
640        Config::clear_cache();
641
642        // Config should still load successfully
643        let config = Config::load().unwrap();
644        assert_eq!(config.general.log_level, "info");
645    }
646}