ryo-storage 0.1.0

Persistent storage and transaction log for RYO
Documentation
//! Global RYO configuration (~/.ryo/config.toml)
//!
//! Provides global configuration for RYO daemon and CLI behavior.
//!
//! # Example config.toml
//!
//! ```toml
//! [server]
//! idle_timeout = 3600       # 1 hour (0 = never timeout)
//! startup_timeout = 300     # 5 minutes for client connection wait
//! parallel_init = true      # Use parallel initialization
//! watch = true              # Auto-reload on file changes
//! watch_debounce_ms = 500   # Debounce duration for file watcher
//!
//! [cli]
//! verbose = false           # Enable verbose output by default
//! color = "auto"            # "auto", "always", "never"
//! ```

use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::Duration;

/// Global configuration file name
pub const CONFIG_FILE: &str = "config.toml";

/// Server-related configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ServerConfig {
    /// Idle timeout in seconds (0 = never timeout)
    /// Default: 3600 (1 hour)
    pub idle_timeout: u64,

    /// Startup timeout in seconds for client to wait for server
    /// Default: 300 (5 minutes)
    pub startup_timeout: u64,

    /// Use parallel initialization for faster startup
    /// Default: true
    pub parallel_init: bool,

    /// Watch for file changes and auto-reload
    /// Default: true
    pub watch: bool,

    /// Debounce duration for file watcher in milliseconds
    /// Default: 500
    pub watch_debounce_ms: u64,
}

impl Default for ServerConfig {
    fn default() -> Self {
        Self {
            idle_timeout: 3600,   // 1 hour
            startup_timeout: 300, // 5 minutes
            parallel_init: true,
            watch: true,            // Enabled by default for responsive UX
            watch_debounce_ms: 500, // 500ms debounce
        }
    }
}

impl ServerConfig {
    /// Get idle timeout as Duration (None if 0 = never timeout)
    pub fn idle_timeout_duration(&self) -> Option<Duration> {
        if self.idle_timeout == 0 {
            None
        } else {
            Some(Duration::from_secs(self.idle_timeout))
        }
    }

    /// Get startup timeout as Duration
    pub fn startup_timeout_duration(&self) -> Duration {
        Duration::from_secs(self.startup_timeout)
    }
}

/// CLI-related configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CliConfig {
    /// Enable verbose output by default
    pub verbose: bool,

    /// Color output mode: "auto", "always", "never"
    pub color: String,

    /// Show progress indicators
    pub progress: bool,
}

impl Default for CliConfig {
    fn default() -> Self {
        Self {
            verbose: false,
            color: "auto".to_string(),
            progress: true,
        }
    }
}

/// Global RYO configuration
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct GlobalConfig {
    /// Server/daemon configuration
    pub server: ServerConfig,

    /// CLI output configuration
    pub cli: CliConfig,
}

impl GlobalConfig {
    /// Load config from a file path
    pub fn load(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
        let path = path.as_ref();
        if !path.exists() {
            return Ok(Self::default());
        }

        let content = std::fs::read_to_string(path)
            .map_err(|e| ConfigError::Io(format!("{}: {}", path.display(), e)))?;

        toml::from_str(&content)
            .map_err(|e| ConfigError::Parse(format!("{}: {}", path.display(), e)))
    }

    /// Load config from ~/.ryo/config.toml
    pub fn load_global() -> Result<Self, ConfigError> {
        let home = dirs::home_dir()
            .ok_or_else(|| ConfigError::Io("Could not find home directory".to_string()))?;
        let path = home.join(".ryo").join(CONFIG_FILE);
        Self::load(&path)
    }

    /// Get the path to the global config file
    pub fn global_path() -> Option<PathBuf> {
        dirs::home_dir().map(|h| h.join(".ryo").join(CONFIG_FILE))
    }

    /// Save config to a file path
    pub fn save(&self, path: impl AsRef<Path>) -> Result<(), ConfigError> {
        let path = path.as_ref();

        // Ensure parent directory exists
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)
                .map_err(|e| ConfigError::Io(format!("mkdir {}: {}", parent.display(), e)))?;
        }

        let content =
            toml::to_string_pretty(self).map_err(|e| ConfigError::Serialize(e.to_string()))?;

        std::fs::write(path, content)
            .map_err(|e| ConfigError::Io(format!("{}: {}", path.display(), e)))
    }

    /// Save to ~/.ryo/config.toml
    pub fn save_global(&self) -> Result<(), ConfigError> {
        let path = Self::global_path()
            .ok_or_else(|| ConfigError::Io("Could not find home directory".to_string()))?;
        self.save(&path)
    }

    /// Create a default config file if it doesn't exist
    pub fn init_global() -> Result<(), ConfigError> {
        let path = Self::global_path()
            .ok_or_else(|| ConfigError::Io("Could not find home directory".to_string()))?;

        if !path.exists() {
            Self::default().save(&path)?;
        }
        Ok(())
    }
}

/// Configuration error
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    /// Filesystem I/O failure while reading or writing the config file.
    #[error("IO error: {0}")]
    Io(String),

    /// Config payload failed to parse (TOML / JSON deserialization error).
    #[error("Parse error: {0}")]
    Parse(String),

    /// Config payload failed to serialize back to its on-disk format.
    #[error("Serialize error: {0}")]
    Serialize(String),
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn test_default_config() {
        let config = GlobalConfig::default();
        assert_eq!(config.server.idle_timeout, 3600);
        assert_eq!(config.server.startup_timeout, 300);
        assert!(config.server.parallel_init);
        assert!(config.server.watch); // Watch enabled by default
        assert_eq!(config.server.watch_debounce_ms, 500);
        assert!(!config.cli.verbose);
        assert_eq!(config.cli.color, "auto");
    }

    #[test]
    fn test_idle_timeout_duration() {
        let mut config = ServerConfig::default();
        assert!(config.idle_timeout_duration().is_some());

        config.idle_timeout = 0;
        assert!(config.idle_timeout_duration().is_none());
    }

    #[test]
    fn test_save_and_load() {
        let temp = TempDir::new().unwrap();
        let path = temp.path().join("config.toml");

        let mut config = GlobalConfig::default();
        config.server.idle_timeout = 7200;
        config.cli.verbose = true;

        config.save(&path).unwrap();
        let loaded = GlobalConfig::load(&path).unwrap();

        assert_eq!(loaded.server.idle_timeout, 7200);
        assert!(loaded.cli.verbose);
    }

    #[test]
    fn test_load_missing_file() {
        let config = GlobalConfig::load("/nonexistent/config.toml").unwrap();
        // Should return defaults
        assert_eq!(config.server.idle_timeout, 3600);
    }

    #[test]
    fn test_parse_partial_config() {
        let temp = TempDir::new().unwrap();
        let path = temp.path().join("config.toml");

        // Write partial config (only server section)
        std::fs::write(
            &path,
            r#"
[server]
idle_timeout = 1800
"#,
        )
        .unwrap();

        let config = GlobalConfig::load(&path).unwrap();
        assert_eq!(config.server.idle_timeout, 1800);
        // Other fields should have defaults
        assert_eq!(config.server.startup_timeout, 300);
        assert!(config.server.parallel_init);
    }
}