bosun-tmux 0.2.5

Tmux-native orchestrator for AI agent sessions
Documentation
//! Runtime configuration. Values are read once at startup and passed
//! around by value, so the rest of the code never touches `std::env`
//! or the config file on disk.
//!
//! Sources, in order of precedence (lowest to highest):
//!   1. Built-in defaults.
//!   2. `$XDG_CONFIG_HOME/bosun/config.toml` (`~/Library/Application
//!      Support/dev.yetidevworks.bosun/config.toml` on macOS).
//!   3. Environment variables (`BOSUN_PREFIX`, `BOSUN_TMUX_SOCKET`,
//!      `BOSUN_THEME`).
//!
//! Env vars always win. A missing or malformed config file is
//! non-fatal — we log a warning and fall through to defaults.

use std::env;
use std::path::PathBuf;

use directories::ProjectDirs;
use serde::{Deserialize, Serialize};

/// The default prefix that bosun considers "managed". Only tmux
/// sessions whose name starts with this prefix appear in bosun's UI
/// and get the bosun status bar applied. Set `BOSUN_PREFIX=` (empty)
/// to see every session on the server.
pub const DEFAULT_SESSION_PREFIX: &str = "bosun-";

/// Default tmux `-L <socket>` that bosun uses. Putting bosun on its
/// own socket means bosun's tmux server is a **child of the bosun
/// process**, which inherits whatever shell context bosun was
/// launched from — critically, including the macOS Keychain lineage
/// that lets Claude Code see its cached credentials. With the default
/// socket, bosun's sessions would live on some ancient server started
/// by some other context and Claude wouldn't see the user's auth.
///
/// Set `BOSUN_TMUX_SOCKET=default` to opt back into the shared
/// default socket (at the cost of the auth issue and of seeing every
/// other tmux session on the machine).
pub const DEFAULT_TMUX_SOCKET: &str = "bosun";

/// Default theme name — must match a built-in in `ui::theme`.
pub const DEFAULT_THEME: &str = "opencode";

#[derive(Debug, Clone)]
pub struct Config {
    /// Only sessions whose name starts with this prefix are shown in
    /// bosun's UI and get the bosun status bar applied. Empty string
    /// means "show everything".
    pub session_prefix: String,
    /// Tmux `-L` socket name. `None` means use tmux's default socket.
    /// `Some("bosun")` (the default) means `tmux -L bosun ...`.
    pub tmux_socket: Option<String>,
    /// Name of the tmux session bosun is currently running inside,
    /// if any. `None` if bosun was launched outside tmux. We exclude
    /// this session from bosun's own list so the preview doesn't
    /// capture bosun itself (which would create a visual feedback
    /// loop: bosun renders a preview of itself, which shows bosun
    /// rendering a preview of itself, etc).
    pub self_session: Option<String>,
    /// Theme name. Resolved against user themes first, then
    /// built-ins (`opencode`, `tokyonight`), then a hard-fallback.
    pub theme: String,
    /// Persisted divider position (absolute terminal column). `None`
    /// means use the default 38% split.
    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,
        }
    }
}

/// Shape of `config.toml` on disk. All fields are optional and
/// defaulted independently so a half-written file still loads.
/// `Serialize` is used by `write_theme` to round-trip the file on
/// disk: read → update one field → write.
#[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 {
    /// Full load path: defaults → config.toml → env vars. See the
    /// module-level doc comment for the precedence order.
    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());

        // Only detect self-session if we're on the same socket as
        // the caller's tmux. If bosun uses a dedicated socket, the
        // parent tmux (if any) is on a different server and bosun
        // isn't "inside" any session on its own socket.
        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,
        }
    }

    /// Back-compat shim for callers that only want env-driven config.
    /// Retained so tests and a few internal paths don't need to
    /// touch the filesystem.
    pub fn from_env() -> Self {
        Self::load()
    }

    /// Does `name` pass the managed-session filter?
    pub fn manages(&self, name: &str) -> bool {
        // Never manage the session bosun is running in — that causes
        // the recursive preview feedback loop.
        if self.self_session.as_deref() == Some(name) {
            return false;
        }
        self.session_prefix.is_empty() || name.starts_with(&self.session_prefix)
    }
}

/// Location of bosun's config directory. Same `ProjectDirs` entry
/// the SQLite store uses, so `config.toml` lives alongside
/// `bosun.db` on macOS.
pub fn config_dir() -> Option<PathBuf> {
    ProjectDirs::from("dev", "yetidevworks", "bosun").map(|d| d.config_dir().to_path_buf())
}

/// Where user-defined themes live — one `.toml` per theme, file name
/// without extension is the theme name.
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
        }
    }
}

/// Persist a new theme choice to `config.toml`. Read-modify-write:
/// existing file fields are preserved, only `theme` is updated. If
/// the file doesn't exist it's created. Returns `Err` if the config
/// dir can't be resolved or writing fails — callers (the theme
/// picker) surface this as a warning in the status bar but still
/// apply the change to the live UI.
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}")))?;

    // Atomic write: temp file + rename. Avoids a half-written
    // config.toml if bosun is killed mid-write.
    let tmp = path.with_extension("toml.tmp");
    std::fs::write(&tmp, body)?;
    std::fs::rename(&tmp, &path)?;
    Ok(())
}

/// Persist the divider position to `config.toml`. Same
/// read-modify-write approach as `write_theme`.
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(())
}

/// If `$TMUX` is set, ask tmux for the current session name. Used to
/// exclude bosun's own session from its list.
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());
    }
}