use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::error::{OlError, ERR_INVALID_CONFIG, ERR_PORT_IN_USE};
#[derive(Debug, Clone)]
pub struct UpdateConfig {
pub check: bool,
}
impl Default for UpdateConfig {
fn default() -> Self {
Self { check: true }
}
}
pub fn openlatch_dir() -> PathBuf {
#[cfg(windows)]
{
dirs::data_dir()
.unwrap_or_else(|| dirs::home_dir().expect("home directory must exist"))
.join("openlatch")
}
#[cfg(not(windows))]
{
dirs::home_dir()
.expect("home directory must exist")
.join(".openlatch")
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub port: u16,
pub log_dir: PathBuf,
pub log_level: String,
pub retention_days: u32,
pub extra_patterns: Vec<String>,
pub foreground: bool,
pub update: UpdateConfig,
}
impl Config {
pub fn defaults() -> Self {
Self {
port: 7443,
log_dir: openlatch_dir().join("logs"),
log_level: "info".into(),
retention_days: 30,
extra_patterns: vec![],
foreground: false,
update: UpdateConfig::default(),
}
}
pub fn load(
cli_port: Option<u16>,
cli_log_level: Option<String>,
cli_foreground: bool,
) -> Result<Self, OlError> {
let mut cfg = Self::defaults();
let config_path = openlatch_dir().join("config.toml");
if config_path.exists() {
let raw = std::fs::read_to_string(&config_path).map_err(|e| {
OlError::new(ERR_INVALID_CONFIG, format!("Cannot read config file: {e}"))
.with_suggestion("Check that the file is readable and not corrupted.")
})?;
let toml_cfg: TomlConfig = toml::from_str(&raw).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Invalid TOML in config file: {e}"),
)
.with_suggestion("Check your config.toml for syntax errors.")
.with_docs("https://docs.openlatch.ai/configuration")
})?;
if let Some(daemon) = toml_cfg.daemon {
if let Some(port) = daemon.port {
cfg.port = port;
}
}
if let Some(logging) = toml_cfg.logging {
if let Some(level) = logging.level {
cfg.log_level = level;
}
if let Some(dir) = logging.dir {
cfg.log_dir = PathBuf::from(dir);
}
if let Some(days) = logging.retention_days {
cfg.retention_days = days;
}
}
if let Some(privacy) = toml_cfg.privacy {
if let Some(patterns) = privacy.extra_patterns {
cfg.extra_patterns = patterns;
}
}
if let Some(update) = toml_cfg.update {
if let Some(check) = update.check {
cfg.update.check = check;
}
}
}
if let Ok(val) = std::env::var("OPENLATCH_PORT") {
cfg.port = val.parse::<u16>().map_err(|_| {
OlError::new(
ERR_INVALID_CONFIG,
format!("OPENLATCH_PORT is not a valid port number: '{val}'"),
)
.with_suggestion("Set OPENLATCH_PORT to an integer between 1024 and 65535.")
})?;
}
if let Ok(val) = std::env::var("OPENLATCH_LOG_DIR") {
cfg.log_dir = PathBuf::from(val);
}
if let Ok(val) = std::env::var("OPENLATCH_LOG") {
cfg.log_level = val;
}
if let Ok(val) = std::env::var("OPENLATCH_RETENTION_DAYS") {
cfg.retention_days = val.parse::<u32>().map_err(|_| {
OlError::new(
ERR_INVALID_CONFIG,
format!("OPENLATCH_RETENTION_DAYS is not a valid integer: '{val}'"),
)
.with_suggestion("Set OPENLATCH_RETENTION_DAYS to a positive integer.")
})?;
}
if let Ok(val) = std::env::var("OPENLATCH_UPDATE_CHECK") {
if val == "false" || val == "0" {
cfg.update.check = false;
}
}
if let Some(port) = cli_port {
cfg.port = port;
}
if let Some(level) = cli_log_level {
cfg.log_level = level;
}
if cli_foreground {
cfg.foreground = true;
}
Ok(cfg)
}
}
#[derive(Debug, Deserialize)]
struct TomlConfig {
#[serde(default)]
daemon: Option<DaemonToml>,
#[serde(default)]
logging: Option<LoggingToml>,
#[serde(default)]
privacy: Option<PrivacyToml>,
#[serde(default)]
update: Option<UpdateToml>,
}
#[derive(Debug, Deserialize)]
struct DaemonToml {
port: Option<u16>,
}
#[derive(Debug, Deserialize)]
struct LoggingToml {
level: Option<String>,
dir: Option<String>,
retention_days: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct PrivacyToml {
extra_patterns: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct UpdateToml {
check: Option<bool>,
}
pub fn generate_default_config_toml(port: u16) -> String {
format!(
r#"# OpenLatch Configuration
# Uncomment and modify values to customize behavior.
[daemon]
port = {port}
# SECURITY: bind address is always 127.0.0.1 — not configurable
[logging]
# level = "info"
# dir = "~/.openlatch/logs"
# retention_days = 30
[privacy]
# Extra regex patterns for secret masking (additive to built-ins).
# Each entry is a regex string applied to JSON string values.
# extra_patterns = ["CUSTOM_SECRET_[A-Z0-9]{{32}}"]
# [update]
# check = true # Set to false to disable update checks on daemon start
"#
)
}
pub fn ensure_config(port: u16) -> Result<PathBuf, OlError> {
let dir = openlatch_dir();
std::fs::create_dir_all(&dir).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot create config directory '{}': {e}", dir.display()),
)
.with_suggestion("Check that you have write permission to your home directory.")
})?;
let config_path = dir.join("config.toml");
if !config_path.exists() {
let content = generate_default_config_toml(port);
std::fs::write(&config_path, content).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot write config file '{}': {e}", config_path.display()),
)
.with_suggestion("Check that you have write permission to ~/.openlatch/.")
})?;
}
Ok(config_path)
}
pub fn generate_token() -> String {
let a = uuid::Uuid::new_v4();
let b = uuid::Uuid::new_v4();
format!("{}{}", a.simple(), b.simple())
}
pub fn ensure_token(dir: &Path) -> Result<String, OlError> {
let token_path = dir.join("daemon.token");
if token_path.exists() {
let token = std::fs::read_to_string(&token_path).map_err(|e| {
OlError::new(
crate::error::ERR_INVALID_CONFIG,
format!("Cannot read token file '{}': {e}", token_path.display()),
)
.with_suggestion("Check that the file exists and is readable.")
})?;
return Ok(token.trim().to_string());
}
let token = generate_token();
if let Some(parent) = token_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
OlError::new(
crate::error::ERR_INVALID_CONFIG,
format!("Cannot create directory '{}': {e}", parent.display()),
)
.with_suggestion("Check that you have write permission to the parent directory.")
})?;
}
std::fs::write(&token_path, &token).map_err(|e| {
OlError::new(
crate::error::ERR_INVALID_CONFIG,
format!("Cannot write token file '{}': {e}", token_path.display()),
)
.with_suggestion("Check that you have write permission to the openlatch directory.")
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&token_path, perms).map_err(|e| {
OlError::new(
crate::error::ERR_INVALID_CONFIG,
format!("Cannot set permissions on token file: {e}"),
)
.with_suggestion("Check that you have permission to modify file attributes.")
})?;
}
Ok(token)
}
pub const PORT_RANGE_START: u16 = 7443;
pub const PORT_RANGE_END: u16 = 7543;
pub fn probe_free_port(start: u16, end: u16) -> Result<u16, OlError> {
for port in start..=end {
if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
return Ok(port);
}
}
Err(OlError::new(
ERR_PORT_IN_USE,
format!("No free port found in range {start}-{end}"),
)
.with_suggestion(format!(
"Free a port in the {start}-{end} range, or set OPENLATCH_PORT to a specific port."
))
.with_docs("https://docs.openlatch.ai/errors/OL-1500"))
}
pub fn write_port_file(port: u16) -> Result<(), OlError> {
let path = openlatch_dir().join("daemon.port");
std::fs::write(&path, port.to_string()).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot write port file '{}': {e}", path.display()),
)
})?;
Ok(())
}
pub fn read_port_file() -> Option<u16> {
let path = openlatch_dir().join("daemon.port");
std::fs::read_to_string(path)
.ok()?
.trim()
.parse::<u16>()
.ok()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_config_defaults_values() {
let cfg = Config::defaults();
assert_eq!(cfg.port, 7443, "Default port must be 7443");
assert_eq!(cfg.log_level, "info", "Default log level must be info");
assert_eq!(cfg.retention_days, 30, "Default retention must be 30 days");
}
#[test]
fn test_config_loads_from_toml_file() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[daemon]
port = 8080
"#,
)
.unwrap();
let raw = std::fs::read_to_string(&config_path).unwrap();
let toml_cfg: TomlConfig = toml::from_str(&raw).unwrap();
let daemon = toml_cfg.daemon.unwrap();
assert_eq!(daemon.port, Some(8080));
}
#[test]
fn test_config_cli_port_overrides_default() {
let cfg = Config::load(Some(9000), None, false)
.expect("Config::load should succeed with valid CLI port");
assert_eq!(cfg.port, 9000, "CLI port should override default");
}
#[test]
fn test_generate_token_produces_64_char_hex() {
let token = generate_token();
assert_eq!(
token.len(),
64,
"Token must be 64 characters (32 bytes hex-encoded), got: {token}"
);
assert!(
token.chars().all(|c| c.is_ascii_hexdigit()),
"Token must be hex-encoded, got: {token}"
);
}
#[test]
fn test_ensure_token_creates_and_returns_token() {
let tmp = TempDir::new().unwrap();
let token1 = ensure_token(tmp.path()).expect("First ensure_token call should succeed");
assert_eq!(token1.len(), 64, "Generated token must be 64 chars");
assert!(
tmp.path().join("daemon.token").exists(),
"Token file must be created"
);
let token2 = ensure_token(tmp.path()).expect("Second ensure_token call should succeed");
assert_eq!(token1, token2, "Second call must return the same token");
}
#[cfg(unix)]
#[test]
fn test_ensure_token_file_has_mode_0600() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
ensure_token(tmp.path()).expect("ensure_token should succeed");
let token_path = tmp.path().join("daemon.token");
let metadata = std::fs::metadata(&token_path).unwrap();
let mode = metadata.permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "Token file must have mode 0600, got: {mode:o}");
}
#[test]
fn test_generate_default_config_toml_format() {
let content = generate_default_config_toml(7443);
assert!(
content.contains("port = 7443"),
"Must contain active port line: {content}"
);
assert!(
content.contains("[daemon]"),
"Must contain [daemon] section: {content}"
);
assert!(
content.contains("# level ="),
"level must be commented out: {content}"
);
assert!(
content.contains("# retention_days ="),
"retention_days must be commented: {content}"
);
}
#[test]
fn test_config_extra_patterns_defaults_empty() {
let cfg = Config::defaults();
assert!(
cfg.extra_patterns.is_empty(),
"Default extra_patterns must be empty"
);
}
#[test]
fn test_probe_free_port_finds_available_port() {
let port = probe_free_port(PORT_RANGE_START, PORT_RANGE_END)
.expect("should find at least one free port");
assert!((PORT_RANGE_START..=PORT_RANGE_END).contains(&port));
}
#[test]
fn test_probe_free_port_skips_occupied_port() {
let listener =
std::net::TcpListener::bind(("127.0.0.1", 0)).expect("should bind to random port");
let occupied = listener.local_addr().unwrap().port();
let result = probe_free_port(occupied, occupied);
assert!(
result.is_err(),
"must fail when only port in range is occupied"
);
if occupied < 65535 {
let result = probe_free_port(occupied, occupied + 1);
assert!(result.is_ok(), "should find next port after occupied one");
assert_eq!(result.unwrap(), occupied + 1);
}
}
#[test]
fn test_write_and_read_port_file_round_trip() {
let tmp = TempDir::new().unwrap();
let port_path = tmp.path().join("daemon.port");
std::fs::write(&port_path, "8080").unwrap();
let content = std::fs::read_to_string(&port_path).unwrap();
assert_eq!(content.trim().parse::<u16>().unwrap(), 8080);
}
}