use std::env;
use std::path::PathBuf;
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use crate::sidebar::{Section, SidebarModel};
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
enum LegacySidebarEntry {
Section { id: String, name: String },
Session { internal: String },
}
fn migrate_legacy_sidebar(old: Vec<LegacySidebarEntry>) -> SidebarModel {
let mut model = SidebarModel::default();
for e in old {
match e {
LegacySidebarEntry::Section { id, name } => {
model.sections.push(Section {
id,
name,
members: Vec::new(),
collapsed: false,
banner_font: None,
});
}
LegacySidebarEntry::Session { internal } => {
model.ungrouped.push(internal);
}
}
}
model
}
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>,
pub sidebar: SidebarModel,
pub session_history: std::collections::HashMap<String, String>,
pub banner_font: String,
}
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,
sidebar: SidebarModel::default(),
session_history: std::collections::HashMap::new(),
banner_font: crate::ui::banner::default_name().to_string(),
}
}
}
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
sidebar: Option<SidebarModel>,
#[serde(default, skip_serializing_if = "Option::is_none")]
session_history: Option<std::collections::HashMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
session_order: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
banner_font: Option<String>,
}
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;
let sidebar = match file.sidebar {
Some(s) => s,
None => {
let ungrouped = file.session_order.unwrap_or_default();
SidebarModel {
ungrouped,
sections: Vec::new(),
}
}
};
let session_history = file.session_history.unwrap_or_default();
let banner_font = file
.banner_font
.unwrap_or_else(|| crate::ui::banner::default_name().to_string());
Self {
session_prefix,
tmux_socket,
self_session,
theme,
divider_x,
sidebar,
session_history,
banner_font,
}
}
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()?;
let mut value: toml::Value = match toml::from_str(&s) {
Ok(v) => v,
Err(e) => {
tracing::warn!("failed to parse {:?}: {}", path, e);
return None;
}
};
if let Some(table) = value.as_table_mut() {
if let Some(sidebar) = table.get("sidebar") {
if sidebar.is_array() {
let cloned = sidebar.clone();
match cloned.try_into::<Vec<LegacySidebarEntry>>() {
Ok(legacy) => {
let migrated = migrate_legacy_sidebar(legacy);
match toml::Value::try_from(&migrated) {
Ok(v) => {
table.insert("sidebar".to_string(), v);
}
Err(e) => {
tracing::warn!("failed to serialize migrated sidebar: {}", e);
table.remove("sidebar");
}
}
}
Err(e) => {
tracing::warn!("failed to parse legacy sidebar: {}", e);
table.remove("sidebar");
}
}
}
}
}
match value.try_into::<ConfigFile>() {
Ok(f) => Some(f),
Err(e) => {
tracing::warn!("failed to deserialize {:?}: {}", 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_banner_font(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.banner_font = 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(())
}
pub fn write_session_history(
history: &std::collections::HashMap<String, String>,
) -> 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 = read_config_file().unwrap_or_default();
file.session_history = if history.is_empty() {
None
} else {
Some(history.clone())
};
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_sidebar(model: &SidebarModel) -> 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 = read_config_file().unwrap_or_default();
file.sidebar = if model.ungrouped.is_empty() && model.sections.is_empty() {
None
} else {
Some(model.clone())
};
file.session_order = None;
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,
sidebar: SidebarModel::default(),
session_history: std::collections::HashMap::new(),
banner_font: crate::ui::banner::default_name().to_string(),
}
}
#[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,
sidebar: SidebarModel::default(),
session_history: std::collections::HashMap::new(),
banner_font: crate::ui::banner::default_name().to_string(),
};
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());
}
}