use anyhow::{Context, Result};
use exomonad_core::Role;
use serde::Deserialize;
use std::path::{Path, PathBuf};
use tracing::debug;
#[derive(Debug, Clone, Deserialize, Default)]
pub struct RawConfig {
pub project_dir: Option<PathBuf>,
pub role: Option<Role>,
pub default_role: Option<Role>,
pub zellij_session: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Config {
pub project_dir: PathBuf,
pub role: Role,
pub zellij_session: String,
}
impl Config {
pub fn discover() -> Result<Self> {
let project_root = find_project_root()?;
let local_path = project_root.join(".exomonad/config.local.toml");
let global_path = project_root.join(".exomonad/config.toml");
let local_raw = if local_path.exists() {
debug!(path = %local_path.display(), "Loaded local config");
Self::load_raw(&local_path)?
} else {
RawConfig::default()
};
let global_raw = if global_path.exists() {
debug!(path = %global_path.display(), "Loaded global config");
Self::load_raw(&global_path)?
} else {
RawConfig::default()
};
let role = local_raw
.role
.or(global_raw.default_role)
.ok_or_else(|| anyhow::anyhow!("No active role defined. Please set 'role' in .exomonad/config.local.toml or 'default_role' in .exomonad/config.toml"))?;
let project_dir = global_raw
.project_dir
.or(local_raw.project_dir)
.map(|p| {
if p.is_absolute() {
p
} else {
project_root.join(p)
}
})
.unwrap_or(project_root);
let zellij_session = local_raw
.zellij_session
.or(global_raw.zellij_session)
.map(sanitize_session_name)
.ok_or_else(|| {
anyhow::anyhow!(
"No Zellij session configured. Please add 'zellij_session = \"myproject\"' \
to .exomonad/config.toml"
)
})?;
Ok(Self {
project_dir,
role,
zellij_session,
})
}
fn load_raw(path: &Path) -> Result<RawConfig> {
debug!(path = %path.display(), "Loading raw config");
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
let config: RawConfig = toml::from_str(&content)
.with_context(|| format!("Failed to parse config file: {}", path.display()))?;
Ok(config)
}
}
impl Default for Config {
fn default() -> Self {
Self {
project_dir: PathBuf::from("."),
role: Role::Dev,
zellij_session: "default".to_string(),
}
}
}
fn find_project_root() -> Result<PathBuf> {
let start = std::env::current_dir()?;
let mut current = start.as_path();
loop {
if current.join(".exomonad/config.toml").exists() {
return Ok(current.to_path_buf());
}
current = current.parent().ok_or_else(|| {
anyhow::anyhow!(
"No .exomonad/config.toml found from {} upward",
start.display()
)
})?;
}
}
fn sanitize_session_name(name: String) -> String {
name.replace('.', "_").chars().take(36).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_raw_config_parse_local() {
let content = r#"
role = "dev"
"#;
let raw: RawConfig = toml::from_str(content).unwrap();
assert_eq!(raw.role, Some(Role::Dev));
}
#[test]
fn test_raw_config_parse_global() {
let content = r#"
project_dir = "/my/project"
default_role = "tl"
"#;
let raw: RawConfig = toml::from_str(content).unwrap();
assert_eq!(raw.project_dir, Some(PathBuf::from("/my/project")));
assert_eq!(raw.default_role, Some(Role::TL));
}
#[test]
fn test_raw_config_empty() {
let raw: RawConfig = toml::from_str("").unwrap();
assert!(raw.role.is_none());
assert!(raw.default_role.is_none());
assert!(raw.project_dir.is_none());
}
#[test]
fn test_config_default() {
let config = Config::default();
assert_eq!(config.project_dir, PathBuf::from("."));
assert_eq!(config.role, Role::Dev);
}
#[test]
fn test_sanitize_session_name() {
assert_eq!(
sanitize_session_name("my.project".to_string()),
"my_project"
);
let long_name = "a".repeat(50);
assert_eq!(sanitize_session_name(long_name).len(), 36);
assert_eq!(sanitize_session_name("tidepool".to_string()), "tidepool");
}
#[test]
fn test_raw_config_parse_with_zellij_session() {
let content = r#"
default_role = "tl"
zellij_session = "tidepool"
"#;
let raw: RawConfig = toml::from_str(content).unwrap();
assert_eq!(raw.zellij_session, Some("tidepool".to_string()));
}
}