//! 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<PathBuf>,
/// Additional paths to bind read-write.
#[serde(default)]
pub rw_bind: Vec<PathBuf>,
}
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,
/// Dependency ordering settings for task execution.
#[serde(default)]
pub dependency_ordering: DependencyOrderingConfig,
}
/// Dependency ordering configuration.
/// Controls how task dependencies are validated and auto-detected.
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct DependencyOrderingConfig {
/// Enable dependency ordering checks.
/// When enabled, tasks with unmet dependencies cannot start.
#[serde(default = "default_true")]
pub enabled: bool,
/// Auto-detect dependencies from file patterns.
/// Analyzes task plans to detect potential dependencies based on:
/// - Migration files -> backend code
/// - Types/models -> consumers
/// - APIs -> UI components
#[serde(default = "default_true")]
pub auto_detect: bool,
/// Warn on detected dependency violations.
/// Produces warnings when tasks may be executing out of order.
#[serde(default = "default_true")]
pub warn_on_violation: bool,
}
impl Default for DependencyOrderingConfig {
fn default() -> Self {
Self {
enabled: true,
auto_detect: true,
warn_on_violation: true,
}
}
}
/// 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 (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<String, String>,
/// 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<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();
// 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(),
dependency_ordering: DependencyOrderingConfig::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
}
}
}