use std::env;
use std::path::PathBuf;
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use crate::sidebar::{Container, 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(Container::single(internal.clone(), 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,
pub editor: Option<String>,
pub preview_tick_ms: u64,
pub embed_enabled: bool,
pub single_window_mode: bool,
pub sidebar_hidden: bool,
}
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(),
editor: None,
preview_tick_ms: DEFAULT_PREVIEW_TICK_MS,
embed_enabled: DEFAULT_EMBED_ENABLED,
single_window_mode: true,
sidebar_hidden: false,
}
}
}
pub const DEFAULT_PREVIEW_TICK_MS: u64 = 200;
pub const DEFAULT_EMBED_ENABLED: bool = true;
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
editor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
preview_tick_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
embed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
single_window: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
sidebar_hidden: Option<bool>,
}
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()
.into_iter()
.map(|n| Container::single(n.clone(), n))
.collect();
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());
let editor = file
.editor
.and_then(|e| if e.trim().is_empty() { None } else { Some(e) });
let preview_tick_ms = env::var("BOSUN_PREVIEW_TICK_MS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.or(file.preview_tick_ms)
.unwrap_or(DEFAULT_PREVIEW_TICK_MS);
let embed_enabled = match env::var("BOSUN_EMBED") {
Ok(s) => !matches!(
s.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
),
Err(_) => file.embed.unwrap_or(DEFAULT_EMBED_ENABLED),
};
let single_window_mode = true;
let sidebar_hidden = file.sidebar_hidden.unwrap_or(false);
Self {
session_prefix,
tmux_socket,
self_session,
theme,
divider_x,
sidebar,
session_history,
banner_font,
editor,
preview_tick_ms,
embed_enabled,
single_window_mode,
sidebar_hidden,
}
}
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_sidebar_hidden(hidden: bool) -> 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.sidebar_hidden = Some(hidden);
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_editor(editor: Option<&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 = read_config_file().unwrap_or_default();
file.editor = editor
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::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_single_window(on: bool) -> 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.single_window = if on { Some(true) } else { 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(())
}
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(),
editor: None,
preview_tick_ms: DEFAULT_PREVIEW_TICK_MS,
embed_enabled: DEFAULT_EMBED_ENABLED,
single_window_mode: false,
sidebar_hidden: false,
}
}
#[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(),
editor: None,
preview_tick_ms: DEFAULT_PREVIEW_TICK_MS,
embed_enabled: DEFAULT_EMBED_ENABLED,
single_window_mode: false,
sidebar_hidden: false,
};
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());
}
#[test]
fn editor_field_parses() {
let parsed: ConfigFile = toml::from_str(r#"editor = "zed""#).unwrap();
assert_eq!(parsed.editor.as_deref(), Some("zed"));
}
}