openlatch_client/core/config/
mod.rs1use std::path::{Path, PathBuf};
9
10use serde::Deserialize;
11
12use crate::error::{OlError, ERR_INVALID_CONFIG, ERR_PORT_IN_USE};
13
14#[derive(Debug, Clone)]
22pub struct UpdateConfig {
23 pub check: bool,
25}
26
27impl Default for UpdateConfig {
28 fn default() -> Self {
29 Self { check: true }
30 }
31}
32
33pub fn openlatch_dir() -> PathBuf {
42 #[cfg(windows)]
43 {
44 dirs::data_dir()
45 .unwrap_or_else(|| dirs::home_dir().expect("home directory must exist"))
46 .join("openlatch")
47 }
48 #[cfg(not(windows))]
49 {
50 dirs::home_dir()
51 .expect("home directory must exist")
52 .join(".openlatch")
53 }
54}
55
56#[derive(Debug, Clone)]
65pub struct Config {
66 pub port: u16,
68 pub log_dir: PathBuf,
70 pub log_level: String,
72 pub retention_days: u32,
74 pub extra_patterns: Vec<String>,
76 pub foreground: bool,
78 pub update: UpdateConfig,
80}
81
82impl Config {
83 pub fn defaults() -> Self {
85 Self {
86 port: 7443,
87 log_dir: openlatch_dir().join("logs"),
88 log_level: "info".into(),
89 retention_days: 30,
90 extra_patterns: vec![],
91 foreground: false,
92 update: UpdateConfig::default(),
93 }
94 }
95
96 pub fn load(
109 cli_port: Option<u16>,
110 cli_log_level: Option<String>,
111 cli_foreground: bool,
112 ) -> Result<Self, OlError> {
113 let mut cfg = Self::defaults();
114
115 let config_path = openlatch_dir().join("config.toml");
117 if config_path.exists() {
118 let raw = std::fs::read_to_string(&config_path).map_err(|e| {
119 OlError::new(ERR_INVALID_CONFIG, format!("Cannot read config file: {e}"))
120 .with_suggestion("Check that the file is readable and not corrupted.")
121 })?;
122 let toml_cfg: TomlConfig = toml::from_str(&raw).map_err(|e| {
123 OlError::new(
124 ERR_INVALID_CONFIG,
125 format!("Invalid TOML in config file: {e}"),
126 )
127 .with_suggestion("Check your config.toml for syntax errors.")
128 .with_docs("https://docs.openlatch.ai/configuration")
129 })?;
130 if let Some(daemon) = toml_cfg.daemon {
131 if let Some(port) = daemon.port {
132 cfg.port = port;
133 }
134 }
135 if let Some(logging) = toml_cfg.logging {
136 if let Some(level) = logging.level {
137 cfg.log_level = level;
138 }
139 if let Some(dir) = logging.dir {
140 cfg.log_dir = PathBuf::from(dir);
141 }
142 if let Some(days) = logging.retention_days {
143 cfg.retention_days = days;
144 }
145 }
146 if let Some(privacy) = toml_cfg.privacy {
147 if let Some(patterns) = privacy.extra_patterns {
148 cfg.extra_patterns = patterns;
149 }
150 }
151 if let Some(update) = toml_cfg.update {
152 if let Some(check) = update.check {
153 cfg.update.check = check;
154 }
155 }
156 }
157
158 if let Ok(val) = std::env::var("OPENLATCH_PORT") {
160 cfg.port = val.parse::<u16>().map_err(|_| {
161 OlError::new(
162 ERR_INVALID_CONFIG,
163 format!("OPENLATCH_PORT is not a valid port number: '{val}'"),
164 )
165 .with_suggestion("Set OPENLATCH_PORT to an integer between 1024 and 65535.")
166 })?;
167 }
168 if let Ok(val) = std::env::var("OPENLATCH_LOG_DIR") {
169 cfg.log_dir = PathBuf::from(val);
170 }
171 if let Ok(val) = std::env::var("OPENLATCH_LOG") {
172 cfg.log_level = val;
173 }
174 if let Ok(val) = std::env::var("OPENLATCH_RETENTION_DAYS") {
175 cfg.retention_days = val.parse::<u32>().map_err(|_| {
176 OlError::new(
177 ERR_INVALID_CONFIG,
178 format!("OPENLATCH_RETENTION_DAYS is not a valid integer: '{val}'"),
179 )
180 .with_suggestion("Set OPENLATCH_RETENTION_DAYS to a positive integer.")
181 })?;
182 }
183 if let Ok(val) = std::env::var("OPENLATCH_UPDATE_CHECK") {
185 if val == "false" || val == "0" {
186 cfg.update.check = false;
187 }
188 }
189
190 if let Some(port) = cli_port {
192 cfg.port = port;
193 }
194 if let Some(level) = cli_log_level {
195 cfg.log_level = level;
196 }
197 if cli_foreground {
198 cfg.foreground = true;
199 }
200
201 Ok(cfg)
202 }
203}
204
205#[derive(Debug, Deserialize)]
210struct TomlConfig {
211 #[serde(default)]
212 daemon: Option<DaemonToml>,
213 #[serde(default)]
214 logging: Option<LoggingToml>,
215 #[serde(default)]
216 privacy: Option<PrivacyToml>,
217 #[serde(default)]
218 update: Option<UpdateToml>,
219}
220
221#[derive(Debug, Deserialize)]
222struct DaemonToml {
223 port: Option<u16>,
224}
225
226#[derive(Debug, Deserialize)]
227struct LoggingToml {
228 level: Option<String>,
229 dir: Option<String>,
230 retention_days: Option<u32>,
231}
232
233#[derive(Debug, Deserialize)]
234struct PrivacyToml {
235 extra_patterns: Option<Vec<String>>,
236}
237
238#[derive(Debug, Deserialize)]
239struct UpdateToml {
240 check: Option<bool>,
241}
242
243pub fn generate_default_config_toml(port: u16) -> String {
252 format!(
253 r#"# OpenLatch Configuration
254# Uncomment and modify values to customize behavior.
255
256[daemon]
257port = {port}
258# SECURITY: bind address is always 127.0.0.1 — not configurable
259
260[logging]
261# level = "info"
262# dir = "~/.openlatch/logs"
263# retention_days = 30
264
265[privacy]
266# Extra regex patterns for secret masking (additive to built-ins).
267# Each entry is a regex string applied to JSON string values.
268# extra_patterns = ["CUSTOM_SECRET_[A-Z0-9]{{32}}"]
269
270# [update]
271# check = true # Set to false to disable update checks on daemon start
272"#
273 )
274}
275
276pub fn ensure_config(port: u16) -> Result<PathBuf, OlError> {
285 let dir = openlatch_dir();
286 std::fs::create_dir_all(&dir).map_err(|e| {
287 OlError::new(
288 ERR_INVALID_CONFIG,
289 format!("Cannot create config directory '{}': {e}", dir.display()),
290 )
291 .with_suggestion("Check that you have write permission to your home directory.")
292 })?;
293
294 let config_path = dir.join("config.toml");
295 if !config_path.exists() {
296 let content = generate_default_config_toml(port);
297 std::fs::write(&config_path, content).map_err(|e| {
298 OlError::new(
299 ERR_INVALID_CONFIG,
300 format!("Cannot write config file '{}': {e}", config_path.display()),
301 )
302 .with_suggestion("Check that you have write permission to ~/.openlatch/.")
303 })?;
304 }
305
306 Ok(config_path)
307}
308
309pub fn generate_token() -> String {
326 let a = uuid::Uuid::new_v4();
327 let b = uuid::Uuid::new_v4();
328 format!("{}{}", a.simple(), b.simple())
329}
330
331pub fn ensure_token(dir: &Path) -> Result<String, OlError> {
347 let token_path = dir.join("daemon.token");
348
349 if token_path.exists() {
350 let token = std::fs::read_to_string(&token_path).map_err(|e| {
352 OlError::new(
353 crate::error::ERR_INVALID_CONFIG,
354 format!("Cannot read token file '{}': {e}", token_path.display()),
355 )
356 .with_suggestion("Check that the file exists and is readable.")
357 })?;
358 return Ok(token.trim().to_string());
359 }
360
361 let token = generate_token();
363 if let Some(parent) = token_path.parent() {
364 std::fs::create_dir_all(parent).map_err(|e| {
365 OlError::new(
366 crate::error::ERR_INVALID_CONFIG,
367 format!("Cannot create directory '{}': {e}", parent.display()),
368 )
369 .with_suggestion("Check that you have write permission to the parent directory.")
370 })?;
371 }
372 std::fs::write(&token_path, &token).map_err(|e| {
373 OlError::new(
374 crate::error::ERR_INVALID_CONFIG,
375 format!("Cannot write token file '{}': {e}", token_path.display()),
376 )
377 .with_suggestion("Check that you have write permission to the openlatch directory.")
378 })?;
379
380 #[cfg(unix)]
383 {
384 use std::os::unix::fs::PermissionsExt;
385 let perms = std::fs::Permissions::from_mode(0o600);
386 std::fs::set_permissions(&token_path, perms).map_err(|e| {
387 OlError::new(
388 crate::error::ERR_INVALID_CONFIG,
389 format!("Cannot set permissions on token file: {e}"),
390 )
391 .with_suggestion("Check that you have permission to modify file attributes.")
392 })?;
393 }
394
395 Ok(token)
396}
397
398pub const PORT_RANGE_START: u16 = 7443;
404pub const PORT_RANGE_END: u16 = 7543;
406
407pub fn probe_free_port(start: u16, end: u16) -> Result<u16, OlError> {
416 for port in start..=end {
417 if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
418 return Ok(port);
419 }
420 }
421 Err(OlError::new(
422 ERR_PORT_IN_USE,
423 format!("No free port found in range {start}-{end}"),
424 )
425 .with_suggestion(format!(
426 "Free a port in the {start}-{end} range, or set OPENLATCH_PORT to a specific port."
427 ))
428 .with_docs("https://docs.openlatch.ai/errors/OL-1500"))
429}
430
431pub fn write_port_file(port: u16) -> Result<(), OlError> {
436 let path = openlatch_dir().join("daemon.port");
437 std::fs::write(&path, port.to_string()).map_err(|e| {
438 OlError::new(
439 ERR_INVALID_CONFIG,
440 format!("Cannot write port file '{}': {e}", path.display()),
441 )
442 })?;
443 Ok(())
444}
445
446pub fn read_port_file() -> Option<u16> {
450 let path = openlatch_dir().join("daemon.port");
451 std::fs::read_to_string(path)
452 .ok()?
453 .trim()
454 .parse::<u16>()
455 .ok()
456}
457
458#[cfg(test)]
463mod tests {
464 use super::*;
465 use tempfile::TempDir;
466
467 #[test]
468 fn test_config_defaults_values() {
469 let cfg = Config::defaults();
471 assert_eq!(cfg.port, 7443, "Default port must be 7443");
472 assert_eq!(cfg.log_level, "info", "Default log level must be info");
473 assert_eq!(cfg.retention_days, 30, "Default retention must be 30 days");
474 }
475
476 #[test]
477 fn test_config_loads_from_toml_file() {
478 let tmp = TempDir::new().unwrap();
480 let config_path = tmp.path().join("config.toml");
481 std::fs::write(
482 &config_path,
483 r#"
484[daemon]
485port = 8080
486"#,
487 )
488 .unwrap();
489
490 let raw = std::fs::read_to_string(&config_path).unwrap();
497 let toml_cfg: TomlConfig = toml::from_str(&raw).unwrap();
498 let daemon = toml_cfg.daemon.unwrap();
499 assert_eq!(daemon.port, Some(8080));
500 }
501
502 #[test]
503 fn test_config_cli_port_overrides_default() {
504 let cfg = Config::load(Some(9000), None, false)
506 .expect("Config::load should succeed with valid CLI port");
507 assert_eq!(cfg.port, 9000, "CLI port should override default");
508 }
509
510 #[test]
511 fn test_generate_token_produces_64_char_hex() {
512 let token = generate_token();
514 assert_eq!(
515 token.len(),
516 64,
517 "Token must be 64 characters (32 bytes hex-encoded), got: {token}"
518 );
519 assert!(
520 token.chars().all(|c| c.is_ascii_hexdigit()),
521 "Token must be hex-encoded, got: {token}"
522 );
523 }
524
525 #[test]
526 fn test_ensure_token_creates_and_returns_token() {
527 let tmp = TempDir::new().unwrap();
529
530 let token1 = ensure_token(tmp.path()).expect("First ensure_token call should succeed");
532 assert_eq!(token1.len(), 64, "Generated token must be 64 chars");
533 assert!(
534 tmp.path().join("daemon.token").exists(),
535 "Token file must be created"
536 );
537
538 let token2 = ensure_token(tmp.path()).expect("Second ensure_token call should succeed");
540 assert_eq!(token1, token2, "Second call must return the same token");
541 }
542
543 #[cfg(unix)]
544 #[test]
545 fn test_ensure_token_file_has_mode_0600() {
546 use std::os::unix::fs::PermissionsExt;
548
549 let tmp = TempDir::new().unwrap();
550 ensure_token(tmp.path()).expect("ensure_token should succeed");
551
552 let token_path = tmp.path().join("daemon.token");
553 let metadata = std::fs::metadata(&token_path).unwrap();
554 let mode = metadata.permissions().mode() & 0o777;
555 assert_eq!(mode, 0o600, "Token file must have mode 0600, got: {mode:o}");
556 }
557
558 #[test]
559 fn test_generate_default_config_toml_format() {
560 let content = generate_default_config_toml(7443);
563
564 assert!(
565 content.contains("port = 7443"),
566 "Must contain active port line: {content}"
567 );
568 assert!(
570 content.contains("[daemon]"),
571 "Must contain [daemon] section: {content}"
572 );
573 assert!(
575 content.contains("# level ="),
576 "level must be commented out: {content}"
577 );
578 assert!(
579 content.contains("# retention_days ="),
580 "retention_days must be commented: {content}"
581 );
582 }
583
584 #[test]
585 fn test_config_extra_patterns_defaults_empty() {
586 let cfg = Config::defaults();
588 assert!(
589 cfg.extra_patterns.is_empty(),
590 "Default extra_patterns must be empty"
591 );
592 }
593
594 #[test]
595 fn test_probe_free_port_finds_available_port() {
596 let port = probe_free_port(PORT_RANGE_START, PORT_RANGE_END)
598 .expect("should find at least one free port");
599 assert!((PORT_RANGE_START..=PORT_RANGE_END).contains(&port));
600 }
601
602 #[test]
603 fn test_probe_free_port_skips_occupied_port() {
604 let listener =
606 std::net::TcpListener::bind(("127.0.0.1", 0)).expect("should bind to random port");
607 let occupied = listener.local_addr().unwrap().port();
608
609 let result = probe_free_port(occupied, occupied);
611 assert!(
612 result.is_err(),
613 "must fail when only port in range is occupied"
614 );
615
616 if occupied < 65535 {
618 let result = probe_free_port(occupied, occupied + 1);
619 assert!(result.is_ok(), "should find next port after occupied one");
620 assert_eq!(result.unwrap(), occupied + 1);
621 }
622 }
623
624 #[test]
625 fn test_write_and_read_port_file_round_trip() {
626 let tmp = TempDir::new().unwrap();
627 let port_path = tmp.path().join("daemon.port");
628
629 std::fs::write(&port_path, "8080").unwrap();
631 let content = std::fs::read_to_string(&port_path).unwrap();
632 assert_eq!(content.trim().parse::<u16>().unwrap(), 8080);
633 }
634}