use std::env;
use std::path::PathBuf;
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
pub const DEFAULT_SESSION_PREFIX: &str = "bosun-";
pub const DEFAULT_TMUX_SOCKET: &str = "bosun";
pub const DEFAULT_THEME: &str = "opencode";
#[derive(Debug, Clone)]
pub struct Config {
pub session_prefix: String,
pub tmux_socket: Option<String>,
pub self_session: Option<String>,
pub theme: String,
pub divider_x: Option<u16>,
}
impl Default for Config {
fn default() -> Self {
Self {
session_prefix: DEFAULT_SESSION_PREFIX.to_string(),
tmux_socket: Some(DEFAULT_TMUX_SOCKET.to_string()),
self_session: None,
theme: DEFAULT_THEME.to_string(),
divider_x: None,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
struct ConfigFile {
#[serde(default, skip_serializing_if = "Option::is_none")]
session_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tmux_socket: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
theme: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
divider_x: Option<u16>,
}
impl Config {
pub fn load() -> Self {
let file = read_config_file().unwrap_or_default();
let session_prefix = env::var("BOSUN_PREFIX")
.ok()
.or(file.session_prefix)
.unwrap_or_else(|| DEFAULT_SESSION_PREFIX.to_string());
let tmux_socket = match env::var("BOSUN_TMUX_SOCKET") {
Ok(s) if s.is_empty() || s == "default" => None,
Ok(s) => Some(s),
Err(_) => match file.tmux_socket.as_deref() {
Some("") | Some("default") => None,
Some(s) => Some(s.to_string()),
None => Some(DEFAULT_TMUX_SOCKET.to_string()),
},
};
let theme = env::var("BOSUN_THEME")
.ok()
.or(file.theme)
.unwrap_or_else(|| DEFAULT_THEME.to_string());
let self_session = if tmux_socket.is_none() {
detect_self_session()
} else {
None
};
let divider_x = file.divider_x;
Self {
session_prefix,
tmux_socket,
self_session,
theme,
divider_x,
}
}
pub fn from_env() -> Self {
Self::load()
}
pub fn manages(&self, name: &str) -> bool {
if self.self_session.as_deref() == Some(name) {
return false;
}
self.session_prefix.is_empty() || name.starts_with(&self.session_prefix)
}
}
pub fn config_dir() -> Option<PathBuf> {
ProjectDirs::from("dev", "yetidevworks", "bosun").map(|d| d.config_dir().to_path_buf())
}
pub fn user_themes_dir() -> Option<PathBuf> {
config_dir().map(|d| d.join("themes"))
}
fn read_config_file() -> Option<ConfigFile> {
let path = config_dir()?.join("config.toml");
let s = std::fs::read_to_string(&path).ok()?;
match toml::from_str::<ConfigFile>(&s) {
Ok(f) => Some(f),
Err(e) => {
tracing::warn!("failed to parse {:?}: {}", path, e);
None
}
}
}
pub fn write_theme(name: &str) -> std::io::Result<()> {
let dir =
config_dir().ok_or_else(|| std::io::Error::other("cannot resolve bosun config dir"))?;
std::fs::create_dir_all(&dir)?;
let path = dir.join("config.toml");
let mut file = match std::fs::read_to_string(&path) {
Ok(s) => toml::from_str::<ConfigFile>(&s).unwrap_or_default(),
Err(_) => ConfigFile::default(),
};
file.theme = Some(name.to_string());
let body = toml::to_string(&file)
.map_err(|e| std::io::Error::other(format!("toml serialize: {e}")))?;
let tmp = path.with_extension("toml.tmp");
std::fs::write(&tmp, body)?;
std::fs::rename(&tmp, &path)?;
Ok(())
}
pub fn write_divider_x(x: Option<u16>) -> std::io::Result<()> {
let dir =
config_dir().ok_or_else(|| std::io::Error::other("cannot resolve bosun config dir"))?;
std::fs::create_dir_all(&dir)?;
let path = dir.join("config.toml");
let mut file = match std::fs::read_to_string(&path) {
Ok(s) => toml::from_str::<ConfigFile>(&s).unwrap_or_default(),
Err(_) => ConfigFile::default(),
};
file.divider_x = x;
let body = toml::to_string(&file)
.map_err(|e| std::io::Error::other(format!("toml serialize: {e}")))?;
let tmp = path.with_extension("toml.tmp");
std::fs::write(&tmp, body)?;
std::fs::rename(&tmp, &path)?;
Ok(())
}
fn detect_self_session() -> Option<String> {
if env::var("TMUX").is_err() {
return None;
}
let out = std::process::Command::new("tmux")
.args(["display-message", "-p", "#{session_name}"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
if name.is_empty() {
None
} else {
Some(name)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(prefix: &str) -> Config {
Config {
session_prefix: prefix.to_string(),
tmux_socket: Some(DEFAULT_TMUX_SOCKET.to_string()),
self_session: None,
theme: DEFAULT_THEME.to_string(),
divider_x: None,
}
}
#[test]
fn default_prefix_matches_bosun_sessions() {
let c = cfg(DEFAULT_SESSION_PREFIX);
assert!(c.manages("bosun-work"));
assert!(c.manages("bosun-"));
assert!(!c.manages("agentdeck-work"));
assert!(!c.manages("main"));
}
#[test]
fn empty_prefix_matches_everything() {
let c = cfg("");
assert!(c.manages("anything"));
assert!(c.manages(""));
}
#[test]
fn custom_prefix_matches_its_namespace() {
let c = cfg("work-");
assert!(c.manages("work-api"));
assert!(!c.manages("bosun-api"));
}
#[test]
fn self_session_is_excluded_even_when_prefix_matches() {
let c = Config {
session_prefix: DEFAULT_SESSION_PREFIX.to_string(),
tmux_socket: None,
self_session: Some("bosun-mine-abc".to_string()),
theme: DEFAULT_THEME.to_string(),
divider_x: None,
};
assert!(!c.manages("bosun-mine-abc"));
assert!(c.manages("bosun-other-xyz"));
}
#[test]
fn config_file_fields_parse() {
let src = r#"
session_prefix = "work-"
tmux_socket = "scratch"
theme = "tokyonight"
"#;
let parsed: ConfigFile = toml::from_str(src).unwrap();
assert_eq!(parsed.session_prefix.as_deref(), Some("work-"));
assert_eq!(parsed.tmux_socket.as_deref(), Some("scratch"));
assert_eq!(parsed.theme.as_deref(), Some("tokyonight"));
}
#[test]
fn empty_config_file_is_all_defaults() {
let parsed: ConfigFile = toml::from_str("").unwrap();
assert!(parsed.session_prefix.is_none());
assert!(parsed.tmux_socket.is_none());
assert!(parsed.theme.is_none());
}
}