Skip to main content

actionqueue_daemon/
config.rs

1//! Daemon configuration type.
2//!
3//! This module defines the configuration surface for the ActionQueue daemon. The
4//! configuration is an operator-facing contract that must be explicit,
5//! deterministic, and serializable. It does not read from environment variables
6//! implicitly — defaults are stable and explicit.
7//!
8//! **Exception:** The tracing/logging subscriber (initialized during bootstrap,
9//! not by this module) reads the `RUST_LOG` environment variable for log-level
10//! filtering. This is standard Rust observability practice and does not affect
11//! engine behavior, mutation authority, or any durable state.
12//!
13//! # Invariant boundaries
14//!
15//! Configuration must not introduce any mutation lane outside the validated
16//! mutation authority defined in `invariant-boundaries-v0.1.md`. Configuration
17//! values must be explicit and inspectable to preserve auditability required by
18//! external systems.
19//!
20//! # Field semantics
21//!
22//! ## Read-only by default
23//!
24//! All introspection endpoints (health, ready, stats, task/run queries) are
25//! enabled by default. Control endpoints require explicit enablement via
26//! `enable_control`.
27//!
28//! ## Deterministic defaults
29//!
30//! The [`DaemonConfig::default`] constructor produces fixed, stable values.
31//! No environment variables or runtime detection influence defaults.
32
33use std::net::{IpAddr, Ipv4Addr, SocketAddr};
34use std::path::PathBuf;
35
36/// Daemon configuration for the ActionQueue service.
37///
38/// This struct defines the operator-facing configuration contract for the
39/// ActionQueue daemon. It is designed to be explicit, deterministic, and
40/// serializable.
41///
42/// # Default posture
43///
44/// The default configuration:
45/// - Binds the HTTP API to `127.0.0.1:8787`
46/// - Uses the literal string `~/.actionqueue/data` as the data directory (no expansion)
47/// - Has control endpoints disabled by default
48/// - Has metrics enabled on a separate port
49///
50/// # Invariants
51///
52/// - Configuration must not permit state mutation paths (no "auto-apply"
53///   flags or bypass toggles).
54/// - Config values must be explicit and inspectable to preserve auditability.
55/// - No implicit environment reads inside constructors or default methods.
56///
57/// # Example
58///
59/// ```rust
60/// use actionqueue_daemon::config::DaemonConfig;
61///
62/// let config = DaemonConfig::default();
63/// assert!(!config.enable_control);
64/// assert_eq!(config.bind_address.ip(), std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
65/// ```
66#[derive(Debug, Clone, PartialEq, Eq, Hash)]
67#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
68pub struct DaemonConfig {
69    /// The bind address (IP and port) for the daemon HTTP server.
70    ///
71    /// This is the primary operational surface for read-only introspection
72    /// endpoints. In v0.1, the daemon listens on localhost by default.
73    ///
74    /// # Default
75    ///
76    /// `127.0.0.1:8787`
77    pub bind_address: SocketAddr,
78
79    /// The data directory or persistence root path.
80    ///
81    /// This path is used for WAL files and optional snapshots. The daemon
82    /// expects to have read/write permissions on this directory.
83    /// The daemon expects `<data_dir>/wal/` and `<data_dir>/snapshots/` to exist or be creatable; filenames are fixed to `actionqueue.wal` and `snapshot.bin`.
84    ///
85    /// # Default
86    ///
87    /// The literal string `~/.actionqueue/data`. No expansion of `~` or environment
88    /// variables occurs in [`DaemonConfig::default`]. The caller/operator is
89    /// responsible for resolving the path to a concrete filesystem location if
90    /// required.
91    pub data_dir: PathBuf,
92
93    /// Feature flag to enable control endpoints.
94    ///
95    /// When `false` (the default), control endpoints are unreachable. When
96    /// `true`, control endpoints (`/api/v1/tasks/:task_id/cancel`,
97    /// `/api/v1/runs/:run_id/cancel`, `/api/v1/engine/pause`,
98    /// `/api/v1/engine/resume`) are registered and may be invoked.
99    ///
100    /// # Safety
101    ///
102    /// Control endpoints require explicit enablement to enforce least-privilege
103    /// by default. This prevents accidental activation of mutating operations.
104    ///
105    /// # Default
106    ///
107    /// `false`
108    pub enable_control: bool,
109
110    /// The bind address for Prometheus-compatible metrics.
111    ///
112    /// If `None`, metrics are disabled. If `Some`, metrics are exposed on the
113    /// specified socket address.
114    ///
115    /// # Default
116    ///
117    /// `Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9090))`
118    pub metrics_bind: Option<SocketAddr>,
119}
120
121impl Default for DaemonConfig {
122    fn default() -> Self {
123        Self {
124            bind_address: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8787),
125            data_dir: PathBuf::from("~/.actionqueue/data"),
126            enable_control: false,
127            metrics_bind: Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9090)),
128        }
129    }
130}
131
132/// Validation error for configuration values.
133#[derive(Debug, Clone, PartialEq, Eq)]
134#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
135pub struct ConfigError {
136    /// A machine-readable error code.
137    pub code: ConfigErrorCode,
138    /// A human-readable error message.
139    pub message: String,
140}
141
142impl std::fmt::Display for ConfigError {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        write!(f, "{}: {}", self.code, self.message)
145    }
146}
147
148impl std::error::Error for ConfigError {}
149
150/// Machine-readable configuration error codes.
151#[derive(Debug, Clone, PartialEq, Eq)]
152#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
153pub enum ConfigErrorCode {
154    /// The bind address is invalid (e.g., port out of range).
155    InvalidBindAddress,
156    /// The data directory path is empty or invalid.
157    InvalidDataDir,
158    /// The metrics bind address is invalid.
159    InvalidMetricsBind,
160    /// The metrics bind address conflicts with the main bind address.
161    PortConflict,
162}
163
164impl std::fmt::Display for ConfigErrorCode {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        match self {
167            ConfigErrorCode::InvalidBindAddress => write!(f, "invalid_bind_address"),
168            ConfigErrorCode::InvalidDataDir => write!(f, "invalid_data_dir"),
169            ConfigErrorCode::InvalidMetricsBind => write!(f, "invalid_metrics_bind"),
170            ConfigErrorCode::PortConflict => write!(f, "port_conflict"),
171        }
172    }
173}
174
175impl DaemonConfig {
176    /// Validate the configuration values.
177    ///
178    /// This method performs structural validation only and is side-effect free.
179    /// It does not perform any I/O or filesystem checks.
180    ///
181    /// # Errors
182    ///
183    /// Returns a `ConfigError` if any field contains an invalid value.
184    pub fn validate(&self) -> Result<(), ConfigError> {
185        // Validate bind address
186        if self.bind_address.port() == 0 {
187            return Err(ConfigError {
188                code: ConfigErrorCode::InvalidBindAddress,
189                message: "bind address port must be non-zero".to_string(),
190            });
191        }
192
193        // Validate metrics bind if present
194        if let Some(ref metrics_addr) = self.metrics_bind {
195            if metrics_addr.port() == 0 {
196                return Err(ConfigError {
197                    code: ConfigErrorCode::InvalidMetricsBind,
198                    message: "metrics bind address port must be non-zero".to_string(),
199                });
200            }
201            if *metrics_addr == self.bind_address {
202                return Err(ConfigError {
203                    code: ConfigErrorCode::PortConflict,
204                    message: format!(
205                        "metrics_bind {metrics_addr} conflicts with bind_address (same address \
206                         and port)",
207                    ),
208                });
209            }
210        }
211
212        // Validate data directory (structural check only)
213        if self.data_dir.as_os_str().is_empty() {
214            return Err(ConfigError {
215                code: ConfigErrorCode::InvalidDataDir,
216                message: "data directory path must not be empty".to_string(),
217            });
218        }
219
220        Ok(())
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_default_config() {
230        let config = DaemonConfig::default();
231        assert_eq!(config.bind_address.ip(), IpAddr::V4(Ipv4Addr::LOCALHOST));
232        assert_eq!(config.bind_address.port(), 8787);
233        assert_eq!(config.data_dir, PathBuf::from("~/.actionqueue/data"));
234        assert!(!config.enable_control);
235        assert!(config.metrics_bind.is_some());
236        let metrics_addr = config.metrics_bind.unwrap();
237        assert_eq!(metrics_addr.ip(), IpAddr::V4(Ipv4Addr::LOCALHOST));
238        assert_eq!(metrics_addr.port(), 9090);
239    }
240
241    #[test]
242    fn test_validate_valid_config() {
243        let config = DaemonConfig::default();
244        assert!(config.validate().is_ok());
245    }
246
247    #[test]
248    fn test_validate_zero_port_bind_address() {
249        let config = DaemonConfig {
250            bind_address: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
251            ..Default::default()
252        };
253        assert_eq!(config.validate().unwrap_err().code, ConfigErrorCode::InvalidBindAddress);
254    }
255
256    #[test]
257    fn test_validate_zero_port_metrics_bind() {
258        let config = DaemonConfig {
259            metrics_bind: Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)),
260            ..Default::default()
261        };
262        assert_eq!(config.validate().unwrap_err().code, ConfigErrorCode::InvalidMetricsBind);
263    }
264
265    #[test]
266    fn test_validate_port_conflict() {
267        let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8787);
268        let config =
269            DaemonConfig { bind_address: addr, metrics_bind: Some(addr), ..Default::default() };
270        let err = config.validate().unwrap_err();
271        assert_eq!(err.code, ConfigErrorCode::PortConflict);
272        assert!(err.message.contains("conflicts with bind_address"));
273    }
274
275    #[test]
276    fn test_validate_different_ports_ok() {
277        let config = DaemonConfig {
278            bind_address: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8787),
279            metrics_bind: Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9090)),
280            ..Default::default()
281        };
282        assert!(config.validate().is_ok());
283    }
284
285    #[test]
286    fn test_validate_empty_data_dir() {
287        let config = DaemonConfig { data_dir: PathBuf::new(), ..Default::default() };
288        assert_eq!(config.validate().unwrap_err().code, ConfigErrorCode::InvalidDataDir);
289    }
290
291    #[test]
292    fn test_config_equality() {
293        let config1 = DaemonConfig::default();
294        let config2 = DaemonConfig::default();
295        assert_eq!(config1, config2);
296    }
297
298    #[test]
299    fn test_config_cloning() {
300        let config = DaemonConfig::default();
301        let cloned = config.clone();
302        assert_eq!(config, cloned);
303    }
304}