use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tracing::warn;
use crate::runtime::wire_worker::ApprovalPolicy;
pub const TEAM_DIR: &str = "team";
pub const WORKERS_DIR: &str = "workers";
pub const EVENTS_FILE: &str = "events.jsonl";
pub const EVENTS_FILE_ALIAS: &str = "event-log.jsonl";
pub const HEARTBEAT_FILE: &str = "heartbeat.json";
pub const INBOX_FILE: &str = "inbox.jsonl";
pub const OUTBOX_FILE: &str = "outbox.jsonl";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OmkConfig {
#[serde(default = "default_team_size")]
pub default_team_size: usize,
#[serde(default)]
pub default_yolo: bool,
#[serde(default)]
pub kimi_binary: Option<String>,
#[serde(default)]
pub extra_skill_dirs: Vec<PathBuf>,
#[serde(default = "default_true")]
pub enable_metrics: bool,
#[serde(default)]
pub registries: Vec<String>,
#[serde(default)]
pub webhooks: Option<crate::notifications::WebhookConfig>,
#[serde(default)]
pub approval_policy: ApprovalPolicy,
#[serde(default = "default_approval_timeout_secs")]
pub approval_timeout_secs: u64,
}
fn default_approval_timeout_secs() -> u64 {
300
}
impl Default for OmkConfig {
fn default() -> Self {
Self {
default_team_size: 2,
default_yolo: false,
kimi_binary: None,
extra_skill_dirs: vec![],
enable_metrics: true,
registries: vec![],
webhooks: None,
approval_policy: ApprovalPolicy::default(),
approval_timeout_secs: default_approval_timeout_secs(),
}
}
}
fn default_team_size() -> usize {
2
}
fn default_true() -> bool {
true
}
fn home_dir() -> anyhow::Result<PathBuf> {
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("HOME directory not found"))
}
pub fn config_dir() -> PathBuf {
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
PathBuf::from(xdg).join("omk")
} else {
home_dir()
.map(|h| h.join(".config").join("omk"))
.unwrap_or_else(|_| PathBuf::from("/tmp/.config/omk"))
}
}
pub fn state_dir() -> PathBuf {
if let Some(xdg) = std::env::var_os("XDG_STATE_HOME") {
PathBuf::from(xdg).join("omk")
} else {
home_dir()
.map(|h| h.join(".local").join("state").join("omk"))
.unwrap_or_else(|_| PathBuf::from("/tmp/.local/state/omk"))
}
}
pub fn data_dir() -> PathBuf {
if let Some(xdg) = std::env::var_os("XDG_DATA_HOME") {
PathBuf::from(xdg).join("omk")
} else {
home_dir()
.map(|h| h.join(".local").join("share").join("omk"))
.unwrap_or_else(|_| PathBuf::from("/tmp/.local/share/omk"))
}
}
pub fn cache_dir() -> PathBuf {
if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
PathBuf::from(xdg).join("omk")
} else {
home_dir()
.map(|h| h.join(".cache").join("omk"))
.unwrap_or_else(|_| PathBuf::from("/tmp/.cache/omk"))
}
}
pub fn legacy_dir() -> PathBuf {
home_dir()
.map(|h| h.join(".omk"))
.unwrap_or_else(|_| PathBuf::from("/tmp/.omk"))
}
pub fn resolve_event_log_for_read(state_dir: &Path) -> PathBuf {
let canonical = state_dir.join(EVENTS_FILE);
if canonical.exists() {
return canonical;
}
let legacy_alias = state_dir.join(EVENTS_FILE_ALIAS);
if legacy_alias.exists() {
return legacy_alias;
}
canonical
}
pub fn omk_state_dir() -> PathBuf {
let legacy = legacy_dir();
if legacy.exists() {
legacy.join("state")
} else {
state_dir()
}
}
pub fn omk_data_dir() -> PathBuf {
let legacy = legacy_dir();
if legacy.exists() {
legacy
} else {
data_dir()
}
}
pub async fn load_config() -> Result<OmkConfig> {
let path = config_dir().join("config.toml");
if !path.exists() {
let legacy = legacy_dir().join("config.toml");
if legacy.exists() {
warn!(legacy = %legacy.display(), "Using legacy config location. Consider migrating to XDG dirs.");
let content = tokio::fs::read_to_string(&legacy).await?;
return parse_config(&content);
}
return Ok(OmkConfig::default());
}
let content = tokio::fs::read_to_string(&path).await?;
parse_config(&content)
}
fn parse_config(content: &str) -> Result<OmkConfig> {
let config: OmkConfig = toml::from_str(content).context("Failed to parse config.toml")?;
Ok(config)
}
pub async fn ensure_dirs() -> Result<()> {
ensure_private_dir(&config_dir()).await?;
ensure_private_dir(&state_dir()).await?;
ensure_private_dir(&data_dir()).await?;
ensure_private_dir(&cache_dir()).await?;
Ok(())
}
pub async fn ensure_private_dir(path: &Path) -> Result<()> {
tokio::fs::create_dir_all(path)
.await
.with_context(|| format!("Failed to create directory: {}", path.display()))?;
set_private_dir_permissions(path).await
}
#[cfg(unix)]
async fn set_private_dir_permissions(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
.await
.with_context(|| format!("Failed to harden directory permissions: {}", path.display()))?;
Ok(())
}
#[cfg(not(unix))]
async fn set_private_dir_permissions(_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_xdg_paths_exist() {
let _ = config_dir();
let _ = state_dir();
let _ = data_dir();
let _ = cache_dir();
}
#[test]
fn test_parse_default_config() {
let config = parse_config("").unwrap();
assert_eq!(config.default_team_size, 2);
assert!(!config.default_yolo);
assert!(config.enable_metrics);
assert!(config.registries.is_empty());
assert_eq!(config.approval_timeout_secs, 300);
}
#[test]
fn test_parse_custom_config() {
let config = parse_config(
r#"
default_team_size = 5
default_yolo = true
enable_metrics = false
kimi_binary = "/opt/kimi"
"#,
)
.unwrap();
assert_eq!(config.default_team_size, 5);
assert!(config.default_yolo);
assert!(!config.enable_metrics);
assert_eq!(config.kimi_binary, Some("/opt/kimi".to_string()));
}
#[test]
fn test_resolve_event_log_for_read_prefers_canonical_file() {
let temp = tempfile::tempdir().unwrap();
let state_dir = temp.path();
let canonical = state_dir.join(EVENTS_FILE);
let alias = state_dir.join(EVENTS_FILE_ALIAS);
std::fs::write(&canonical, "{}\n").unwrap();
std::fs::write(&alias, "{}\n").unwrap();
assert_eq!(resolve_event_log_for_read(state_dir), canonical);
}
#[test]
fn test_resolve_event_log_for_read_falls_back_to_alias() {
let temp = tempfile::tempdir().unwrap();
let state_dir = temp.path();
let alias = state_dir.join(EVENTS_FILE_ALIAS);
std::fs::write(&alias, "{}\n").unwrap();
assert_eq!(resolve_event_log_for_read(state_dir), alias);
}
}