diff options
Diffstat (limited to 'makima/src/daemon/config.rs')
| -rw-r--r-- | makima/src/daemon/config.rs | 555 |
1 files changed, 555 insertions, 0 deletions
diff --git a/makima/src/daemon/config.rs b/makima/src/daemon/config.rs new file mode 100644 index 0000000..866ee70 --- /dev/null +++ b/makima/src/daemon/config.rs @@ -0,0 +1,555 @@ +//! 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: &super::cli::daemon::DaemonArgs) -> Result<Self, config::ConfigError> { + Self::load_with_daemon_args(cli) + } + + /// Load configuration from various sources with daemon CLI overrides. + pub fn load_with_daemon_args(args: &super::cli::daemon::DaemonArgs) -> Result<Self, config::ConfigError> { + // Load base config (with optional custom config file) + let mut config = Self::load_from_path(args.config.as_deref())?; + + // Apply CLI overrides (highest priority) + if let Some(ref repos_dir) = args.repos_dir { + config.worktree.repos_dir = repos_dir.clone(); + } + if let Some(ref worktrees_dir) = args.worktrees_dir { + config.worktree.base_dir = worktrees_dir.clone(); + } + if let Some(ref server_url) = args.server_url { + config.server.url = server_url.clone(); + } + if let Some(ref api_key) = args.api_key { + config.server.api_key = api_key.clone(); + } + if let Some(max_tasks) = args.max_tasks { + config.process.max_concurrent_tasks = max_tasks; + } + // Log level is always set (has default) + config.logging.level = args.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 + } + } +} |
