//! Configuration management for the makima daemon. use config::{Config, Environment, File}; use serde::Deserialize; use std::collections::HashMap; use std::path::PathBuf; /// Bubblewrap sandbox configuration for Claude processes. #[derive(Debug, Clone, Deserialize, Default)] pub struct BubblewrapConfig { /// Enable bubblewrap sandboxing. #[serde(default)] pub enabled: bool, /// Path to bwrap binary (default: 'bwrap'). #[serde(default = "default_bwrap_command")] pub bwrap_command: String, /// Allow network access inside sandbox (default: true). #[serde(default = "default_true")] pub network: bool, /// Additional paths to bind read-only. #[serde(default)] pub ro_bind: Vec, /// Additional paths to bind read-write. #[serde(default)] pub rw_bind: Vec, } fn default_bwrap_command() -> String { "bwrap".to_string() } fn default_true() -> bool { true } /// 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, } 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, /// Arguments to pass before the default arguments. /// Useful for overriding defaults. #[serde(default, alias = "claudepreargs")] pub claude_pre_args: Vec, /// 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 (global cap). #[serde(default = "default_max_tasks", alias = "maxconcurrenttasks")] pub max_concurrent_tasks: u32, /// Maximum concurrent tasks per contract/supervisor. /// Standalone tasks are treated as their own single-task contract. #[serde(default = "default_max_tasks_per_contract", alias = "maxtaskspercontract")] pub max_tasks_per_contract: 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, /// Bubblewrap sandbox configuration. #[serde(default)] pub bubblewrap: BubblewrapConfig, /// Interval in seconds between heartbeat commits (WIP checkpoints). /// Set to 0 to disable. Default: 300 (5 minutes). #[serde(default = "default_heartbeat_commit_interval", alias = "heartbeatcommitintervalsecs")] pub heartbeat_commit_interval_secs: u64, /// Checkpoint patch storage configuration for task recovery. #[serde(default)] pub checkpoint_patches: CheckpointPatchConfig, } /// Configuration for checkpoint patch storage in PostgreSQL. /// Patches are stored to enable task recovery when local worktrees are lost. #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct CheckpointPatchConfig { /// Enable patch storage in PostgreSQL (default: true). #[serde(default = "default_true")] pub enabled: bool, /// Maximum patch size in bytes (default: 10MB). /// Patches larger than this will not be stored. #[serde(default = "default_max_patch_size", alias = "maxpatchsizebytes")] pub max_patch_size_bytes: usize, /// TTL for patches in hours (default: 168 = 7 days). /// Patches older than this will be automatically cleaned up. #[serde(default = "default_patch_ttl_hours", alias = "ttlhours")] pub ttl_hours: u64, } fn default_max_patch_size() -> usize { 10 * 1024 * 1024 // 10MB } fn default_patch_ttl_hours() -> u64 { 168 // 7 days } impl Default for CheckpointPatchConfig { fn default() -> Self { Self { enabled: true, max_patch_size_bytes: default_max_patch_size(), ttl_hours: default_patch_ttl_hours(), } } } fn default_claude_command() -> String { "claude".to_string() } fn default_heartbeat_commit_interval() -> u64 { 300 // 5 minutes } fn default_max_tasks() -> u32 { 4 } fn default_max_tasks_per_contract() -> u32 { 10 } 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(), max_tasks_per_contract: default_max_tasks_per_contract(), default_timeout_secs: 0, env_vars: HashMap::new(), bubblewrap: BubblewrapConfig::default(), heartbeat_commit_interval_secs: default_heartbeat_commit_interval(), checkpoint_patches: CheckpointPatchConfig::default(), } } } /// 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, } /// 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, /// Branch to checkout after cloning (defaults to default branch). #[serde(default)] branch: Option, /// 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 { 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::load_from_path(None) } /// Load configuration from a specific path plus standard sources. fn load_from_path(config_path: Option<&std::path::Path>) -> Result { 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::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 { // 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(); // Enable bubblewrap if --bubblewrap flag is set if args.bubblewrap { config.process.bubblewrap.enabled = true; } // 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, max_tasks_per_contract: 10, default_timeout_secs: 0, env_vars: HashMap::new(), bubblewrap: BubblewrapConfig::default(), heartbeat_commit_interval_secs: 300, checkpoint_patches: CheckpointPatchConfig::default(), }, 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 { std::env::var("HOME").ok().map(PathBuf::from) } pub fn config_dir() -> Option { #[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 } } }