tazuna 0.1.0

TUI tool for managing multiple Claude Code sessions in parallel
Documentation
//! Configuration loading and management.
//!
//! Handles TOML config file at `~/.config/tazuna/config.toml`.

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

use crate::error::ConfigError;

/// Top-level configuration
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
    /// Session settings
    pub session: SessionConfig,
    /// Worktree settings
    pub worktree: WorktreeConfig,
    /// Notification settings
    pub notification: NotificationConfig,
    /// Log settings
    pub log: LogConfig,
    /// Claude Code settings
    pub claude: ClaudeConfig,
}

/// Session configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SessionConfig {
    /// Maximum concurrent sessions
    pub max_sessions: usize,
}

/// Git pull strategy
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PullStrategy {
    /// Standard merge pull
    #[default]
    Merge,
    /// Rebase on pull
    Rebase,
}

/// Worktree configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct WorktreeConfig {
    /// Auto-cleanup on session termination
    pub auto_cleanup: bool,
    /// Branch prefix for auto-generated branches
    pub branch_prefix: String,
    /// Base path for worktrees (supports ~ expansion)
    pub base_path: String,
    /// Git pull strategy
    pub pull_strategy: PullStrategy,
}

/// Notification configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct NotificationConfig {
    /// Enable terminal bell
    pub terminal_bell: bool,
    /// Webhook settings
    pub webhook: WebhookConfig,
}

/// Webhook configuration
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct WebhookConfig {
    /// Enable webhook
    pub enabled: bool,
    /// Webhook URL
    pub url: String,
}

/// Log configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LogConfig {
    /// Log directory path
    pub directory: String,
}

/// Claude Code configuration
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ClaudeConfig {
    /// Default arguments for Claude Code
    pub default_args: Vec<String>,
}

impl Default for SessionConfig {
    fn default() -> Self {
        Self { max_sessions: 10 }
    }
}

impl Default for WorktreeConfig {
    fn default() -> Self {
        Self {
            auto_cleanup: false,
            branch_prefix: "tazuna/".to_string(),
            base_path: "~/worktrees".to_string(),
            pull_strategy: PullStrategy::default(),
        }
    }
}

impl Default for NotificationConfig {
    fn default() -> Self {
        Self {
            terminal_bell: true,
            webhook: WebhookConfig::default(),
        }
    }
}

impl Default for LogConfig {
    fn default() -> Self {
        Self {
            directory: "~/.local/share/tazuna/logs".to_string(),
        }
    }
}

impl Config {
    /// Load config from default path
    pub fn load() -> Result<Self, ConfigError> {
        let path = Self::default_path();
        Self::load_from(&path)
    }

    /// Load config from specified path
    pub(crate) fn load_from(path: &PathBuf) -> Result<Self, ConfigError> {
        if !path.exists() {
            return Ok(Self::default());
        }

        let content = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFailed {
            path: path.clone(),
            source: e,
        })?;

        toml::from_str(&content).map_err(ConfigError::ParseFailed)
    }

    /// Default config file path
    #[must_use]
    pub fn default_path() -> PathBuf {
        dirs::config_dir()
            .unwrap_or_else(|| PathBuf::from("."))
            .join("tazuna")
            .join("config.toml")
    }

    /// Expand tilde in log directory path
    #[must_use]
    pub fn log_directory(&self) -> PathBuf {
        expand_tilde(&self.log.directory)
    }

    /// Expand tilde in worktree base path
    #[must_use]
    pub fn worktree_base_path(&self) -> PathBuf {
        expand_tilde(&self.worktree.base_path)
    }
}

/// Expand `~/` prefix to home directory
fn expand_tilde(path: &str) -> PathBuf {
    if let Some(stripped) = path.strip_prefix("~/")
        && let Some(home) = dirs::home_dir()
    {
        return home.join(stripped);
    }
    PathBuf::from(path)
}

#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
    use super::*;
    use rstest::rstest;

    #[test]
    fn default_config_values() {
        let config = Config::default();
        assert_eq!(config.session.max_sessions, 10);
        assert!(!config.worktree.auto_cleanup);
        assert_eq!(config.worktree.branch_prefix, "tazuna/");
        assert_eq!(config.worktree.base_path, "~/worktrees");
        assert!(config.notification.terminal_bell);
        assert!(!config.notification.webhook.enabled);
        assert!(config.notification.webhook.url.is_empty());
        assert!(config.claude.default_args.is_empty());
    }

    #[test]
    fn parse_minimal_toml() {
        let toml_str = r"
            [session]
            max_sessions = 5
        ";
        let config: Config = toml::from_str(toml_str).expect("failed to parse toml");
        assert_eq!(config.session.max_sessions, 5);
        // Other values should be defaults
        assert!(!config.worktree.auto_cleanup);
        assert_eq!(config.worktree.branch_prefix, "tazuna/");
        assert_eq!(config.worktree.base_path, "~/worktrees");
    }

    #[test]
    fn parse_full_toml() {
        let toml_str = r#"
            [session]
            max_sessions = 20

            [worktree]
            auto_cleanup = true
            branch_prefix = "feature/"
            base_path = "/custom/worktrees"

            [notification]
            terminal_bell = true

            [notification.webhook]
            enabled = true
            url = "https://hooks.example.com"

            [log]
            directory = "/var/log/tazuna"

            [claude]
            default_args = ["--model", "opus"]
        "#;
        let config: Config = toml::from_str(toml_str).expect("failed to parse toml");
        assert_eq!(config.session.max_sessions, 20);
        assert!(config.worktree.auto_cleanup);
        assert_eq!(config.worktree.branch_prefix, "feature/");
        assert_eq!(config.worktree.base_path, "/custom/worktrees");
        assert!(config.notification.terminal_bell);
        assert!(config.notification.webhook.enabled);
        assert_eq!(config.notification.webhook.url, "https://hooks.example.com");
        assert_eq!(config.log.directory, "/var/log/tazuna");
        assert_eq!(config.claude.default_args, vec!["--model", "opus"]);
    }

    #[test]
    fn load_nonexistent_returns_default() {
        let path = PathBuf::from("/nonexistent/path/config.toml");
        let config = Config::load_from(&path).expect("should return default for nonexistent path");
        assert_eq!(config.session.max_sessions, 10);
    }

    #[test]
    fn expand_tilde_replaces_home() {
        let result = expand_tilde("~/some/path");
        assert!(!result.to_string_lossy().starts_with('~'));
        assert!(result.to_string_lossy().ends_with("some/path"));
    }

    #[test]
    fn expand_tilde_keeps_absolute() {
        let result = expand_tilde("/absolute/path");
        assert_eq!(result, PathBuf::from("/absolute/path"));
    }

    #[test]
    fn pull_strategy_default_is_merge() {
        assert_eq!(PullStrategy::default(), PullStrategy::Merge);
    }

    #[rstest]
    #[case("merge", PullStrategy::Merge)]
    #[case("rebase", PullStrategy::Rebase)]
    fn pull_strategy_parse(#[case] strategy_str: &str, #[case] expected: PullStrategy) {
        let toml_str = format!(
            r#"
            [worktree]
            pull_strategy = "{strategy_str}"
        "#
        );
        let config: Config = toml::from_str(&toml_str).expect("failed to parse toml");
        assert_eq!(config.worktree.pull_strategy, expected);
    }

    #[test]
    fn default_path_returns_config_toml() {
        let path = Config::default_path();
        assert!(path.to_string_lossy().contains("tazuna"));
        assert!(path.to_string_lossy().ends_with("config.toml"));
    }

    #[test]
    fn load_from_invalid_toml() {
        let temp = tempfile::tempdir().expect("create temp dir");
        let config_path = temp.path().join("config.toml");
        std::fs::write(&config_path, "invalid [ toml syntax").expect("write config");

        let result = Config::load_from(&config_path);
        assert!(result.is_err());
        assert!(matches!(
            result.expect_err("should be parse error"),
            ConfigError::ParseFailed(_)
        ));
    }

    #[test]
    fn load_from_valid_toml() {
        let temp = tempfile::tempdir().expect("create temp dir");
        let config_path = temp.path().join("config.toml");
        std::fs::write(
            &config_path,
            r"
            [session]
            max_sessions = 15
            ",
        )
        .expect("write config");

        let config = Config::load_from(&config_path).expect("load config");
        assert_eq!(config.session.max_sessions, 15);
    }

    #[test]
    fn load_from_unreadable_path() {
        // Create a directory instead of a file to trigger read error
        let temp = tempfile::tempdir().expect("create temp dir");
        let config_path = temp.path().join("config.toml");
        std::fs::create_dir(&config_path).expect("create dir as file");

        let result = Config::load_from(&config_path);
        assert!(result.is_err());
        assert!(matches!(
            result.expect_err("should be read error"),
            ConfigError::ReadFailed { .. }
        ));
    }

    #[test]
    fn load_returns_default_when_no_config() {
        // Config::load() uses default_path which likely doesn't exist in test env
        // This should return default config without error
        let config = Config::load().expect("load should succeed with default");
        assert_eq!(config.session.max_sessions, 10);
    }
}