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}