Skip to main content

detrix_rs/
config.rs

1//! Configuration for the Detrix client.
2
3use std::env;
4use std::path::PathBuf;
5use std::time::Duration;
6
7use crate::Error;
8
9/// Default timeout for daemon health checks.
10const DEFAULT_HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(2);
11/// Default timeout for connection registration.
12const DEFAULT_REGISTER_TIMEOUT: Duration = Duration::from_secs(5);
13/// Default timeout for connection unregistration.
14const DEFAULT_UNREGISTER_TIMEOUT: Duration = Duration::from_secs(2);
15/// Default timeout for lldb-dap to start.
16const DEFAULT_LLDB_START_TIMEOUT: Duration = Duration::from_secs(10);
17
18/// Configuration for the Detrix client.
19#[derive(Debug, Clone)]
20pub struct Config {
21    /// Connection name (default: "detrix-client-{pid}")
22    pub name: Option<String>,
23
24    /// Control plane bind host (default: "127.0.0.1")
25    pub control_host: String,
26
27    /// Advertise host for registration with daemon.
28    /// If set, this host is sent to the daemon instead of `control_host`.
29    /// Useful in Docker/cloud where bind address (0.0.0.0) differs from
30    /// the reachable address (container hostname).
31    pub advertise_host: Option<String>,
32
33    /// Control plane port (0 = auto-assign)
34    pub control_port: u16,
35
36    /// Debug adapter port (0 = auto-assign)
37    pub debug_port: u16,
38
39    /// Detrix daemon URL (default: "http://127.0.0.1:8090")
40    pub daemon_url: String,
41
42    /// Path to lldb-dap binary (default: searches PATH)
43    pub lldb_dap_path: Option<PathBuf>,
44
45    /// Detrix home directory (default: ~/detrix)
46    pub detrix_home: Option<PathBuf>,
47
48    /// Override workspace root sent to daemon (default: current working directory).
49    /// Set this in Docker/cloud where the CWD doesn't match the build source path.
50    pub workspace_root: Option<String>,
51
52    /// Safe mode: only logpoints allowed, no breakpoint operations.
53    /// Recommended for production environments.
54    pub safe_mode: bool,
55
56    /// Explicit build commit override (optional)
57    pub build_commit: Option<String>,
58
59    /// Explicit build tag override (optional)
60    pub build_tag: Option<String>,
61
62    /// Timeout for daemon health checks
63    pub health_check_timeout: Duration,
64
65    /// Timeout for connection registration
66    pub register_timeout: Duration,
67
68    /// Timeout for connection unregistration
69    pub unregister_timeout: Duration,
70
71    /// Timeout for lldb-dap to start
72    pub lldb_start_timeout: Duration,
73}
74
75impl Default for Config {
76    fn default() -> Self {
77        Self {
78            name: None,
79            control_host: "127.0.0.1".to_string(),
80            advertise_host: None,
81            control_port: 0,
82            debug_port: 0,
83            daemon_url: "http://127.0.0.1:8090".to_string(),
84            lldb_dap_path: None,
85            detrix_home: None,
86            workspace_root: None,
87            safe_mode: false,
88            build_commit: None,
89            build_tag: None,
90            health_check_timeout: DEFAULT_HEALTH_CHECK_TIMEOUT,
91            // Aligned with Python/Go: 5s is sufficient for registration
92            register_timeout: DEFAULT_REGISTER_TIMEOUT,
93            unregister_timeout: DEFAULT_UNREGISTER_TIMEOUT,
94            lldb_start_timeout: DEFAULT_LLDB_START_TIMEOUT,
95        }
96    }
97}
98
99impl Config {
100    /// Create a new Config with defaults.
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// Apply environment variable overrides.
106    ///
107    /// Environment variables take precedence over defaults but are overridden
108    /// by explicitly set config values.
109    pub fn with_env_overrides(mut self) -> Self {
110        // Name
111        if self.name.is_none() {
112            if let Ok(name) = env::var("DETRIX_NAME") {
113                if !name.is_empty() {
114                    self.name = Some(name);
115                }
116            }
117        }
118
119        // Control host
120        if self.control_host == "127.0.0.1" {
121            if let Ok(host) = env::var("DETRIX_CONTROL_HOST") {
122                if !host.is_empty() {
123                    self.control_host = host;
124                }
125            }
126        }
127
128        // Advertise host (for Docker/cloud: the reachable hostname)
129        if self.advertise_host.is_none() {
130            if let Ok(host) = env::var("DETRIX_HOST") {
131                if !host.is_empty() {
132                    self.advertise_host = Some(host);
133                }
134            }
135        }
136
137        // Control port
138        if self.control_port == 0 {
139            if let Ok(port_str) = env::var("DETRIX_CONTROL_PORT") {
140                if let Ok(port) = port_str.parse() {
141                    self.control_port = port;
142                }
143            }
144        }
145
146        // Debug port
147        if self.debug_port == 0 {
148            if let Ok(port_str) = env::var("DETRIX_DEBUG_PORT") {
149                if let Ok(port) = port_str.parse() {
150                    self.debug_port = port;
151                }
152            }
153        }
154
155        // Daemon URL
156        if self.daemon_url == "http://127.0.0.1:8090" {
157            if let Ok(url) = env::var("DETRIX_DAEMON_URL") {
158                if !url.is_empty() {
159                    self.daemon_url = url;
160                }
161            }
162        }
163
164        // lldb-dap path
165        if self.lldb_dap_path.is_none() {
166            if let Ok(path) = env::var("DETRIX_LLDB_DAP_PATH") {
167                if !path.is_empty() {
168                    self.lldb_dap_path = Some(PathBuf::from(path));
169                }
170            }
171        }
172
173        // Detrix home
174        if self.detrix_home.is_none() {
175            if let Ok(home) = env::var("DETRIX_HOME") {
176                if !home.is_empty() {
177                    self.detrix_home = Some(PathBuf::from(home));
178                }
179            }
180        }
181
182        // Workspace root
183        if self.workspace_root.is_none() {
184            if let Ok(root) = env::var("DETRIX_WORKSPACE_ROOT") {
185                if !root.is_empty() {
186                    self.workspace_root = Some(root);
187                }
188            }
189        }
190
191        // Timeout overrides (values in seconds, e.g. "2.0")
192        if self.health_check_timeout == DEFAULT_HEALTH_CHECK_TIMEOUT {
193            if let Some(d) = Self::parse_duration_env("DETRIX_HEALTH_CHECK_TIMEOUT") {
194                self.health_check_timeout = d;
195            }
196        }
197        if self.register_timeout == DEFAULT_REGISTER_TIMEOUT {
198            if let Some(d) = Self::parse_duration_env("DETRIX_REGISTER_TIMEOUT") {
199                self.register_timeout = d;
200            }
201        }
202        if self.unregister_timeout == DEFAULT_UNREGISTER_TIMEOUT {
203            if let Some(d) = Self::parse_duration_env("DETRIX_UNREGISTER_TIMEOUT") {
204                self.unregister_timeout = d;
205            }
206        }
207
208        self
209    }
210
211    /// Parse a duration from an environment variable (value in seconds, e.g. "2.0").
212    fn parse_duration_env(key: &str) -> Option<Duration> {
213        env::var(key)
214            .ok()
215            .and_then(|v| v.parse::<f64>().ok())
216            .map(Duration::from_secs_f64)
217    }
218
219    /// Generate the connection name.
220    ///
221    /// Returns the configured name or generates one as "detrix-client-{pid}".
222    pub fn connection_name(&self) -> String {
223        self.name
224            .clone()
225            .unwrap_or_else(|| format!("detrix-client-{}", std::process::id()))
226    }
227
228    /// Get the detrix home directory.
229    ///
230    /// Returns the configured path or defaults to ~/detrix.
231    pub fn detrix_home_path(&self) -> Option<PathBuf> {
232        self.detrix_home
233            .clone()
234            .or_else(|| dirs::home_dir().map(|home| home.join("detrix")))
235    }
236}
237
238/// TLS configuration for daemon communication.
239#[derive(Debug, Clone)]
240pub struct TlsConfig {
241    /// Whether to verify TLS certificates (default: true).
242    pub verify: bool,
243    /// Path to CA bundle file for TLS verification.
244    pub ca_bundle: Option<PathBuf>,
245}
246
247impl Default for TlsConfig {
248    fn default() -> Self {
249        Self {
250            verify: true,
251            ca_bundle: None,
252        }
253    }
254}
255
256impl TlsConfig {
257    /// Validate the TLS configuration (fail fast on invalid CA bundle).
258    ///
259    /// # Errors
260    ///
261    /// Returns `Error::ConfigError` if the CA bundle path is specified but doesn't exist.
262    pub fn validate(&self) -> Result<(), Error> {
263        if let Some(ref path) = self.ca_bundle {
264            if !path.is_file() {
265                return Err(Error::ConfigError(format!(
266                    "CA bundle not found: {}",
267                    path.display()
268                )));
269            }
270        }
271        Ok(())
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn test_default_config() {
281        let config = Config::default();
282        assert!(config.name.is_none());
283        assert_eq!(config.control_host, "127.0.0.1");
284        assert_eq!(config.control_port, 0);
285        assert_eq!(config.debug_port, 0);
286        assert_eq!(config.daemon_url, "http://127.0.0.1:8090");
287        assert!(config.lldb_dap_path.is_none());
288        assert!(!config.safe_mode);
289    }
290
291    #[test]
292    fn test_register_timeout_default() {
293        let config = Config::default();
294        // Changed from 10s to 5s for consistency with Python/Go clients
295        assert_eq!(config.register_timeout, Duration::from_secs(5));
296    }
297
298    #[test]
299    fn test_connection_name_default() {
300        let config = Config::default();
301        let name = config.connection_name();
302        assert!(name.starts_with("detrix-client-"));
303    }
304
305    #[test]
306    fn test_connection_name_custom() {
307        let config = Config {
308            name: Some("my-service".to_string()),
309            ..Config::default()
310        };
311        assert_eq!(config.connection_name(), "my-service");
312    }
313
314    #[test]
315    fn test_detrix_home_path() {
316        let config = Config::default();
317        if let Some(path) = config.detrix_home_path() {
318            assert!(path.ends_with("detrix"));
319        }
320    }
321
322    #[test]
323    fn test_tls_config_default() {
324        let config = TlsConfig::default();
325        assert!(config.verify);
326        assert!(config.ca_bundle.is_none());
327    }
328
329    #[test]
330    fn test_tls_config_validate_missing_ca_bundle() {
331        let config = TlsConfig {
332            verify: true,
333            ca_bundle: Some(PathBuf::from("/nonexistent/ca.pem")),
334        };
335        assert!(config.validate().is_err());
336    }
337
338    #[test]
339    fn test_tls_config_validate_no_ca_bundle() {
340        let config = TlsConfig::default();
341        assert!(config.validate().is_ok());
342    }
343}