Skip to main content

ryo_storage/storage/
config.rs

1//! Global RYO configuration (~/.ryo/config.toml)
2//!
3//! Provides global configuration for RYO daemon and CLI behavior.
4//!
5//! # Example config.toml
6//!
7//! ```toml
8//! [server]
9//! idle_timeout = 3600       # 1 hour (0 = never timeout)
10//! startup_timeout = 300     # 5 minutes for client connection wait
11//! parallel_init = true      # Use parallel initialization
12//! watch = true              # Auto-reload on file changes
13//! watch_debounce_ms = 500   # Debounce duration for file watcher
14//!
15//! [cli]
16//! verbose = false           # Enable verbose output by default
17//! color = "auto"            # "auto", "always", "never"
18//! ```
19
20use serde::{Deserialize, Serialize};
21use std::path::{Path, PathBuf};
22use std::time::Duration;
23
24/// Global configuration file name
25pub const CONFIG_FILE: &str = "config.toml";
26
27/// Server-related configuration
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(default)]
30pub struct ServerConfig {
31    /// Idle timeout in seconds (0 = never timeout)
32    /// Default: 3600 (1 hour)
33    pub idle_timeout: u64,
34
35    /// Startup timeout in seconds for client to wait for server
36    /// Default: 300 (5 minutes)
37    pub startup_timeout: u64,
38
39    /// Use parallel initialization for faster startup
40    /// Default: true
41    pub parallel_init: bool,
42
43    /// Watch for file changes and auto-reload
44    /// Default: true
45    pub watch: bool,
46
47    /// Debounce duration for file watcher in milliseconds
48    /// Default: 500
49    pub watch_debounce_ms: u64,
50}
51
52impl Default for ServerConfig {
53    fn default() -> Self {
54        Self {
55            idle_timeout: 3600,   // 1 hour
56            startup_timeout: 300, // 5 minutes
57            parallel_init: true,
58            watch: true,            // Enabled by default for responsive UX
59            watch_debounce_ms: 500, // 500ms debounce
60        }
61    }
62}
63
64impl ServerConfig {
65    /// Get idle timeout as Duration (None if 0 = never timeout)
66    pub fn idle_timeout_duration(&self) -> Option<Duration> {
67        if self.idle_timeout == 0 {
68            None
69        } else {
70            Some(Duration::from_secs(self.idle_timeout))
71        }
72    }
73
74    /// Get startup timeout as Duration
75    pub fn startup_timeout_duration(&self) -> Duration {
76        Duration::from_secs(self.startup_timeout)
77    }
78}
79
80/// CLI-related configuration
81#[derive(Debug, Clone, Serialize, Deserialize)]
82#[serde(default)]
83pub struct CliConfig {
84    /// Enable verbose output by default
85    pub verbose: bool,
86
87    /// Color output mode: "auto", "always", "never"
88    pub color: String,
89
90    /// Show progress indicators
91    pub progress: bool,
92}
93
94impl Default for CliConfig {
95    fn default() -> Self {
96        Self {
97            verbose: false,
98            color: "auto".to_string(),
99            progress: true,
100        }
101    }
102}
103
104/// Global RYO configuration
105#[derive(Debug, Clone, Default, Serialize, Deserialize)]
106#[serde(default)]
107pub struct GlobalConfig {
108    /// Server/daemon configuration
109    pub server: ServerConfig,
110
111    /// CLI output configuration
112    pub cli: CliConfig,
113}
114
115impl GlobalConfig {
116    /// Load config from a file path
117    pub fn load(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
118        let path = path.as_ref();
119        if !path.exists() {
120            return Ok(Self::default());
121        }
122
123        let content = std::fs::read_to_string(path)
124            .map_err(|e| ConfigError::Io(format!("{}: {}", path.display(), e)))?;
125
126        toml::from_str(&content)
127            .map_err(|e| ConfigError::Parse(format!("{}: {}", path.display(), e)))
128    }
129
130    /// Load config from ~/.ryo/config.toml
131    pub fn load_global() -> Result<Self, ConfigError> {
132        let home = dirs::home_dir()
133            .ok_or_else(|| ConfigError::Io("Could not find home directory".to_string()))?;
134        let path = home.join(".ryo").join(CONFIG_FILE);
135        Self::load(&path)
136    }
137
138    /// Get the path to the global config file
139    pub fn global_path() -> Option<PathBuf> {
140        dirs::home_dir().map(|h| h.join(".ryo").join(CONFIG_FILE))
141    }
142
143    /// Save config to a file path
144    pub fn save(&self, path: impl AsRef<Path>) -> Result<(), ConfigError> {
145        let path = path.as_ref();
146
147        // Ensure parent directory exists
148        if let Some(parent) = path.parent() {
149            std::fs::create_dir_all(parent)
150                .map_err(|e| ConfigError::Io(format!("mkdir {}: {}", parent.display(), e)))?;
151        }
152
153        let content =
154            toml::to_string_pretty(self).map_err(|e| ConfigError::Serialize(e.to_string()))?;
155
156        std::fs::write(path, content)
157            .map_err(|e| ConfigError::Io(format!("{}: {}", path.display(), e)))
158    }
159
160    /// Save to ~/.ryo/config.toml
161    pub fn save_global(&self) -> Result<(), ConfigError> {
162        let path = Self::global_path()
163            .ok_or_else(|| ConfigError::Io("Could not find home directory".to_string()))?;
164        self.save(&path)
165    }
166
167    /// Create a default config file if it doesn't exist
168    pub fn init_global() -> Result<(), ConfigError> {
169        let path = Self::global_path()
170            .ok_or_else(|| ConfigError::Io("Could not find home directory".to_string()))?;
171
172        if !path.exists() {
173            Self::default().save(&path)?;
174        }
175        Ok(())
176    }
177}
178
179/// Configuration error
180#[derive(Debug, thiserror::Error)]
181pub enum ConfigError {
182    /// Filesystem I/O failure while reading or writing the config file.
183    #[error("IO error: {0}")]
184    Io(String),
185
186    /// Config payload failed to parse (TOML / JSON deserialization error).
187    #[error("Parse error: {0}")]
188    Parse(String),
189
190    /// Config payload failed to serialize back to its on-disk format.
191    #[error("Serialize error: {0}")]
192    Serialize(String),
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use tempfile::TempDir;
199
200    #[test]
201    fn test_default_config() {
202        let config = GlobalConfig::default();
203        assert_eq!(config.server.idle_timeout, 3600);
204        assert_eq!(config.server.startup_timeout, 300);
205        assert!(config.server.parallel_init);
206        assert!(config.server.watch); // Watch enabled by default
207        assert_eq!(config.server.watch_debounce_ms, 500);
208        assert!(!config.cli.verbose);
209        assert_eq!(config.cli.color, "auto");
210    }
211
212    #[test]
213    fn test_idle_timeout_duration() {
214        let mut config = ServerConfig::default();
215        assert!(config.idle_timeout_duration().is_some());
216
217        config.idle_timeout = 0;
218        assert!(config.idle_timeout_duration().is_none());
219    }
220
221    #[test]
222    fn test_save_and_load() {
223        let temp = TempDir::new().unwrap();
224        let path = temp.path().join("config.toml");
225
226        let mut config = GlobalConfig::default();
227        config.server.idle_timeout = 7200;
228        config.cli.verbose = true;
229
230        config.save(&path).unwrap();
231        let loaded = GlobalConfig::load(&path).unwrap();
232
233        assert_eq!(loaded.server.idle_timeout, 7200);
234        assert!(loaded.cli.verbose);
235    }
236
237    #[test]
238    fn test_load_missing_file() {
239        let config = GlobalConfig::load("/nonexistent/config.toml").unwrap();
240        // Should return defaults
241        assert_eq!(config.server.idle_timeout, 3600);
242    }
243
244    #[test]
245    fn test_parse_partial_config() {
246        let temp = TempDir::new().unwrap();
247        let path = temp.path().join("config.toml");
248
249        // Write partial config (only server section)
250        std::fs::write(
251            &path,
252            r#"
253[server]
254idle_timeout = 1800
255"#,
256        )
257        .unwrap();
258
259        let config = GlobalConfig::load(&path).unwrap();
260        assert_eq!(config.server.idle_timeout, 1800);
261        // Other fields should have defaults
262        assert_eq!(config.server.startup_timeout, 300);
263        assert!(config.server.parallel_init);
264    }
265}