summaryrefslogblamecommitdiff
path: root/makima/daemon/src/config.rs
blob: 94d1e8a87983846e9a420877462c4e4dc2a5d2c9 (plain) (tree)
1
2
3
4
5
6
7
8
9
10
11










                                                   
                     
























                                              
                 

                                                                                               

                                            


























                                                                                     















                                                                  




























































































































































































































































































































































                                                                                                     



















































































































                                                                                                    
//! Configuration management for the makima daemon.

use config::{Config, Environment, File};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;

/// Root daemon configuration.
#[derive(Debug, Clone, Deserialize)]
pub struct DaemonConfig {
    /// Server connection settings.
    #[serde(default)]
    pub server: ServerConfig,

    /// Worktree settings.
    #[serde(default)]
    pub worktree: WorktreeConfig,

    /// Process settings.
    #[serde(default)]
    pub process: ProcessConfig,

    /// Local database settings.
    #[serde(default)]
    pub local_db: LocalDbConfig,

    /// Logging settings.
    #[serde(default)]
    pub logging: LoggingConfig,

    /// Repositories to auto-clone on startup.
    #[serde(default)]
    pub repos: ReposConfig,
}

/// Server connection configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ServerConfig {
    /// WebSocket URL of makima server (e.g., ws://localhost:8080 or wss://makima.example.com).
    /// Defaults to wss://api.makima.jp.
    #[serde(default = "default_server_url")]
    pub url: String,

    /// API key for authentication.
    #[serde(default, alias = "apikey")]
    pub api_key: String,

    /// Heartbeat interval in seconds.
    #[serde(default = "default_heartbeat_interval", alias = "heartbeatintervalsecs")]
    pub heartbeat_interval_secs: u64,

    /// Reconnect interval in seconds after connection loss.
    #[serde(default = "default_reconnect_interval", alias = "reconnectintervalsecs")]
    pub reconnect_interval_secs: u64,

    /// Maximum reconnect attempts before giving up (0 = infinite).
    #[serde(default, alias = "maxreconnectattempts")]
    pub max_reconnect_attempts: u32,
}

fn default_heartbeat_interval() -> u64 {
    30
}

fn default_reconnect_interval() -> u64 {
    5
}

fn default_server_url() -> String {
    "wss://api.makima.jp".to_string()
}

impl Default for ServerConfig {
    fn default() -> Self {
        Self {
            url: default_server_url(),
            api_key: String::new(),
            heartbeat_interval_secs: default_heartbeat_interval(),
            reconnect_interval_secs: default_reconnect_interval(),
            max_reconnect_attempts: 0,
        }
    }
}

/// Worktree configuration for task isolation.
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct WorktreeConfig {
    /// Base directory for worktrees (~/.makima/worktrees).
    #[serde(default = "default_worktree_base_dir", alias = "basedir")]
    pub base_dir: PathBuf,

    /// Base directory for cloned repositories (~/.makima/repos).
    #[serde(default = "default_repos_base_dir", alias = "reposdir")]
    pub repos_dir: PathBuf,

    /// Branch prefix for task branches.
    #[serde(default = "default_branch_prefix", alias = "branchprefix")]
    pub branch_prefix: String,

    /// Clean up worktrees on daemon start.
    #[serde(default, alias = "cleanuponstart")]
    pub cleanup_on_start: bool,

    /// Default target repository path for pushing completed branches.
    /// Used when task.target_repo_path is not set.
    #[serde(default, alias = "defaulttargetrepo")]
    pub default_target_repo: Option<PathBuf>,
}

fn default_worktree_base_dir() -> PathBuf {
    dirs::home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".makima")
        .join("worktrees")
}

fn default_repos_base_dir() -> PathBuf {
    dirs::home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".makima")
        .join("repos")
}

fn default_branch_prefix() -> String {
    "makima/task-".to_string()
}

impl Default for WorktreeConfig {
    fn default() -> Self {
        Self {
            base_dir: default_worktree_base_dir(),
            repos_dir: default_repos_base_dir(),
            branch_prefix: default_branch_prefix(),
            cleanup_on_start: false,
            default_target_repo: None,
        }
    }
}

/// Process configuration for Claude Code subprocess execution.
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ProcessConfig {
    /// Path or command for Claude Code CLI.
    #[serde(default = "default_claude_command", alias = "claudecommand")]
    pub claude_command: String,

    /// Additional arguments to pass to Claude Code.
    /// These are added after the default arguments.
    #[serde(default, alias = "claudeargs")]
    pub claude_args: Vec<String>,

    /// Arguments to pass before the default arguments.
    /// Useful for overriding defaults.
    #[serde(default, alias = "claudepreargs")]
    pub claude_pre_args: Vec<String>,

    /// Skip the --dangerously-skip-permissions flag (default: false).
    /// Set to true if you want to use Claude's permission system.
    #[serde(default, alias = "enablepermissions")]
    pub enable_permissions: bool,

    /// Skip the --verbose flag (default: false).
    #[serde(default, alias = "disableverbose")]
    pub disable_verbose: bool,

    /// Maximum concurrent tasks.
    #[serde(default = "default_max_tasks", alias = "maxconcurrenttasks")]
    pub max_concurrent_tasks: u32,

    /// Default timeout for tasks in seconds (0 = no timeout).
    #[serde(default, alias = "defaulttimeoutsecs")]
    pub default_timeout_secs: u64,

    /// Additional environment variables to pass to Claude Code.
    #[serde(default, alias = "envvars")]
    pub env_vars: HashMap<String, String>,
}

fn default_claude_command() -> String {
    "claude".to_string()
}

fn default_max_tasks() -> u32 {
    4
}

impl Default for ProcessConfig {
    fn default() -> Self {
        Self {
            claude_command: default_claude_command(),
            claude_args: Vec::new(),
            claude_pre_args: Vec::new(),
            enable_permissions: false,
            disable_verbose: false,
            max_concurrent_tasks: default_max_tasks(),
            default_timeout_secs: 0,
            env_vars: HashMap::new(),
        }
    }
}

/// Local database configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct LocalDbConfig {
    /// Path to local SQLite database.
    #[serde(default = "default_db_path")]
    pub path: PathBuf,
}

impl Default for LocalDbConfig {
    fn default() -> Self {
        Self {
            path: default_db_path(),
        }
    }
}

fn default_db_path() -> PathBuf {
    dirs::home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".makima")
        .join("daemon.db")
}

/// Logging configuration.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct LoggingConfig {
    /// Log level: "trace", "debug", "info", "warn", "error".
    #[serde(default = "default_log_level")]
    pub level: String,

    /// Log format: "pretty" or "json".
    #[serde(default = "default_log_format")]
    pub format: String,
}

fn default_log_level() -> String {
    "info".to_string()
}

fn default_log_format() -> String {
    "pretty".to_string()
}

/// Repository auto-clone configuration.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ReposConfig {
    /// Directory to clone repositories into (default: ~/.makima/home).
    #[serde(default = "default_home_dir")]
    pub home_dir: PathBuf,

    /// List of repositories to auto-clone on startup.
    /// Each entry can be a URL (e.g., "https://github.com/user/repo.git")
    /// or a shorthand (e.g., "github:user/repo").
    #[serde(default, alias = "autoclone")]
    pub auto_clone: Vec<RepoEntry>,
}

/// A repository entry for auto-cloning.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum RepoEntry {
    /// Simple URL string.
    Url(String),
    /// Detailed configuration.
    Config {
        /// Repository URL.
        url: String,
        /// Custom directory name (defaults to repo name from URL).
        #[serde(default)]
        name: Option<String>,
        /// Branch to checkout after cloning (defaults to default branch).
        #[serde(default)]
        branch: Option<String>,
        /// Whether to do a shallow clone (default: false).
        #[serde(default)]
        shallow: bool,
    },
}

impl RepoEntry {
    /// Get the URL for this repo entry.
    pub fn url(&self) -> &str {
        match self {
            RepoEntry::Url(url) => url,
            RepoEntry::Config { url, .. } => url,
        }
    }

    /// Get the custom name, if any.
    pub fn name(&self) -> Option<&str> {
        match self {
            RepoEntry::Url(_) => None,
            RepoEntry::Config { name, .. } => name.as_deref(),
        }
    }

    /// Get the branch to checkout, if any.
    pub fn branch(&self) -> Option<&str> {
        match self {
            RepoEntry::Url(_) => None,
            RepoEntry::Config { branch, .. } => branch.as_deref(),
        }
    }

    /// Whether to do a shallow clone.
    pub fn shallow(&self) -> bool {
        match self {
            RepoEntry::Url(_) => false,
            RepoEntry::Config { shallow, .. } => *shallow,
        }
    }

    /// Get the directory name to use (either custom name or derived from URL).
    pub fn dir_name(&self) -> Option<String> {
        if let Some(name) = self.name() {
            return Some(name.to_string());
        }

        // Derive from URL
        let url = self.url();

        // Handle shorthand formats
        let url = if url.starts_with("github:") {
            url.strip_prefix("github:").unwrap_or(url)
        } else if url.starts_with("gitlab:") {
            url.strip_prefix("gitlab:").unwrap_or(url)
        } else {
            url
        };

        // Extract repo name from URL
        url.trim_end_matches('/')
            .trim_end_matches(".git")
            .rsplit('/')
            .next()
            .map(|s| s.to_string())
    }

    /// Expand the URL (e.g., convert shorthand to full URL).
    pub fn expanded_url(&self) -> String {
        let url = self.url();

        if url.starts_with("github:") {
            format!("https://github.com/{}.git", url.strip_prefix("github:").unwrap_or(""))
        } else if url.starts_with("gitlab:") {
            format!("https://gitlab.com/{}.git", url.strip_prefix("gitlab:").unwrap_or(""))
        } else {
            url.to_string()
        }
    }
}

fn default_home_dir() -> PathBuf {
    dirs::home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".makima")
        .join("home")
}

impl DaemonConfig {
    /// Load configuration from files and environment variables.
    ///
    /// Configuration sources (in order of precedence):
    /// 1. Environment variables (MAKIMA_API_KEY, MAKIMA_DAEMON_SERVER_URL, etc.)
    /// 2. ./makima-daemon.toml (current directory)
    /// 3. ~/.config/makima-daemon/config.toml
    /// 4. /etc/makima-daemon/config.toml (Linux only)
    ///
    /// Environment variable examples:
    /// - MAKIMA_API_KEY=your-api-key (preferred)
    /// - MAKIMA_DAEMON_SERVER_URL=ws://localhost:8080
    /// - MAKIMA_DAEMON_PROCESS_MAXCONCURRENTTASKS=4
    pub fn load() -> Result<Self, config::ConfigError> {
        Self::load_from_path(None)
    }

    /// Load configuration from a specific path plus standard sources.
    fn load_from_path(config_path: Option<&std::path::Path>) -> Result<Self, config::ConfigError> {
        let mut builder = Config::builder();

        // System-wide config (Linux only)
        #[cfg(target_os = "linux")]
        {
            builder = builder.add_source(
                File::with_name("/etc/makima-daemon/config").required(false),
            );
        }

        // User config
        if let Some(config_dir) = dirs::config_dir() {
            let user_config = config_dir.join("makima-daemon").join("config");
            builder = builder.add_source(
                File::with_name(user_config.to_str().unwrap_or("")).required(false),
            );
        }

        // Local config
        builder = builder.add_source(File::with_name("makima-daemon").required(false));

        // Custom config file (if provided)
        if let Some(path) = config_path {
            builder = builder.add_source(
                File::with_name(path.to_str().unwrap_or("")).required(true),
            );
        }

        // Environment variables with underscore separator for nesting
        // e.g., MAKIMA_DAEMON_SERVER_URL -> server.url
        //       MAKIMA_DAEMON_SERVER_APIKEY -> server.api_key
        builder = builder.add_source(
            Environment::with_prefix("MAKIMA_DAEMON")
                .separator("_")
                .try_parsing(true),
        );

        let config = builder.build()?;
        let mut config: DaemonConfig = config.try_deserialize()?;

        // Check for MAKIMA_API_KEY environment variable (preferred over MAKIMA_DAEMON_SERVER_APIKEY)
        if let Ok(api_key) = std::env::var("MAKIMA_API_KEY") {
            config.server.api_key = api_key;
        }

        // Validate required fields (don't validate here - let load_with_cli do final validation)
        Ok(config)
    }

    /// Validate that required configuration fields are set.
    pub fn validate(&self) -> Result<(), config::ConfigError> {
        if self.server.api_key.is_empty() {
            return Err(config::ConfigError::Message(
                "API key is required. Set via MAKIMA_API_KEY, config file, or --api-key".to_string()
            ));
        }
        Ok(())
    }

    /// Load configuration with CLI argument overrides.
    ///
    /// Configuration sources (in order of precedence, highest first):
    /// 1. CLI arguments
    /// 2. Environment variables
    /// 3. Custom config file (if --config specified)
    /// 4. ./makima-daemon.toml (current directory)
    /// 5. ~/.config/makima-daemon/config.toml
    /// 6. /etc/makima-daemon/config.toml (Linux only)
    /// 7. Default values
    pub fn load_with_cli(cli: &crate::cli::Cli) -> Result<Self, config::ConfigError> {
        // Load base config (with optional custom config file)
        let mut config = Self::load_from_path(cli.config.as_deref())?;

        // Apply CLI overrides (highest priority)
        if let Some(ref repos_dir) = cli.repos_dir {
            config.worktree.repos_dir = repos_dir.clone();
        }
        if let Some(ref worktrees_dir) = cli.worktrees_dir {
            config.worktree.base_dir = worktrees_dir.clone();
        }
        if let Some(ref server_url) = cli.server_url {
            config.server.url = server_url.clone();
        }
        if let Some(ref api_key) = cli.api_key {
            config.server.api_key = api_key.clone();
        }
        if let Some(max_tasks) = cli.max_tasks {
            config.process.max_concurrent_tasks = max_tasks;
        }
        // Log level is always set (has default)
        config.logging.level = cli.log_level.clone();

        // Validate required fields after all sources are merged
        config.validate()?;

        Ok(config)
    }

    /// Create a minimal config for testing.
    #[cfg(test)]
    pub fn test_config() -> Self {
        Self {
            server: ServerConfig {
                url: "ws://localhost:8080".to_string(),
                api_key: "test-key".to_string(),
                heartbeat_interval_secs: 30,
                reconnect_interval_secs: 5,
                max_reconnect_attempts: 0,
            },
            worktree: WorktreeConfig {
                base_dir: PathBuf::from("/tmp/makima-daemon-test/worktrees"),
                repos_dir: PathBuf::from("/tmp/makima-daemon-test/repos"),
                branch_prefix: "makima/task-".to_string(),
                cleanup_on_start: true,
                default_target_repo: None,
            },
            process: ProcessConfig {
                claude_command: "claude".to_string(),
                claude_args: Vec::new(),
                claude_pre_args: Vec::new(),
                enable_permissions: false,
                disable_verbose: false,
                max_concurrent_tasks: 2,
                default_timeout_secs: 0,
                env_vars: HashMap::new(),
            },
            local_db: LocalDbConfig {
                path: PathBuf::from("/tmp/makima-daemon-test/state.db"),
            },
            logging: LoggingConfig::default(),
            repos: ReposConfig::default(),
        }
    }
}

/// Helper module for dirs crate (minimal subset).
mod dirs {
    use std::path::PathBuf;

    pub fn home_dir() -> Option<PathBuf> {
        std::env::var("HOME").ok().map(PathBuf::from)
    }

    pub fn config_dir() -> Option<PathBuf> {
        #[cfg(target_os = "macos")]
        {
            std::env::var("HOME")
                .ok()
                .map(|h| PathBuf::from(h).join("Library").join("Application Support"))
        }
        #[cfg(target_os = "linux")]
        {
            std::env::var("XDG_CONFIG_HOME")
                .ok()
                .map(PathBuf::from)
                .or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".config")))
        }
        #[cfg(target_os = "windows")]
        {
            std::env::var("APPDATA").ok().map(PathBuf::from)
        }
        #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
        {
            None
        }
    }
}