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 }
}
}
#[derive(Debug, Clone)]
pub struct CloudConfig {
pub enabled: bool,
pub api_url: String,
pub timeout_connect_ms: u64,
pub timeout_total_ms: u64,
pub retry_count: u32,
pub retry_delay_ms: u64,
pub channel_size: usize,
pub credential_poll_interval_ms: u64,
}
impl Default for CloudConfig {
fn default() -> Self {
Self {
enabled: true,
api_url: "https://app.openlatch.ai/api".into(),
timeout_connect_ms: 5000,
timeout_total_ms: 10000,
retry_count: 1,
retry_delay_ms: 2000,
channel_size: 1000,
credential_poll_interval_ms: 60_000,
}
}
}
pub fn openlatch_dir() -> PathBuf {
if let Ok(dir) = std::env::var("OPENLATCH_DIR") {
if !dir.is_empty() {
return PathBuf::from(dir);
}
}
#[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,
pub cloud: CloudConfig,
pub agent_id: Option<String>,
pub supervision: crate::supervision::SupervisionConfig,
}
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(),
cloud: CloudConfig::default(),
agent_id: None,
supervision: crate::supervision::SupervisionConfig::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(ref mid) = daemon.agent_id {
cfg.agent_id = Some(mid.clone());
}
}
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 Some(sup) = toml_cfg.supervision {
use crate::supervision::{SupervisionMode, SupervisorKind};
if let Some(mode) = sup.mode.as_deref() {
cfg.supervision.mode = match mode {
"active" => SupervisionMode::Active,
"deferred" => SupervisionMode::Deferred,
_ => SupervisionMode::Disabled,
};
}
if let Some(backend) = sup.backend.as_deref() {
cfg.supervision.backend = match backend {
"launchd" => SupervisorKind::Launchd,
"systemd" => SupervisorKind::Systemd,
"task_scheduler" => SupervisorKind::TaskScheduler,
_ => SupervisorKind::None,
};
}
cfg.supervision.disabled_reason = sup.disabled_reason;
}
if let Some(cloud) = toml_cfg.cloud {
if let Some(v) = cloud.enabled {
cfg.cloud.enabled = v;
}
if let Some(v) = cloud.api_url {
cfg.cloud.api_url = v;
}
if let Some(v) = cloud.timeout_connect_ms {
cfg.cloud.timeout_connect_ms = v;
}
if let Some(v) = cloud.timeout_total_ms {
cfg.cloud.timeout_total_ms = v;
}
if let Some(v) = cloud.retry_count {
cfg.cloud.retry_count = v;
}
if let Some(v) = cloud.retry_delay_ms {
cfg.cloud.retry_delay_ms = v;
}
if let Some(v) = cloud.channel_size {
cfg.cloud.channel_size = v;
}
if let Some(v) = cloud.credential_poll_interval_ms {
cfg.cloud.credential_poll_interval_ms = v;
}
}
}
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 Ok(val) = std::env::var("OPENLATCH_CLOUD_ENABLED") {
cfg.cloud.enabled = val == "true" || val == "1";
}
if let Ok(val) = std::env::var("OPENLATCH_API_URL") {
cfg.cloud.api_url = val;
}
if let Ok(val) = std::env::var("OPENLATCH_CLOUD_CREDENTIAL_POLL_MS") {
cfg.cloud.credential_poll_interval_ms = val.parse::<u64>().map_err(|_| {
OlError::new(
ERR_INVALID_CONFIG,
format!("OPENLATCH_CLOUD_CREDENTIAL_POLL_MS is not a valid integer: '{val}'"),
)
.with_suggestion(
"Set OPENLATCH_CLOUD_CREDENTIAL_POLL_MS to a positive integer (ms).",
)
})?;
}
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>,
#[serde(default)]
cloud: Option<CloudToml>,
#[serde(default)]
supervision: Option<SupervisionToml>,
}
#[derive(Debug, Deserialize)]
struct DaemonToml {
port: Option<u16>,
agent_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CloudToml {
enabled: Option<bool>,
api_url: Option<String>,
timeout_connect_ms: Option<u64>,
timeout_total_ms: Option<u64>,
retry_count: Option<u32>,
retry_delay_ms: Option<u64>,
channel_size: Option<usize>,
credential_poll_interval_ms: Option<u64>,
}
#[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>,
}
#[derive(Debug, Deserialize)]
struct SupervisionToml {
mode: Option<String>,
backend: Option<String>,
disabled_reason: Option<String>,
}
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
# agent_id is generated by 'openlatch init'
[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
# [cloud]
# enabled = true
# api_url = "https://app.openlatch.ai/api"
# timeout_connect_ms = 5000
# timeout_total_ms = 10000
# retry_count = 1
# retry_delay_ms = 2000
# channel_size = 1000
# [supervision]
# OS-native auto-restart (launchd / systemd-user / Task Scheduler).
# Managed by `openlatch init` and `openlatch supervision {{install|uninstall|enable|disable}}`.
# mode = "disabled" # "active" | "deferred" | "disabled"
# backend = "none" # "launchd" | "systemd" | "task_scheduler" | "none"
# disabled_reason = "user_opt_out"
"#
)
}
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 fn ensure_agent_id(config_path: &Path) -> Result<String, OlError> {
let raw = std::fs::read_to_string(config_path).map_err(|e| {
OlError::new(
crate::error::ERR_INVALID_CONFIG,
format!("Cannot read config file '{}': {e}", config_path.display()),
)
.with_suggestion("Check that the file exists and is readable.")
})?;
let toml_cfg: TomlConfig = toml::from_str(&raw).map_err(|e| {
OlError::new(
crate::error::ERR_INVALID_CONFIG,
format!("Invalid TOML in config file: {e}"),
)
.with_suggestion("Check your config.toml for syntax errors.")
})?;
if let Some(ref daemon) = toml_cfg.daemon {
if let Some(ref existing_id) = daemon.agent_id {
return Ok(existing_id.clone());
}
}
let new_id = format!("agt_{}", uuid::Uuid::new_v4().simple());
let updated_raw = insert_agent_id_into_toml(&raw, &new_id);
std::fs::write(config_path, &updated_raw).map_err(|e| {
OlError::new(
crate::error::ERR_INVALID_CONFIG,
format!("Cannot write config file '{}': {e}", config_path.display()),
)
.with_suggestion("Check that you have write permission to ~/.openlatch/.")
})?;
Ok(new_id)
}
fn insert_agent_id_into_toml(raw: &str, agent_id: &str) -> String {
let agent_id_line = format!("agent_id = \"{agent_id}\"");
let daemon_header_pos = raw
.lines()
.enumerate()
.find(|(_, line)| line.trim() == "[daemon]")
.map(|(idx, _)| idx);
match daemon_header_pos {
Some(daemon_idx) => {
let lines: Vec<&str> = raw.lines().collect();
let insert_after = find_insert_position(&lines, daemon_idx);
let mut result = String::with_capacity(raw.len() + agent_id_line.len() + 1);
for (i, line) in lines.iter().enumerate() {
result.push_str(line);
result.push('\n');
if i == insert_after {
result.push_str(&agent_id_line);
result.push('\n');
}
}
result
}
None => {
let mut result = raw.to_string();
if !result.ends_with('\n') {
result.push('\n');
}
result.push_str("\n[daemon]\n");
result.push_str(&agent_id_line);
result.push('\n');
result
}
}
}
fn find_insert_position(lines: &[&str], daemon_header_idx: usize) -> usize {
let mut best = daemon_header_idx;
for (i, line) in lines.iter().enumerate().skip(daemon_header_idx + 1) {
let trimmed = line.trim();
if trimmed.starts_with('[') {
break;
}
if trimmed.starts_with("port") {
best = i;
break;
}
}
best
}
pub fn persist_supervision_state(
config_path: &Path,
mode: &crate::supervision::SupervisionMode,
backend: &crate::supervision::SupervisorKind,
disabled_reason: Option<&str>,
) -> Result<(), OlError> {
let mode_str = match mode {
crate::supervision::SupervisionMode::Active => "active",
crate::supervision::SupervisionMode::Deferred => "deferred",
crate::supervision::SupervisionMode::Disabled => "disabled",
};
let backend_str = match backend {
crate::supervision::SupervisorKind::Launchd => "launchd",
crate::supervision::SupervisorKind::Systemd => "systemd",
crate::supervision::SupervisorKind::TaskScheduler => "task_scheduler",
crate::supervision::SupervisorKind::None => "none",
};
if !config_path.exists() {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot create config directory: {e}"),
)
})?;
}
std::fs::write(config_path, "").map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot create config file: {e}"),
)
})?;
}
let raw = std::fs::read_to_string(config_path).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot read config file '{}': {e}", config_path.display()),
)
})?;
let new_raw = rewrite_supervision_section(&raw, mode_str, backend_str, disabled_reason);
let tmp_path = config_path.with_extension("toml.tmp");
std::fs::write(&tmp_path, &new_raw)
.map_err(|e| OlError::new(ERR_INVALID_CONFIG, format!("Cannot write config tmp: {e}")))?;
std::fs::rename(&tmp_path, config_path)
.map_err(|e| OlError::new(ERR_INVALID_CONFIG, format!("Cannot rename config tmp: {e}")))?;
Ok(())
}
fn rewrite_supervision_section(
raw: &str,
mode: &str,
backend: &str,
disabled_reason: Option<&str>,
) -> String {
let mut block = String::new();
block.push_str("[supervision]\n");
block.push_str(&format!("mode = \"{mode}\"\n"));
block.push_str(&format!("backend = \"{backend}\"\n"));
if let Some(reason) = disabled_reason {
block.push_str(&format!("disabled_reason = \"{reason}\"\n"));
}
let lines: Vec<&str> = raw.lines().collect();
let mut header_idx: Option<usize> = None;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed == "[supervision]" || trimmed == "# [supervision]" {
header_idx = Some(i);
break;
}
}
match header_idx {
Some(start) => {
let mut end = lines.len();
for (i, line) in lines.iter().enumerate().skip(start + 1) {
let trimmed = line.trim();
if trimmed.starts_with('[') && !trimmed.starts_with("[supervision]") {
end = i;
break;
}
}
let mut result = String::new();
for line in lines.iter().take(start) {
result.push_str(line);
result.push('\n');
}
result.push_str(&block);
for line in lines.iter().skip(end) {
result.push_str(line);
result.push('\n');
}
result
}
None => {
let mut result = raw.to_string();
if !result.is_empty() && !result.ends_with('\n') {
result.push('\n');
}
if !result.is_empty() {
result.push('\n');
}
result.push_str(&block);
result
}
}
}
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);
}
#[test]
fn test_ensure_agent_id_creates_agt_prefixed_id() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
std::fs::write(&config_path, "[daemon]\nport = 7443\n").unwrap();
let mid = ensure_agent_id(&config_path).expect("ensure_agent_id should succeed");
assert!(
mid.starts_with("agt_"),
"agent_id must start with 'agt_': {mid}"
);
let hex_part = &mid[4..]; assert_eq!(
hex_part.len(),
32,
"hex part must be 32 chars (UUID simple): {mid}"
);
assert!(
hex_part.chars().all(|c| c.is_ascii_hexdigit()),
"hex part must be hex digits: {mid}"
);
}
#[test]
fn test_ensure_agent_id_is_idempotent() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
std::fs::write(&config_path, "[daemon]\nport = 7443\n").unwrap();
let mid1 = ensure_agent_id(&config_path).expect("first call should succeed");
let mid2 = ensure_agent_id(&config_path).expect("second call should succeed");
assert_eq!(mid1, mid2, "ensure_agent_id must be idempotent");
}
#[test]
fn test_ensure_agent_id_preserves_port_value() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
std::fs::write(&config_path, "[daemon]\nport = 8888\n").unwrap();
ensure_agent_id(&config_path).expect("ensure_agent_id should succeed");
let raw = std::fs::read_to_string(&config_path).unwrap();
assert!(
raw.contains("port = 8888"),
"port must be preserved after ensure_agent_id: {raw}"
);
}
#[test]
fn test_config_reads_agent_id_from_daemon_section() {
let toml_str = r#"
[daemon]
port = 7443
agent_id = "agt_abcdef1234567890abcdef1234567890"
"#;
let toml_cfg: TomlConfig = toml::from_str(toml_str).unwrap();
let daemon = toml_cfg.daemon.unwrap();
assert_eq!(
daemon.agent_id.as_deref(),
Some("agt_abcdef1234567890abcdef1234567890")
);
}
#[test]
fn test_cloud_config_default_values() {
let cfg = CloudConfig::default();
assert!(cfg.enabled, "cloud.enabled default must be true");
assert_eq!(
cfg.api_url, "https://app.openlatch.ai/api",
"cloud.api_url default must be https://app.openlatch.ai/api"
);
assert_eq!(
cfg.timeout_connect_ms, 5000,
"cloud.timeout_connect_ms default must be 5000"
);
assert_eq!(
cfg.timeout_total_ms, 10000,
"cloud.timeout_total_ms default must be 10000"
);
assert_eq!(cfg.retry_count, 1, "cloud.retry_count default must be 1");
assert_eq!(
cfg.retry_delay_ms, 2000,
"cloud.retry_delay_ms default must be 2000"
);
assert_eq!(
cfg.channel_size, 1000,
"cloud.channel_size default must be 1000"
);
}
#[test]
fn test_config_defaults_includes_cloud_config() {
let cfg = Config::defaults();
assert!(cfg.cloud.enabled);
assert_eq!(cfg.cloud.api_url, "https://app.openlatch.ai/api");
assert_eq!(cfg.cloud.timeout_connect_ms, 5000);
assert_eq!(cfg.cloud.timeout_total_ms, 10000);
assert_eq!(cfg.cloud.retry_count, 1);
assert_eq!(cfg.cloud.retry_delay_ms, 2000);
assert_eq!(cfg.cloud.channel_size, 1000);
}
#[test]
fn test_config_load_no_cloud_section_returns_defaults() {
let toml_str = r#"
[daemon]
port = 7443
"#;
let toml_cfg: TomlConfig = toml::from_str(toml_str).unwrap();
assert!(
toml_cfg.cloud.is_none(),
"TomlConfig.cloud must be None when [cloud] is absent"
);
}
#[test]
fn test_config_load_parses_all_cloud_fields() {
let toml_str = r#"
[cloud]
enabled = true
api_url = "https://custom.openlatch.ai"
timeout_connect_ms = 3000
timeout_total_ms = 8000
retry_count = 3
retry_delay_ms = 1000
channel_size = 500
"#;
let toml_cfg: TomlConfig = toml::from_str(toml_str).unwrap();
let cloud = toml_cfg.cloud.unwrap();
assert_eq!(cloud.enabled, Some(true));
assert_eq!(
cloud.api_url.as_deref(),
Some("https://custom.openlatch.ai")
);
assert_eq!(cloud.timeout_connect_ms, Some(3000));
assert_eq!(cloud.timeout_total_ms, Some(8000));
assert_eq!(cloud.retry_count, Some(3));
assert_eq!(cloud.retry_delay_ms, Some(1000));
assert_eq!(cloud.channel_size, Some(500));
}
#[test]
fn test_config_load_partial_cloud_section_merges_with_defaults() {
let toml_str = r#"
[cloud]
enabled = true
api_url = "https://staging.openlatch.ai"
"#;
let toml_cfg: TomlConfig = toml::from_str(toml_str).unwrap();
let cloud = toml_cfg.cloud.unwrap();
assert_eq!(cloud.enabled, Some(true));
assert_eq!(
cloud.api_url.as_deref(),
Some("https://staging.openlatch.ai")
);
assert!(cloud.timeout_connect_ms.is_none());
assert!(cloud.retry_count.is_none());
}
#[test]
fn test_supervision_toml_round_trip() {
let toml_str = r#"
[supervision]
mode = "active"
backend = "launchd"
disabled_reason = "user_opt_out"
"#;
let toml_cfg: TomlConfig = toml::from_str(toml_str).unwrap();
let sup = toml_cfg.supervision.unwrap();
assert_eq!(sup.mode.as_deref(), Some("active"));
assert_eq!(sup.backend.as_deref(), Some("launchd"));
assert_eq!(sup.disabled_reason.as_deref(), Some("user_opt_out"));
}
#[test]
fn test_rewrite_supervision_section_appends_when_absent() {
let raw = "[daemon]\nport = 7443\n";
let out = rewrite_supervision_section(raw, "active", "launchd", None);
assert!(out.contains("[supervision]"));
assert!(out.contains("mode = \"active\""));
assert!(out.contains("backend = \"launchd\""));
assert!(!out.contains("disabled_reason"));
assert!(out.contains("[daemon]"));
assert!(out.contains("port = 7443"));
}
#[test]
fn test_rewrite_supervision_section_replaces_existing() {
let raw = "[daemon]\nport = 7443\n\n[supervision]\nmode = \"disabled\"\nbackend = \"none\"\ndisabled_reason = \"user_opt_out\"\n\n[cloud]\nenabled = false\n";
let out = rewrite_supervision_section(raw, "active", "task_scheduler", None);
assert!(out.contains("mode = \"active\""));
assert!(out.contains("backend = \"task_scheduler\""));
assert!(!out.contains("user_opt_out"));
assert!(out.contains("[cloud]"));
assert!(out.contains("enabled = false"));
}
#[test]
fn test_rewrite_supervision_section_replaces_commented_header() {
let raw = "[daemon]\nport = 7443\n\n# [supervision]\n# mode = \"disabled\"\n";
let out = rewrite_supervision_section(raw, "active", "launchd", Some("ok"));
assert!(out.contains("[supervision]"));
assert!(out.contains("mode = \"active\""));
assert_eq!(out.matches("[supervision]").count(), 1);
}
#[test]
fn test_persist_supervision_state_writes_to_disk() {
use crate::supervision::{SupervisionMode, SupervisorKind};
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
std::fs::write(&config_path, "[daemon]\nport = 7443\n").unwrap();
persist_supervision_state(
&config_path,
&SupervisionMode::Active,
&SupervisorKind::Launchd,
None,
)
.expect("persist should succeed");
let raw = std::fs::read_to_string(&config_path).unwrap();
assert!(raw.contains("[supervision]"));
assert!(raw.contains("mode = \"active\""));
assert!(raw.contains("backend = \"launchd\""));
}
#[test]
fn test_generate_default_config_toml_contains_supervision_template() {
let content = generate_default_config_toml(7443);
assert!(
content.contains("# [supervision]"),
"Must contain commented [supervision] header: {content}"
);
assert!(
content.contains("# mode = \"disabled\""),
"Must contain commented mode line: {content}"
);
}
#[test]
fn test_generate_default_config_toml_contains_cloud_section() {
let content = generate_default_config_toml(7443);
assert!(
content.contains("# [cloud]"),
"Must contain commented [cloud] header: {content}"
);
assert!(
content.contains("# enabled = true"),
"Must contain commented enabled line: {content}"
);
assert!(
content.contains("# api_url = \"https://app.openlatch.ai/api\""),
"Must contain commented api_url line: {content}"
);
assert!(
content.contains("# timeout_connect_ms = 5000"),
"Must contain commented timeout_connect_ms line: {content}"
);
assert!(
content.contains("# timeout_total_ms = 10000"),
"Must contain commented timeout_total_ms line: {content}"
);
assert!(
content.contains("# retry_count = 1"),
"Must contain commented retry_count line: {content}"
);
assert!(
content.contains("# retry_delay_ms = 2000"),
"Must contain commented retry_delay_ms line: {content}"
);
assert!(
content.contains("# channel_size = 1000"),
"Must contain commented channel_size line: {content}"
);
}
}