use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::debug;
use crate::{cmd, git, nerdfont};
use which::{which, which_in};
const NODE_MODULES_CLEANUP_SCRIPT: &str = include_str!("scripts/cleanup_node_modules.sh");
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct FileConfig {
#[serde(default)]
pub copy: Option<Vec<String>>,
#[serde(default)]
pub symlink: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct StatusIcons {
pub working: Option<String>,
pub waiting: Option<String>,
pub done: Option<String>,
}
impl StatusIcons {
pub fn working(&self) -> &str {
self.working.as_deref().unwrap_or("🤖")
}
pub fn waiting(&self) -> &str {
self.waiting.as_deref().unwrap_or("💬")
}
pub fn done(&self) -> &str {
self.done.as_deref().unwrap_or("✅")
}
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct AutoNameConfig {
pub command: Option<String>,
pub model: Option<String>,
pub system_prompt: Option<String>,
pub background: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct DashboardConfig {
pub commit: Option<String>,
pub merge: Option<String>,
pub preview_size: Option<u8>,
#[serde(default)]
pub show_check_counts: Option<bool>,
}
impl DashboardConfig {
pub fn commit(&self) -> &str {
self.commit
.as_deref()
.unwrap_or("Commit staged changes with a descriptive message")
}
pub fn merge(&self) -> &str {
self.merge.as_deref().unwrap_or("!workmux merge")
}
pub fn preview_size(&self) -> u8 {
self.preview_size.unwrap_or(60).clamp(10, 90)
}
pub fn show_check_counts(&self) -> bool {
self.show_check_counts.unwrap_or(false)
}
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct SidebarConfig {
pub width: Option<SidebarWidth>,
pub layout: Option<String>,
}
#[derive(Debug, Clone)]
pub enum SidebarWidth {
Absolute(u16),
Percent(u16),
}
impl SidebarWidth {
pub fn resolve(&self, terminal_width: u16) -> u16 {
match self {
SidebarWidth::Absolute(w) => *w,
SidebarWidth::Percent(p) => {
if terminal_width == 0 {
25
} else {
terminal_width * p / 100
}
}
}
}
}
impl Serialize for SidebarWidth {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
SidebarWidth::Absolute(w) => serializer.serialize_u16(*w),
SidebarWidth::Percent(p) => serializer.serialize_str(&format!("{}%", p)),
}
}
}
impl<'de> Deserialize<'de> for SidebarWidth {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de;
struct SidebarWidthVisitor;
impl<'de> de::Visitor<'de> for SidebarWidthVisitor {
type Value = SidebarWidth;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a number (columns) or a string like \"15%\"")
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> {
Ok(SidebarWidth::Absolute(v as u16))
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> {
if v < 0 {
return Err(de::Error::custom("width cannot be negative"));
}
Ok(SidebarWidth::Absolute(v as u16))
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
if let Some(pct) = v.strip_suffix('%') {
let p: u16 = pct
.trim()
.parse()
.map_err(|_| de::Error::custom("invalid percentage"))?;
if p == 0 || p > 100 {
return Err(de::Error::custom("percentage must be 1-100"));
}
Ok(SidebarWidth::Percent(p))
} else {
let w: u16 = v
.trim()
.parse()
.map_err(|_| de::Error::custom("invalid width"))?;
Ok(SidebarWidth::Absolute(w))
}
}
}
deserializer.deserialize_any(SidebarWidthVisitor)
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct WindowConfig {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub panes: Option<Vec<PaneConfig>>,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct Config {
#[serde(default)]
pub main_branch: Option<String>,
#[serde(default)]
pub base_branch: Option<String>,
#[serde(default)]
pub worktree_dir: Option<String>,
#[serde(default)]
pub window_prefix: Option<String>,
#[serde(default)]
pub panes: Option<Vec<PaneConfig>>,
#[serde(default)]
pub layouts: Option<HashMap<String, LayoutConfig>>,
#[serde(default)]
pub windows: Option<Vec<WindowConfig>>,
#[serde(default)]
pub post_create: Option<Vec<String>>,
#[serde(default)]
pub pre_merge: Option<Vec<String>>,
#[serde(default)]
pub pre_remove: Option<Vec<String>>,
#[serde(default)]
pub agent: Option<String>,
#[serde(default)]
pub merge_strategy: Option<MergeStrategy>,
#[serde(default)]
pub worktree_naming: WorktreeNaming,
#[serde(default)]
pub worktree_prefix: Option<String>,
#[serde(default)]
pub files: FileConfig,
#[serde(default)]
pub status_format: Option<bool>,
#[serde(default)]
pub status_icons: StatusIcons,
#[serde(default)]
pub auto_name: Option<AutoNameConfig>,
#[serde(default)]
pub dashboard: DashboardConfig,
#[serde(default)]
pub sidebar: SidebarConfig,
#[serde(default)]
pub nerdfont: Option<bool>,
#[serde(default)]
pub theme: ThemeConfig,
#[serde(default)]
pub mode: Option<MuxMode>,
#[serde(default)]
pub auto_update_check: Option<bool>,
#[serde(default)]
pub prompt_file_only: Option<bool>,
#[serde(default)]
pub agents: BTreeMap<String, AgentEntry>,
#[serde(skip)]
pub agent_type: Option<String>,
#[serde(default)]
pub sandbox: SandboxConfig,
}
#[derive(Debug, Clone, Serialize)]
pub struct AgentEntry {
pub command: String,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub agent_type: Option<String>,
}
impl<'de> Deserialize<'de> for AgentEntry {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum RawEntry {
String(String),
Map {
command: String,
#[serde(rename = "type")]
agent_type: Option<String>,
},
}
match RawEntry::deserialize(deserializer)? {
RawEntry::String(s) => Ok(AgentEntry {
command: s,
agent_type: None,
}),
RawEntry::Map {
command,
agent_type,
} => Ok(AgentEntry {
command,
agent_type,
}),
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct PaneConfig {
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub focus: bool,
#[serde(default)]
pub split: Option<SplitDirection>,
#[serde(default)]
pub size: Option<u16>,
#[serde(default)]
pub percentage: Option<u8>,
#[serde(default)]
pub target: Option<usize>,
#[serde(default)]
pub zoom: bool,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct LayoutConfig {
pub panes: Vec<PaneConfig>,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum SplitDirection {
Horizontal,
Vertical,
}
#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Default)]
#[serde(rename_all = "lowercase")]
pub enum MergeStrategy {
#[default]
Merge,
Rebase,
Squash,
}
#[derive(Debug, Serialize, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ThemeMode {
#[default]
Dark,
Light,
}
impl<'de> serde::Deserialize<'de> for ThemeMode {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
match s.to_lowercase().as_str() {
"light" => Ok(ThemeMode::Light),
_ => Ok(ThemeMode::Dark),
}
}
}
#[derive(Debug, Serialize, Clone, Copy, Default, PartialEq, Eq)]
pub enum ThemeScheme {
#[default]
Default,
Emberforge,
GlacierSignal,
ObsidianPop,
SlateGarden,
PhosphorArcade,
Lasergrid,
Mossfire,
NightSorbet,
GraphiteCode,
FestivalCircuit,
TealDrift,
}
impl ThemeScheme {
pub const ALL: [ThemeScheme; 12] = [
ThemeScheme::Default,
ThemeScheme::Emberforge,
ThemeScheme::GlacierSignal,
ThemeScheme::ObsidianPop,
ThemeScheme::SlateGarden,
ThemeScheme::PhosphorArcade,
ThemeScheme::Lasergrid,
ThemeScheme::Mossfire,
ThemeScheme::NightSorbet,
ThemeScheme::GraphiteCode,
ThemeScheme::FestivalCircuit,
ThemeScheme::TealDrift,
];
pub fn next(self) -> Self {
let idx = Self::ALL.iter().position(|&s| s == self).unwrap_or(0);
Self::ALL[(idx + 1) % Self::ALL.len()]
}
#[allow(dead_code)]
pub fn name(&self) -> &'static str {
match self {
ThemeScheme::Default => "Default",
ThemeScheme::Emberforge => "Emberforge",
ThemeScheme::GlacierSignal => "Glacier Signal",
ThemeScheme::ObsidianPop => "Obsidian Pop",
ThemeScheme::SlateGarden => "Slate Garden",
ThemeScheme::PhosphorArcade => "Phosphor Arcade",
ThemeScheme::Lasergrid => "Lasergrid",
ThemeScheme::Mossfire => "Mossfire",
ThemeScheme::NightSorbet => "Night Sorbet",
ThemeScheme::GraphiteCode => "Graphite Code",
ThemeScheme::FestivalCircuit => "Festival Circuit",
ThemeScheme::TealDrift => "Teal Drift",
}
}
pub fn slug(&self) -> &'static str {
match self {
ThemeScheme::Default => "default",
ThemeScheme::Emberforge => "emberforge",
ThemeScheme::GlacierSignal => "glacier-signal",
ThemeScheme::ObsidianPop => "obsidian-pop",
ThemeScheme::SlateGarden => "slate-garden",
ThemeScheme::PhosphorArcade => "phosphor-arcade",
ThemeScheme::Lasergrid => "lasergrid",
ThemeScheme::Mossfire => "mossfire",
ThemeScheme::NightSorbet => "night-sorbet",
ThemeScheme::GraphiteCode => "graphite-code",
ThemeScheme::FestivalCircuit => "festival-circuit",
ThemeScheme::TealDrift => "teal-drift",
}
}
pub fn from_slug(s: &str) -> Option<Self> {
Self::ALL.iter().find(|v| v.slug() == s).copied()
}
}
impl<'de> serde::Deserialize<'de> for ThemeScheme {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Ok(Self::from_slug(&s.to_lowercase()).unwrap_or_default())
}
}
#[derive(Debug, Serialize, Clone, Default, PartialEq, Eq)]
pub struct ThemeConfig {
pub scheme: ThemeScheme,
pub mode: Option<ThemeMode>,
}
impl<'de> serde::Deserialize<'de> for ThemeConfig {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de;
struct ThemeVisitor;
impl<'de> de::Visitor<'de> for ThemeVisitor {
type Value = ThemeConfig;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a theme scheme name, \"dark\", \"light\", or a {scheme, mode} map")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<ThemeConfig, E> {
let lower = v.to_lowercase();
match lower.as_str() {
"dark" => Ok(ThemeConfig {
scheme: ThemeScheme::Default,
mode: Some(ThemeMode::Dark),
}),
"light" => Ok(ThemeConfig {
scheme: ThemeScheme::Default,
mode: Some(ThemeMode::Light),
}),
_ => Ok(ThemeConfig {
scheme: ThemeScheme::from_slug(&lower).unwrap_or_default(),
mode: None,
}),
}
}
fn visit_map<M: de::MapAccess<'de>>(self, mut map: M) -> Result<ThemeConfig, M::Error> {
let mut scheme = None;
let mut mode = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"scheme" => {
let s: String = map.next_value()?;
scheme = ThemeScheme::from_slug(&s.to_lowercase());
}
"mode" => {
let s: String = map.next_value()?;
mode = Some(match s.to_lowercase().as_str() {
"light" => ThemeMode::Light,
_ => ThemeMode::Dark,
});
}
_ => {
let _ = map.next_value::<serde::de::IgnoredAny>()?;
}
}
}
Ok(ThemeConfig {
scheme: scheme.unwrap_or_default(),
mode,
})
}
}
d.deserialize_any(ThemeVisitor)
}
}
#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Default)]
#[serde(rename_all = "lowercase")]
pub enum MuxMode {
#[default]
Window,
Session,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum WorktreeNaming {
#[default]
Full,
Basename,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
#[serde(rename_all = "lowercase")]
pub enum SandboxBackend {
#[default]
Container,
Lima,
}
#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[serde(rename_all = "lowercase")]
pub enum SandboxRuntime {
#[default]
Docker,
Podman,
#[serde(rename = "apple-container")]
AppleContainer,
}
impl SandboxRuntime {
pub fn detect() -> Self {
#[cfg(target_os = "macos")]
if which("container").is_ok() {
return SandboxRuntime::AppleContainer;
}
if which("docker").is_ok() {
SandboxRuntime::Docker
} else if which("podman").is_ok() {
SandboxRuntime::Podman
} else {
debug!("no container runtime found in PATH, defaulting to docker");
SandboxRuntime::Docker
}
}
pub fn binary_name(&self) -> &'static str {
match self {
SandboxRuntime::Docker => "docker",
SandboxRuntime::Podman => "podman",
SandboxRuntime::AppleContainer => "container",
}
}
pub fn display_name(&self) -> &'static str {
match self {
SandboxRuntime::Docker => "docker",
SandboxRuntime::Podman => "podman",
SandboxRuntime::AppleContainer => "apple-container",
}
}
pub fn needs_add_host(&self) -> bool {
matches!(self, SandboxRuntime::Docker)
}
pub fn needs_userns_keep_id(&self) -> bool {
matches!(self, SandboxRuntime::Podman)
}
pub fn needs_deny_mode_caps(&self) -> bool {
matches!(self, SandboxRuntime::Docker | SandboxRuntime::Podman)
}
pub fn supports_file_mounts(&self) -> bool {
!matches!(self, SandboxRuntime::AppleContainer)
}
pub fn pull_args(&self, image: &str) -> Vec<String> {
match self {
SandboxRuntime::AppleContainer => {
vec!["image".into(), "pull".into(), image.into()]
}
_ => vec!["pull".into(), image.into()],
}
}
pub fn rpc_host_address(&self) -> &'static str {
match self {
SandboxRuntime::Docker => "host.docker.internal",
SandboxRuntime::Podman => "host.containers.internal",
SandboxRuntime::AppleContainer => "192.168.64.1",
}
}
pub fn default_memory(&self) -> Option<&'static str> {
match self {
SandboxRuntime::AppleContainer => Some("16G"),
_ => None,
}
}
pub fn serde_name(&self) -> &'static str {
match self {
SandboxRuntime::Docker => "docker",
SandboxRuntime::Podman => "podman",
SandboxRuntime::AppleContainer => "apple-container",
}
}
pub fn from_serde_name(s: &str) -> Option<Self> {
match s {
"docker" => Some(SandboxRuntime::Docker),
"podman" => Some(SandboxRuntime::Podman),
"apple-container" => Some(SandboxRuntime::AppleContainer),
_ => None,
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
#[serde(rename_all = "lowercase")]
pub enum IsolationLevel {
Shared,
#[default]
Project,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
#[serde(rename_all = "lowercase")]
pub enum SandboxTarget {
#[default]
Agent,
All,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
#[serde(rename_all = "lowercase")]
pub enum ToolchainMode {
#[default]
Auto,
Off,
Devbox,
Flake,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
#[serde(untagged)]
pub enum ExtraMount {
Path(String),
Spec {
host_path: String,
#[serde(default)]
guest_path: Option<String>,
#[serde(default)]
writable: Option<bool>,
},
}
impl ExtraMount {
pub fn resolve(&self) -> anyhow::Result<(PathBuf, PathBuf, bool)> {
let (host_str, guest_str, writable) = match self {
Self::Path(p) => (p.as_str(), None, false),
Self::Spec {
host_path,
guest_path,
writable,
} => (
host_path.as_str(),
guest_path.as_deref(),
writable.unwrap_or(false),
),
};
let host_path = expand_tilde(host_str);
if !host_path.is_absolute() {
anyhow::bail!(
"extra_mounts: host path must be absolute (got '{}'). Use an absolute path or ~/.",
host_str
);
}
let guest_path = guest_str
.map(PathBuf::from)
.unwrap_or_else(|| host_path.clone());
if !guest_path.is_absolute() {
anyhow::bail!(
"extra_mounts: guest_path must be absolute (got '{}')",
guest_str.unwrap_or("")
);
}
let read_only = !writable;
Ok((host_path, guest_path, read_only))
}
}
fn expand_tilde(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = home::home_dir() {
return home.join(rest);
}
} else if path == "~"
&& let Some(home) = home::home_dir()
{
return home;
}
PathBuf::from(path)
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct LimaConfig {
#[serde(default)]
pub isolation: Option<IsolationLevel>,
#[serde(default)]
pub projects_dir: Option<PathBuf>,
#[serde(default)]
pub cpus: Option<u32>,
#[serde(default)]
pub memory: Option<String>,
#[serde(default)]
pub disk: Option<String>,
#[serde(default)]
pub provision: Option<String>,
#[serde(default)]
pub skip_default_provision: Option<bool>,
}
impl LimaConfig {
pub fn isolation(&self) -> IsolationLevel {
self.isolation.clone().unwrap_or_default()
}
pub fn cpus(&self) -> u32 {
self.cpus.unwrap_or(4)
}
pub fn memory(&self) -> &str {
self.memory.as_deref().unwrap_or("4GiB")
}
pub fn disk(&self) -> &str {
self.disk.as_deref().unwrap_or("100GiB")
}
pub fn provision_script(&self) -> Option<&str> {
self.provision.as_deref().filter(|s| !s.trim().is_empty())
}
pub fn skip_default_provision(&self) -> bool {
self.skip_default_provision.unwrap_or(false)
}
fn merge(global: Self, project: Self) -> Self {
Self {
isolation: project.isolation.or(global.isolation),
projects_dir: project.projects_dir.or(global.projects_dir),
cpus: project.cpus.or(global.cpus),
memory: project.memory.or(global.memory),
disk: project.disk.or(global.disk),
provision: project.provision.or(global.provision),
skip_default_provision: project
.skip_default_provision
.or(global.skip_default_provision),
}
}
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct ContainerConfig {
#[serde(default)]
pub runtime: Option<SandboxRuntime>,
#[serde(default)]
pub cpus: Option<u32>,
#[serde(default)]
pub memory: Option<String>,
}
impl ContainerConfig {
pub fn runtime(&self) -> SandboxRuntime {
self.runtime.unwrap_or_else(SandboxRuntime::detect)
}
fn merge(global: Self, project: Self) -> Self {
Self {
runtime: project.runtime.or(global.runtime),
cpus: project.cpus.or(global.cpus),
memory: project.memory.or(global.memory),
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum NetworkPolicy {
Allow,
Deny,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct NetworkConfig {
#[serde(default)]
pub policy: Option<NetworkPolicy>,
#[serde(default)]
pub allowed_domains: Option<Vec<String>>,
}
impl NetworkConfig {
pub fn policy(&self) -> NetworkPolicy {
self.policy.clone().unwrap_or(NetworkPolicy::Allow)
}
pub fn allowed_domains(&self) -> &[String] {
self.allowed_domains.as_deref().unwrap_or(&[])
}
pub fn validate(&self) -> anyhow::Result<()> {
for domain in self.allowed_domains() {
validate_domain(domain)?;
}
Ok(())
}
}
fn validate_domain(domain: &str) -> anyhow::Result<()> {
use std::net::IpAddr;
if domain.parse::<IpAddr>().is_ok() {
anyhow::bail!("IP literals not allowed in allowed_domains: {}", domain);
}
if domain.ends_with('.') {
anyhow::bail!("trailing dot not allowed in domain: {}", domain);
}
if domain.contains('*') && !domain.starts_with("*.") {
anyhow::bail!("invalid wildcard pattern (must be *.suffix): {}", domain);
}
if domain.is_empty() {
anyhow::bail!("empty domain not allowed in allowed_domains");
}
Ok(())
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct SandboxConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub backend: Option<SandboxBackend>,
#[serde(default)]
pub target: Option<SandboxTarget>,
#[serde(default)]
pub image: Option<String>,
#[serde(default)]
pub env_passthrough: Option<Vec<String>>,
#[serde(default)]
pub env: Option<HashMap<String, String>>,
#[serde(default)]
pub rpc_host: Option<String>,
#[serde(default)]
pub toolchain: Option<ToolchainMode>,
#[serde(default)]
pub host_commands: Option<Vec<String>>,
#[serde(default)]
pub extra_mounts: Option<Vec<ExtraMount>>,
#[serde(default)]
pub agent_config_dir: Option<String>,
#[serde(default)]
pub lima: LimaConfig,
#[serde(default)]
pub container: ContainerConfig,
#[serde(default)]
pub network: NetworkConfig,
#[serde(default)]
pub dangerously_allow_unsandboxed_host_exec: Option<bool>,
}
impl SandboxConfig {
pub fn is_enabled(&self) -> bool {
self.enabled.unwrap_or(false)
}
pub fn backend(&self) -> SandboxBackend {
self.backend.clone().unwrap_or_default()
}
pub fn runtime(&self) -> SandboxRuntime {
self.container.runtime()
}
pub fn target(&self) -> SandboxTarget {
self.target.clone().unwrap_or_default()
}
pub fn resolved_image(&self, agent: &str) -> String {
match &self.image {
Some(image) => image.clone(),
None => format!("{}:{}", crate::sandbox::DEFAULT_IMAGE_REGISTRY, agent),
}
}
pub fn env_passthrough(&self) -> Vec<&str> {
self.env_passthrough
.as_ref()
.map(|v| v.iter().map(|s| s.as_str()).collect())
.unwrap_or_default()
}
pub fn env_vars(&self) -> Vec<(&str, &str)> {
self.env
.as_ref()
.map(|m| m.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect())
.unwrap_or_default()
}
pub fn resolved_rpc_host(&self) -> String {
self.rpc_host
.clone()
.unwrap_or_else(|| self.runtime().rpc_host_address().to_string())
}
pub fn toolchain(&self) -> ToolchainMode {
self.toolchain.clone().unwrap_or_default()
}
pub fn host_commands(&self) -> &[String] {
self.host_commands.as_deref().unwrap_or(&[])
}
pub fn extra_mounts(&self) -> &[ExtraMount] {
self.extra_mounts.as_deref().unwrap_or(&[])
}
pub fn allow_unsandboxed_host_exec(&self) -> bool {
self.dangerously_allow_unsandboxed_host_exec
.unwrap_or(false)
}
pub fn network_policy_is_deny(&self) -> bool {
self.network.policy() == NetworkPolicy::Deny
}
pub fn resolved_agent_config_dir(&self, agent: &str) -> Option<PathBuf> {
if let Some(ref dir) = self.agent_config_dir {
let expanded = dir.replace("{agent}", agent);
Some(expand_tilde(&expanded))
} else {
let home = home::home_dir()?;
match agent {
"claude" => Some(home.join(".claude")),
"copilot" => Some(home.join(".copilot")),
"gemini" => Some(home.join(".gemini")),
"codex" => Some(home.join(".codex")),
"opencode" => Some(home.join(".local/share/opencode")),
_ => None,
}
}
}
}
#[derive(Debug, Clone)]
pub struct ConfigLocation {
pub config_path: PathBuf,
pub config_dir: PathBuf,
pub rel_dir: PathBuf,
}
pub fn find_project_config(start_dir: &Path) -> anyhow::Result<Option<ConfigLocation>> {
let config_names = [".workmux.yaml", ".workmux.yml"];
let repo_root = match git::get_repo_root_for(start_dir) {
Ok(root) => root,
Err(_) => return Ok(None),
};
let repo_root = repo_root.canonicalize().unwrap_or(repo_root);
let mut dir = start_dir
.canonicalize()
.unwrap_or_else(|_| start_dir.to_path_buf());
if !dir.starts_with(&repo_root) {
return Ok(None);
}
loop {
for name in &config_names {
let candidate = dir.join(name);
if candidate.exists() {
let rel_dir = dir
.strip_prefix(&repo_root)
.map(|p| p.to_path_buf())
.unwrap_or_default();
debug!(
path = %candidate.display(),
rel_dir = %rel_dir.display(),
"config:found project config"
);
return Ok(Some(ConfigLocation {
config_path: candidate,
config_dir: dir,
rel_dir,
}));
}
}
if dir == repo_root {
break;
}
if !dir.pop() {
break;
}
}
if let Ok(main_root) = git::get_main_worktree_root() {
let main_root = main_root.canonicalize().unwrap_or(main_root);
if main_root != repo_root {
for name in &config_names {
let candidate = main_root.join(name);
if candidate.exists() {
debug!(path = %candidate.display(), "config:found main-worktree config");
return Ok(Some(ConfigLocation {
config_path: candidate,
config_dir: main_root.clone(),
rel_dir: PathBuf::new(), }));
}
}
}
}
Ok(None)
}
impl WorktreeNaming {
pub fn derive_name(&self, branch: &str) -> String {
match self {
Self::Full => branch.to_string(),
Self::Basename => branch
.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap_or(branch)
.to_string(),
}
}
}
pub fn validate_windows_config(windows: &[WindowConfig]) -> anyhow::Result<()> {
if windows.is_empty() {
anyhow::bail!("'windows' list must not be empty.");
}
for (i, window) in windows.iter().enumerate() {
if let Some(panes) = &window.panes {
validate_panes_config(panes).map_err(|e| {
anyhow::anyhow!(
"Window {} ({}): {}",
i,
window.name.as_deref().unwrap_or("unnamed"),
e
)
})?;
}
}
Ok(())
}
pub fn validate_panes_config(panes: &[PaneConfig]) -> anyhow::Result<()> {
for (i, pane) in panes.iter().enumerate() {
if i == 0 {
if pane.split.is_some() {
anyhow::bail!("First pane (index 0) cannot have a 'split' direction.");
}
if pane.size.is_some() || pane.percentage.is_some() {
anyhow::bail!("First pane (index 0) cannot have 'size' or 'percentage'.");
}
} else {
if pane.split.is_none() {
anyhow::bail!("Pane {} must have a 'split' direction specified.", i);
}
}
if pane.size.is_some() && pane.percentage.is_some() {
anyhow::bail!(
"Pane {} cannot have both 'size' and 'percentage' specified.",
i
);
}
if let Some(p) = pane.percentage
&& !(1..=100).contains(&p)
{
anyhow::bail!(
"Pane {} has invalid percentage {}. Must be between 1 and 100.",
i,
p
);
}
if let Some(target) = pane.target
&& target >= i
{
anyhow::bail!(
"Pane {} has invalid target {}. Target must reference a previously created pane (0-{}).",
i,
target,
i.saturating_sub(1)
);
}
}
let zoom_count = panes.iter().filter(|p| p.zoom).count();
if zoom_count > 1 {
anyhow::bail!(
"Only one pane can have 'zoom: true' (found {}).",
zoom_count
);
}
Ok(())
}
#[cfg(test)]
pub fn validate_layouts_config(layouts: &HashMap<String, LayoutConfig>) -> anyhow::Result<()> {
for (name, layout) in layouts {
validate_panes_config(&layout.panes)
.map_err(|e| anyhow::anyhow!("Invalid panes in layout '{}': {}", name, e))?;
}
Ok(())
}
pub fn global_config_path() -> Option<PathBuf> {
let home = home::home_dir()?;
let yaml = home.join(".config/workmux/config.yaml");
let yml = home.join(".config/workmux/config.yml");
if yml.exists() && !yaml.exists() {
Some(yml)
} else {
Some(yaml)
}
}
impl Config {
pub fn load(cli_agent: Option<&str>) -> anyhow::Result<Self> {
debug!("config:loading");
let global_config = Self::load_global()?.unwrap_or_default();
let project_config = Self::load_project()?.unwrap_or_default();
let has_explicit_agent =
cli_agent.is_some() || project_config.agent.is_some() || global_config.agent.is_some();
let final_agent = cli_agent
.map(|s| s.to_string())
.or_else(|| project_config.agent.clone())
.or_else(|| global_config.agent.clone())
.unwrap_or_else(|| "claude".to_string());
let mut config = global_config.merge(project_config);
if let Some(entry) = config.agents.get(&final_agent) {
config.agent_type = entry.agent_type.clone();
config.agent = Some(entry.command.clone());
} else {
config.agent = Some(final_agent);
}
if let Ok(repo_root) = git::get_repo_root() {
let has_node_modules = repo_root.join("pnpm-lock.yaml").exists()
|| repo_root.join("package-lock.json").exists()
|| repo_root.join("yarn.lock").exists();
if config.panes.is_none() && config.windows.is_none() {
if repo_root.join("CLAUDE.md").exists() || has_explicit_agent {
config.panes = Some(Self::agent_default_panes());
} else {
config.panes = Some(Self::default_panes());
}
}
if config.pre_remove.is_none() && has_node_modules {
config.pre_remove = Some(vec![NODE_MODULES_CLEANUP_SCRIPT.to_string()]);
}
} else {
if config.panes.is_none() && config.windows.is_none() {
if has_explicit_agent {
config.panes = Some(Self::agent_default_panes());
} else {
config.panes = Some(Self::default_panes());
}
}
}
config.sandbox.network.validate()?;
debug!(
agent = ?config.agent,
panes = config.panes.as_ref().map_or(0, |p| p.len()),
windows = config.windows.as_ref().map_or(0, |w| w.len()),
"config:loaded"
);
Ok(config)
}
pub fn load_with_location(
cli_agent: Option<&str>,
) -> anyhow::Result<(Self, Option<ConfigLocation>)> {
debug!("config:loading with location");
let global_config = Self::load_global()?.unwrap_or_default();
let (project_config, location) = Self::load_project_with_location()?;
let project_config = project_config.unwrap_or_default();
let defaults_root = location
.as_ref()
.map(|loc| loc.config_dir.clone())
.or_else(|| git::get_repo_root().ok())
.unwrap_or_default();
let config = Self::merge_and_apply_defaults(
global_config,
project_config,
cli_agent,
&defaults_root,
);
debug!(
agent = ?config.agent,
panes = config.panes.as_ref().map_or(0, |p| p.len()),
windows = config.windows.as_ref().map_or(0, |w| w.len()),
has_location = location.is_some(),
"config:loaded with location"
);
Ok((config, location))
}
pub fn load_with_location_from(
start_dir: &std::path::Path,
cli_agent: Option<&str>,
) -> anyhow::Result<(Self, Option<ConfigLocation>)> {
debug!(start_dir = %start_dir.display(), "config:loading with location from");
let global_config = Self::load_global()?.unwrap_or_default();
let location = find_project_config(start_dir)?;
let project_config = if let Some(ref loc) = location {
Self::load_from_path(&loc.config_path)?.unwrap_or_default()
} else {
Self::default()
};
let defaults_root = location
.as_ref()
.map(|loc| loc.config_dir.clone())
.unwrap_or_else(|| start_dir.to_path_buf());
let config = Self::merge_and_apply_defaults(
global_config,
project_config,
cli_agent,
&defaults_root,
);
debug!(
agent = ?config.agent,
has_location = location.is_some(),
"config:loaded with location from"
);
Ok((config, location))
}
fn merge_and_apply_defaults(
global_config: Self,
project_config: Self,
cli_agent: Option<&str>,
defaults_root: &std::path::Path,
) -> Self {
let has_explicit_agent =
cli_agent.is_some() || project_config.agent.is_some() || global_config.agent.is_some();
let final_agent = cli_agent
.map(|s| s.to_string())
.or_else(|| project_config.agent.clone())
.or_else(|| global_config.agent.clone())
.unwrap_or_else(|| "claude".to_string());
let mut config = global_config.merge(project_config);
if let Some(entry) = config.agents.get(&final_agent) {
config.agent_type = entry.agent_type.clone();
config.agent = Some(entry.command.clone());
} else {
config.agent = Some(final_agent);
}
if !defaults_root.as_os_str().is_empty() {
let has_node_modules = defaults_root.join("pnpm-lock.yaml").exists()
|| defaults_root.join("package-lock.json").exists()
|| defaults_root.join("yarn.lock").exists();
if config.panes.is_none() && config.windows.is_none() {
if defaults_root.join("CLAUDE.md").exists() || has_explicit_agent {
config.panes = Some(Self::agent_default_panes());
} else {
config.panes = Some(Self::default_panes());
}
}
if config.pre_remove.is_none() && has_node_modules {
config.pre_remove = Some(vec![NODE_MODULES_CLEANUP_SCRIPT.to_string()]);
}
} else if config.panes.is_none() && config.windows.is_none() {
if has_explicit_agent {
config.panes = Some(Self::agent_default_panes());
} else {
config.panes = Some(Self::default_panes());
}
}
let _ = config.sandbox.network.validate();
config
}
fn load_from_path(path: &Path) -> anyhow::Result<Option<Self>> {
if !path.exists() {
return Ok(None);
}
debug!(path = %path.display(), "config:reading file");
let contents = fs::read_to_string(path)?;
let config: Config = serde_yaml::from_str(&contents)
.map_err(|e| anyhow::anyhow!("Failed to parse config at {}: {}", path.display(), e))?;
Ok(Some(config))
}
fn load_global() -> anyhow::Result<Option<Self>> {
if let Some(home_dir) = home::home_dir() {
let xdg_config_path = home_dir.join(".config/workmux/config.yaml");
if xdg_config_path.exists() {
return Self::load_from_path(&xdg_config_path);
}
let xdg_config_path_yml = home_dir.join(".config/workmux/config.yml");
if xdg_config_path_yml.exists() {
return Self::load_from_path(&xdg_config_path_yml);
}
}
Ok(None)
}
fn load_project_with_location() -> anyhow::Result<(Option<Self>, Option<ConfigLocation>)> {
let start_dir = std::env::current_dir().unwrap_or_default();
if let Some(location) = find_project_config(&start_dir)? {
let config = Self::load_from_path(&location.config_path)?;
return Ok((config, Some(location)));
}
Ok((None, None))
}
fn load_project() -> anyhow::Result<Option<Self>> {
let (config, _location) = Self::load_project_with_location()?;
Ok(config)
}
fn merge(self, project: Self) -> Self {
fn merge_vec_with_placeholder(
global: Option<Vec<String>>,
project: Option<Vec<String>>,
) -> Option<Vec<String>> {
match (global, project) {
(Some(global_items), Some(project_items)) => {
let has_placeholder = project_items.iter().any(|s| s == "<global>");
if has_placeholder {
let mut result = Vec::new();
for item in project_items {
if item == "<global>" {
result.extend(global_items.clone());
} else {
result.push(item);
}
}
Some(result)
} else {
Some(project_items)
}
}
(global, project) => project.or(global),
}
}
let project_has_windows = project.windows.is_some();
macro_rules! merge_options {
($global:expr, $project:expr, $($field:ident),+ $(,)?) => {
Self {
$($field: $project.$field.or($global.$field),)+
..Default::default()
}
};
}
let mut merged = merge_options!(
self,
project,
main_branch,
base_branch,
worktree_dir,
window_prefix,
agent,
merge_strategy,
worktree_prefix,
panes,
windows,
status_format,
nerdfont,
auto_update_check,
prompt_file_only,
);
merged.layouts = match (self.layouts, project.layouts) {
(Some(mut global), Some(proj)) => {
global.extend(proj);
Some(global)
}
(global, proj) => proj.or(global),
};
merged.auto_name = match (self.auto_name, project.auto_name) {
(Some(global), Some(project)) => {
if project.command.is_some() {
tracing::warn!(
"auto_name.command in project config (.workmux.yaml) is ignored -- \
move it to your global config (~/.config/workmux/config.yaml)"
);
}
Some(AutoNameConfig {
command: global.command,
model: project.model.or(global.model),
system_prompt: project.system_prompt.or(global.system_prompt),
background: project.background.or(global.background),
})
}
(Some(global), None) => Some(global),
(None, Some(project)) => {
if project.command.is_some() {
tracing::warn!(
"auto_name.command in project config (.workmux.yaml) is ignored -- \
move it to your global config (~/.config/workmux/config.yaml)"
);
}
Some(AutoNameConfig {
command: None,
model: project.model,
system_prompt: project.system_prompt,
background: project.background,
})
}
(None, None) => None,
};
if merged.windows.is_some() && merged.panes.is_some() {
if project_has_windows {
merged.panes = None;
} else {
merged.windows = None;
}
}
merged.worktree_naming = if project.worktree_naming != WorktreeNaming::default() {
project.worktree_naming
} else {
self.worktree_naming
};
merged.theme = ThemeConfig {
scheme: if project.theme.scheme != ThemeScheme::Default {
project.theme.scheme
} else {
self.theme.scheme
},
mode: project.theme.mode.or(self.theme.mode),
};
merged.mode = project.mode.or(self.mode);
merged.post_create = merge_vec_with_placeholder(self.post_create, project.post_create);
merged.pre_merge = merge_vec_with_placeholder(self.pre_merge, project.pre_merge);
merged.pre_remove = merge_vec_with_placeholder(self.pre_remove, project.pre_remove);
merged.files = FileConfig {
copy: merge_vec_with_placeholder(self.files.copy, project.files.copy),
symlink: merge_vec_with_placeholder(self.files.symlink, project.files.symlink),
};
merged.status_icons = StatusIcons {
working: project.status_icons.working.or(self.status_icons.working),
waiting: project.status_icons.waiting.or(self.status_icons.waiting),
done: project.status_icons.done.or(self.status_icons.done),
};
merged.dashboard = DashboardConfig {
commit: project.dashboard.commit.or(self.dashboard.commit),
merge: project.dashboard.merge.or(self.dashboard.merge),
preview_size: project
.dashboard
.preview_size
.or(self.dashboard.preview_size),
show_check_counts: project
.dashboard
.show_check_counts
.or(self.dashboard.show_check_counts),
};
merged.sidebar = SidebarConfig {
width: project.sidebar.width.or(self.sidebar.width),
layout: project.sidebar.layout.or(self.sidebar.layout),
};
merged.sandbox = SandboxConfig {
enabled: project.sandbox.enabled.or(self.sandbox.enabled),
backend: project
.sandbox
.backend
.clone()
.or(self.sandbox.backend.clone()),
target: project
.sandbox
.target
.clone()
.or(self.sandbox.target.clone()),
image: project.sandbox.image.clone().or(self.sandbox.image.clone()),
env_passthrough: {
if project.sandbox.env_passthrough.is_some() {
tracing::warn!(
"env_passthrough in project config (.workmux.yaml) is ignored -- \
move it to your global config (~/.config/workmux/config.yaml)"
);
}
self.sandbox.env_passthrough.clone()
},
env: {
if project.sandbox.env.is_some() {
tracing::warn!(
"env in project config (.workmux.yaml) is ignored -- \
move it to your global config (~/.config/workmux/config.yaml)"
);
}
self.sandbox.env
},
rpc_host: {
if project.sandbox.rpc_host.is_some() {
tracing::warn!(
"rpc_host in project config (.workmux.yaml) is ignored -- \
move it to your global config (~/.config/workmux/config.yaml)"
);
}
self.sandbox.rpc_host.clone()
},
toolchain: project
.sandbox
.toolchain
.clone()
.or(self.sandbox.toolchain.clone()),
host_commands: {
if project.sandbox.host_commands.is_some() {
tracing::warn!(
"host_commands in project config (.workmux.yaml) is ignored -- \
move it to your global config (~/.config/workmux/config.yaml)"
);
}
self.sandbox.host_commands.clone()
},
extra_mounts: {
if project.sandbox.extra_mounts.is_some() {
tracing::warn!(
"extra_mounts in project config (.workmux.yaml) is ignored -- \
move it to your global config (~/.config/workmux/config.yaml)"
);
}
self.sandbox.extra_mounts.clone()
},
agent_config_dir: {
if project.sandbox.agent_config_dir.is_some() {
tracing::warn!(
"agent_config_dir in project config (.workmux.yaml) is ignored -- \
move it to your global config (~/.config/workmux/config.yaml)"
);
}
self.sandbox.agent_config_dir.clone()
},
lima: LimaConfig::merge(self.sandbox.lima, project.sandbox.lima),
container: ContainerConfig::merge(self.sandbox.container, project.sandbox.container),
network: {
if project.sandbox.network.policy.is_some()
|| project.sandbox.network.allowed_domains.is_some()
{
tracing::warn!(
"network in project config (.workmux.yaml) is ignored -- \
move it to your global config (~/.config/workmux/config.yaml)"
);
}
self.sandbox.network.clone()
},
dangerously_allow_unsandboxed_host_exec: self
.sandbox
.dangerously_allow_unsandboxed_host_exec,
};
merged.agents = if !project.agents.is_empty() {
tracing::warn!(
"agents in project config (.workmux.yaml) is ignored -- \
move it to your global config (~/.config/workmux/config.yaml)"
);
self.agents
} else {
self.agents
};
merged
}
fn default_panes() -> Vec<PaneConfig> {
vec![
PaneConfig {
command: None, focus: true,
..Default::default()
},
PaneConfig {
command: Some("clear".to_string()),
split: Some(SplitDirection::Horizontal),
..Default::default()
},
]
}
fn agent_default_panes() -> Vec<PaneConfig> {
vec![
PaneConfig {
command: Some("<agent>".to_string()),
focus: true,
..Default::default()
},
PaneConfig {
command: Some("clear".to_string()),
split: Some(SplitDirection::Horizontal),
..Default::default()
},
]
}
pub fn window_prefix(&self) -> &str {
if let Some(ref prefix) = self.window_prefix {
prefix
} else if nerdfont::is_enabled() {
"\u{f418} " } else {
"wm-"
}
}
pub fn mode(&self) -> MuxMode {
self.mode.unwrap_or(MuxMode::Window)
}
pub fn init() -> anyhow::Result<()> {
use std::path::PathBuf;
let config_path = PathBuf::from(".workmux.yaml");
if config_path.exists() {
return Err(anyhow::anyhow!(
".workmux.yaml already exists. Remove it first if you want to regenerate it."
));
}
fs::write(&config_path, EXAMPLE_PROJECT_CONFIG)?;
println!("✓ Created .workmux.yaml");
println!("\nThis file provides project-specific overrides.");
println!("For global settings, edit ~/.config/workmux/config.yaml");
Ok(())
}
}
pub const EXAMPLE_PROJECT_CONFIG: &str = r#"# workmux project configuration
# For global settings, edit ~/.config/workmux/config.yaml
# All options below are commented out - uncomment to override defaults.
#-------------------------------------------------------------------------------
# Appearance
#-------------------------------------------------------------------------------
# Color scheme for the dashboard. Press T (shift+t) in the dashboard to cycle.
# Options: default, emberforge, glacier-signal, obsidian-pop, slate-garden,
# phosphor-arcade, lasergrid, mossfire, night-sorbet, graphite-code,
# festival-circuit, teal-drift
# theme: default
#
# Or with explicit dark/light mode (otherwise auto-detected from terminal):
# theme:
# scheme: emberforge
# mode: dark
#-------------------------------------------------------------------------------
# Git
#-------------------------------------------------------------------------------
# The primary branch to merge into.
# Default: Auto-detected from remote HEAD, falls back to main/master.
# main_branch: main
# Default base branch/commit to branch from when creating new worktrees.
# The --base CLI flag always overrides this.
# Default: The currently checked out branch.
# base_branch: main
# Default merge strategy for `workmux merge`.
# Options: merge (default), rebase, squash
# CLI flags (--rebase, --squash) always override this.
# merge_strategy: rebase
#-------------------------------------------------------------------------------
# Naming & Paths
#-------------------------------------------------------------------------------
# Directory where worktrees are created.
# Can be relative to repo root or absolute.
# Default: Sibling directory '<project>__worktrees'.
# worktree_dir: .worktrees
# Strategy for deriving names from branch names.
# Options: full (default), basename (part after last '/').
# worktree_naming: basename
# Prefix added to worktree directories and tmux window names.
# worktree_prefix: ""
# Prefix for tmux window names.
# Default: "wm-"
# window_prefix: "wm-"
#-------------------------------------------------------------------------------
# Tmux
#-------------------------------------------------------------------------------
# Mode for tmux operations: window (default) or session.
# - window: Create windows within the current tmux session
# - session: Create new tmux sessions for each worktree (useful for session-per-project workflows)
# mode: session
# Custom tmux pane layout (mutually exclusive with 'windows').
# Default: Two-pane layout with shell and clear command.
# panes:
# - command: pnpm install
# focus: true
# - split: horizontal
# - command: clear
# split: vertical
# size: 5
# Multiple windows per session (session mode only, mutually exclusive with 'panes').
# Each window can have its own pane layout. Unnamed windows get tmux's
# automatic naming based on the running command.
# windows:
# - name: editor
# panes:
# - command: <agent>
# focus: true
# - split: horizontal
# size: 20
# - name: tests
# panes:
# - command: just test --watch
# - panes:
# - command: tail -f app.log
# Auto-apply agent status icons to tmux window format.
# Default: true
# status_format: true
# Custom icons for agent status display.
# status_icons:
# working: "🤖"
# waiting: "💬"
# done: "✅"
#-------------------------------------------------------------------------------
# Agent & AI
#-------------------------------------------------------------------------------
# Agent command for '<agent>' placeholder in pane commands.
# Default: "claude"
# agent: claude
# LLM-based branch name generation (`workmux add -A`).
# auto_name:
# model: "gpt-4o-mini"
# system_prompt: "Generate a kebab-case git branch name."
# background: true # Always run in background when using --auto-name
#-------------------------------------------------------------------------------
# Hooks
#-------------------------------------------------------------------------------
# Commands to run in new worktree before tmux window opens.
# These block window creation - use for short tasks only.
# Use "<global>" to inherit from global config.
# Set to empty list to disable: `post_create: []`
# post_create:
# - "<global>"
# - mise use
# Commands to run before merging (e.g., linting, tests).
# Aborts the merge if any command fails.
# Use "<global>" to inherit from global config.
# Environment variables available:
# - WM_BRANCH_NAME: The name of the branch being merged
# - WM_TARGET_BRANCH: The name of the target branch (e.g., main)
# - WM_WORKTREE_PATH: Absolute path to the worktree
# - WM_PROJECT_ROOT: Absolute path of the main project directory
# - WM_HANDLE: The worktree handle/window name
# pre_merge:
# - "<global>"
# - cargo test
# - cargo clippy -- -D warnings
# Commands to run before worktree removal (during merge or remove).
# Useful for backing up gitignored files before cleanup.
# Default: Auto-detects Node.js projects and fast-deletes node_modules.
# Set to empty list to disable: `pre_remove: []`
# Environment variables available:
# - WM_HANDLE: The worktree handle (directory name)
# - WM_WORKTREE_PATH: Absolute path of the worktree being deleted
# - WM_PROJECT_ROOT: Absolute path of the main project directory
# pre_remove:
# - mkdir -p "$WM_PROJECT_ROOT/artifacts/$WM_HANDLE"
# - cp -r test-results/ "$WM_PROJECT_ROOT/artifacts/$WM_HANDLE/"
#-------------------------------------------------------------------------------
# Files
#-------------------------------------------------------------------------------
# File operations when creating a worktree.
# files:
# # Files to copy (useful for .env files that need to be unique).
# copy:
# - .env.local
#
# # Files/directories to symlink (saves disk space, shares caches).
# # Default: None.
# # Use "<global>" to inherit from global config.
# symlink:
# - "<global>"
# - node_modules
#-------------------------------------------------------------------------------
# Dashboard
#-------------------------------------------------------------------------------
# Actions for dashboard keybindings (c = commit, m = merge).
# Values are sent to the agent's pane. Use ! prefix for shell commands.
# Preview size (10-90): larger = more preview, less table. Use +/- keys to adjust.
# dashboard:
# commit: "Commit staged changes with a descriptive message"
# merge: "!workmux merge"
# preview_size: 60
#-------------------------------------------------------------------------------
# Sidebar
#-------------------------------------------------------------------------------
# sidebar:
# # Width: absolute columns or percentage of terminal width.
# # Default: "10%" (clamped to 25-50 columns).
# # Explicit values are not clamped (minimum 10 columns).
# width: 40 # absolute columns
# # width: "15%" # percentage of terminal width
#
# # Layout mode: "compact" (single line per agent) or "tiles" (cards).
# # Default: "tiles". Can be toggled at runtime with 'v' key.
# layout: tiles
#-------------------------------------------------------------------------------
# Sandbox
#-------------------------------------------------------------------------------
# sandbox:
# enabled: false
# backend: lima
# # host_commands: ["just", "cargo", "npm"]
# # container:
# # runtime: docker # docker | podman | apple-container
# # # memory: 16G # VM memory limit (apple-container default: 16G)
# # # cpus: 4 # VM CPU count (only passed when set)
# # lima:
# # isolation: project
# # cpus: 4
# # memory: 4GiB
# # # Custom provision script (runs once on VM creation, as user).
# # # Use sudo for system commands.
# # # provision: |
# # # sudo apt-get install -y ripgrep fd-find jq
# # Extra mount points (read-only by default).
# # Supports simple paths or detailed specs with guest_path and writable.
# # extra_mounts:
# # - ~/my-notes
# # - host_path: ~/data
# # guest_path: /mnt/data
# # writable: true
"#;
pub fn resolve_executable_path(executable: &str) -> Option<String> {
let exec_path = Path::new(executable);
if exec_path.is_absolute() {
return Some(exec_path.to_string_lossy().into_owned());
}
if executable.contains(std::path::MAIN_SEPARATOR)
|| executable.contains('/')
|| executable.contains('\\')
{
if let Ok(current_dir) = env::current_dir() {
return Some(current_dir.join(exec_path).to_string_lossy().into_owned());
}
} else {
if let Some(tmux_path) = tmux_global_path() {
let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
if let Ok(found) = which_in(executable, Some(tmux_path.as_str()), &cwd) {
return Some(found.to_string_lossy().into_owned());
}
}
if let Ok(found) = which(executable) {
return Some(found.to_string_lossy().into_owned());
}
}
None
}
pub fn tmux_global_path() -> Option<String> {
let output = cmd::Cmd::new("tmux")
.args(&["show-environment", "-g", "PATH"])
.run_and_capture_stdout()
.ok()?;
output.strip_prefix("PATH=").map(|s| s.to_string())
}
pub fn split_first_token(command: &str) -> Option<(&str, &str)> {
let trimmed = command.trim_start();
if trimmed.is_empty() {
return None;
}
Some(
trimmed
.split_once(char::is_whitespace)
.unwrap_or((trimmed, "")),
)
}
pub fn is_agent_command(command_line: &str, agent_command: &str) -> bool {
use crate::multiplexer::agent::find_executable_token;
let trimmed = command_line.trim();
if trimmed.is_empty() {
return false;
}
let cmd_token = find_executable_token(trimmed);
if cmd_token == "<agent>" {
return true;
}
let agent_token = find_executable_token(agent_command);
if agent_token.is_empty() {
return false;
}
let resolved_cmd = resolve_executable_path(cmd_token).unwrap_or_else(|| cmd_token.to_string());
let resolved_agent =
resolve_executable_path(agent_token).unwrap_or_else(|| agent_token.to_string());
let cmd_stem = Path::new(&resolved_cmd).file_stem();
let agent_stem = Path::new(&resolved_agent).file_stem();
cmd_stem.is_some() && cmd_stem == agent_stem
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::{
Config, ContainerConfig, ExtraMount, LayoutConfig, LimaConfig, NetworkConfig,
NetworkPolicy, PaneConfig, SandboxConfig, SandboxRuntime, SandboxTarget, SplitDirection,
ToolchainMode, is_agent_command, split_first_token, validate_domain,
validate_layouts_config,
};
#[test]
fn split_first_token_single_word() {
assert_eq!(split_first_token("claude"), Some(("claude", "")));
}
#[test]
fn split_first_token_with_args() {
assert_eq!(
split_first_token("claude --verbose"),
Some(("claude", "--verbose"))
);
}
#[test]
fn split_first_token_multiple_spaces() {
assert_eq!(
split_first_token("claude --verbose"),
Some(("claude", " --verbose"))
);
}
#[test]
fn split_first_token_leading_whitespace() {
assert_eq!(
split_first_token(" claude --verbose"),
Some(("claude", "--verbose"))
);
}
#[test]
fn split_first_token_empty_string() {
assert_eq!(split_first_token(""), None);
}
#[test]
fn split_first_token_only_whitespace() {
assert_eq!(split_first_token(" "), None);
}
#[test]
fn is_agent_command_placeholder() {
assert!(is_agent_command("<agent>", "claude"));
assert!(is_agent_command(" <agent> ", "gemini"));
assert!(is_agent_command("<agent> --verbose", "claude"));
assert!(is_agent_command("<agent> -p foo", "gemini"));
}
#[test]
fn is_agent_command_exact_match() {
assert!(is_agent_command("claude", "claude"));
assert!(is_agent_command("gemini", "gemini"));
}
#[test]
fn is_agent_command_with_args() {
assert!(is_agent_command("claude --verbose", "claude"));
assert!(is_agent_command("gemini -i", "gemini --model foo"));
}
#[test]
fn is_agent_command_mismatch() {
assert!(!is_agent_command("claude", "gemini"));
assert!(!is_agent_command("vim", "claude"));
assert!(!is_agent_command("clear", "claude"));
}
#[test]
fn is_agent_command_empty() {
assert!(!is_agent_command("", "claude"));
assert!(!is_agent_command(" ", "claude"));
}
#[test]
fn is_agent_command_env_wrapped() {
assert!(is_agent_command("env -u FOO claude", "claude"));
assert!(is_agent_command("claude", "env -u FOO claude"));
assert!(is_agent_command("env -u FOO claude", "env -u BAR claude"));
assert!(is_agent_command("FOO=bar claude", "claude"));
}
#[test]
fn is_agent_command_env_wrapped_mismatch() {
assert!(!is_agent_command("env -u FOO claude", "gemini"));
assert!(!is_agent_command("env -u FOO vim", "claude"));
}
#[test]
fn agents_deserialize_string_form() {
let yaml = r#"
agents:
cc-work: "claude --dangerously-skip-permissions"
cc-bedrock: "env -u CLAUDE_CODE_USE_BEDROCK claude"
cod: "codex --yolo"
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.agents.len(), 3);
assert_eq!(
config.agents.get("cc-work").unwrap().command,
"claude --dangerously-skip-permissions"
);
assert!(config.agents.get("cc-work").unwrap().agent_type.is_none());
assert_eq!(
config.agents.get("cc-bedrock").unwrap().command,
"env -u CLAUDE_CODE_USE_BEDROCK claude"
);
assert_eq!(config.agents.get("cod").unwrap().command, "codex --yolo");
}
#[test]
fn agents_deserialize_map_form_with_type() {
let yaml = r#"
agents:
cc-smart:
command: "/path/to/smart-picker"
type: claude
cod-plain: "codex"
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.agents.len(), 2);
let smart = config.agents.get("cc-smart").unwrap();
assert_eq!(smart.command, "/path/to/smart-picker");
assert_eq!(smart.agent_type.as_deref(), Some("claude"));
let cod = config.agents.get("cod-plain").unwrap();
assert_eq!(cod.command, "codex");
assert!(cod.agent_type.is_none());
}
#[test]
fn agents_empty_by_default() {
let yaml = "agent: claude";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.agents.is_empty());
}
use super::find_project_config;
use std::fs;
use tempfile::TempDir;
#[test]
fn find_project_config_from_subdir() {
let temp = TempDir::new().unwrap();
let root = temp.path();
std::process::Command::new("git")
.args(["init"])
.current_dir(root)
.output()
.unwrap();
let backend = root.join("backend");
fs::create_dir_all(&backend).unwrap();
fs::write(backend.join(".workmux.yaml"), "agent: claude").unwrap();
let src = backend.join("src");
fs::create_dir_all(&src).unwrap();
let result = find_project_config(&src).unwrap();
assert!(result.is_some());
let loc = result.unwrap();
assert!(loc.config_path.ends_with("backend/.workmux.yaml"));
assert_eq!(loc.rel_dir, std::path::PathBuf::from("backend"));
}
#[test]
fn find_project_config_nearest_wins() {
let temp = TempDir::new().unwrap();
let root = temp.path();
std::process::Command::new("git")
.args(["init"])
.current_dir(root)
.output()
.unwrap();
fs::write(root.join(".workmux.yaml"), "agent: root").unwrap();
let backend = root.join("backend");
fs::create_dir_all(&backend).unwrap();
fs::write(backend.join(".workmux.yaml"), "agent: backend").unwrap();
let result = find_project_config(&backend).unwrap();
assert!(result.is_some());
let loc = result.unwrap();
assert!(loc.config_path.ends_with("backend/.workmux.yaml"));
}
#[test]
fn sandbox_config_defaults() {
let config = SandboxConfig::default();
assert!(!config.is_enabled());
assert_eq!(config.target(), SandboxTarget::Agent);
assert!(config.env_passthrough().is_empty());
}
#[test]
fn sandbox_runtime_explicit_overrides_detect() {
let config = ContainerConfig {
runtime: Some(SandboxRuntime::Podman),
..Default::default()
};
assert_eq!(config.runtime(), SandboxRuntime::Podman);
let config = ContainerConfig {
runtime: Some(SandboxRuntime::Docker),
..Default::default()
};
assert_eq!(config.runtime(), SandboxRuntime::Docker);
}
#[test]
fn sandbox_runtime_detect_when_unset() {
let config = ContainerConfig {
runtime: None,
..Default::default()
};
let _runtime = config.runtime();
}
#[test]
fn sandbox_config_merge() {
let global = Config {
sandbox: SandboxConfig {
enabled: Some(true),
container: ContainerConfig {
runtime: Some(SandboxRuntime::Docker),
..Default::default()
},
image: Some("global-image".to_string()),
..Default::default()
},
..Default::default()
};
let project = Config {
sandbox: SandboxConfig {
image: Some("project-image".to_string()),
container: ContainerConfig {
runtime: Some(SandboxRuntime::Podman),
..Default::default()
},
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert!(merged.sandbox.is_enabled()); assert_eq!(merged.sandbox.resolved_image("claude"), "project-image"); assert_eq!(merged.sandbox.runtime(), SandboxRuntime::Podman); }
#[test]
fn sandbox_provision_merge_override() {
let global = Config {
sandbox: SandboxConfig {
lima: LimaConfig {
provision: Some("echo global".to_string()),
..Default::default()
},
..Default::default()
},
..Default::default()
};
let project = Config {
sandbox: SandboxConfig {
lima: LimaConfig {
provision: Some("echo project".to_string()),
..Default::default()
},
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert_eq!(merged.sandbox.lima.provision_script(), Some("echo project"));
}
#[test]
fn sandbox_provision_merge_fallback() {
let global = Config {
sandbox: SandboxConfig {
lima: LimaConfig {
provision: Some("echo global".to_string()),
..Default::default()
},
..Default::default()
},
..Default::default()
};
let project = Config::default();
let merged = global.merge(project);
assert_eq!(merged.sandbox.lima.provision_script(), Some("echo global"));
}
#[test]
fn sandbox_provision_empty_disables_global() {
let global = Config {
sandbox: SandboxConfig {
lima: LimaConfig {
provision: Some("echo global".to_string()),
..Default::default()
},
..Default::default()
},
..Default::default()
};
let project = Config {
sandbox: SandboxConfig {
lima: LimaConfig {
provision: Some("".to_string()),
..Default::default()
},
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert_eq!(merged.sandbox.lima.provision, Some("".to_string()));
assert_eq!(merged.sandbox.lima.provision_script(), None);
}
#[test]
fn sandbox_skip_default_provision_defaults_false() {
let config = LimaConfig::default();
assert!(!config.skip_default_provision());
}
#[test]
fn sandbox_skip_default_provision_merge() {
let global = Config {
sandbox: SandboxConfig {
lima: LimaConfig {
skip_default_provision: Some(true),
..Default::default()
},
..Default::default()
},
..Default::default()
};
let project = Config::default();
let merged = global.merge(project);
assert!(merged.sandbox.lima.skip_default_provision());
}
#[test]
fn sandbox_skip_default_provision_project_overrides() {
let global = Config {
sandbox: SandboxConfig {
lima: LimaConfig {
skip_default_provision: Some(true),
..Default::default()
},
..Default::default()
},
..Default::default()
};
let project = Config {
sandbox: SandboxConfig {
lima: LimaConfig {
skip_default_provision: Some(false),
..Default::default()
},
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert!(!merged.sandbox.lima.skip_default_provision());
}
#[test]
fn test_rpc_host_address_defaults() {
assert_eq!(
SandboxRuntime::Docker.rpc_host_address(),
"host.docker.internal"
);
assert_eq!(
SandboxRuntime::Podman.rpc_host_address(),
"host.containers.internal"
);
}
#[test]
fn test_resolved_rpc_host_uses_override() {
let config = SandboxConfig {
rpc_host: Some("custom.host.local".to_string()),
..Default::default()
};
assert_eq!(config.resolved_rpc_host(), "custom.host.local");
}
#[test]
fn test_resolved_rpc_host_falls_back_to_runtime() {
let config = SandboxConfig {
container: ContainerConfig {
runtime: Some(SandboxRuntime::Podman),
..Default::default()
},
..Default::default()
};
assert_eq!(config.resolved_rpc_host(), "host.containers.internal");
}
#[test]
fn sandbox_toolchain_defaults_to_auto() {
let config = SandboxConfig::default();
assert_eq!(config.toolchain(), ToolchainMode::Auto);
}
#[test]
fn sandbox_toolchain_merge_project_overrides() {
let global = Config {
sandbox: SandboxConfig {
toolchain: Some(ToolchainMode::Auto),
..Default::default()
},
..Default::default()
};
let project = Config {
sandbox: SandboxConfig {
toolchain: Some(ToolchainMode::Off),
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert_eq!(merged.sandbox.toolchain(), ToolchainMode::Off);
}
#[test]
fn sandbox_toolchain_merge_fallback_to_global() {
let global = Config {
sandbox: SandboxConfig {
toolchain: Some(ToolchainMode::Devbox),
..Default::default()
},
..Default::default()
};
let project = Config::default();
let merged = global.merge(project);
assert_eq!(merged.sandbox.toolchain(), ToolchainMode::Devbox);
}
#[test]
fn test_sandbox_host_commands_default_empty() {
let config = SandboxConfig::default();
assert!(config.host_commands().is_empty());
}
#[test]
fn test_sandbox_host_commands_global_only() {
let global = Config {
sandbox: SandboxConfig {
host_commands: Some(vec!["just".to_string(), "cargo".to_string()]),
..Default::default()
},
..Default::default()
};
let project = Config {
sandbox: SandboxConfig {
host_commands: Some(vec!["npm".to_string()]),
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert_eq!(
merged.sandbox.host_commands(),
&["just".to_string(), "cargo".to_string()]
);
}
#[test]
fn test_sandbox_host_commands_project_ignored_when_no_global() {
let global = Config::default(); let project = Config {
sandbox: SandboxConfig {
host_commands: Some(vec!["rm".to_string()]),
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert!(merged.sandbox.host_commands().is_empty());
}
#[test]
fn test_sandbox_host_commands_uses_global() {
let global = Config {
sandbox: SandboxConfig {
host_commands: Some(vec!["just".to_string()]),
..Default::default()
},
..Default::default()
};
let project = Config::default();
let merged = global.merge(project);
assert_eq!(merged.sandbox.host_commands(), &["just".to_string()]);
}
#[test]
fn test_allow_unsandboxed_host_exec_defaults_false() {
let config = SandboxConfig::default();
assert!(!config.allow_unsandboxed_host_exec());
}
#[test]
fn test_allow_unsandboxed_host_exec_global_only() {
let global = Config {
sandbox: SandboxConfig {
dangerously_allow_unsandboxed_host_exec: Some(true),
..Default::default()
},
..Default::default()
};
let project = Config {
sandbox: SandboxConfig {
dangerously_allow_unsandboxed_host_exec: Some(false),
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert!(merged.sandbox.allow_unsandboxed_host_exec());
}
#[test]
fn test_allow_unsandboxed_host_exec_not_set_in_project() {
let global = Config::default();
let project = Config {
sandbox: SandboxConfig {
dangerously_allow_unsandboxed_host_exec: Some(true),
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert!(!merged.sandbox.allow_unsandboxed_host_exec());
}
#[test]
fn test_sandbox_rpc_host_global_only() {
let global = Config {
sandbox: SandboxConfig {
rpc_host: Some("trusted.host".to_string()),
..Default::default()
},
..Default::default()
};
let project = Config {
sandbox: SandboxConfig {
rpc_host: Some("evil.attacker.com".to_string()),
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert_eq!(merged.sandbox.rpc_host, Some("trusted.host".to_string()));
}
#[test]
fn test_sandbox_rpc_host_project_ignored_when_no_global() {
let global = Config::default(); let project = Config {
sandbox: SandboxConfig {
rpc_host: Some("evil.attacker.com".to_string()),
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert!(merged.sandbox.rpc_host.is_none());
}
#[test]
fn test_sandbox_rpc_host_uses_global() {
let global = Config {
sandbox: SandboxConfig {
rpc_host: Some("custom.host".to_string()),
..Default::default()
},
..Default::default()
};
let project = Config::default();
let merged = global.merge(project);
assert_eq!(merged.sandbox.rpc_host, Some("custom.host".to_string()));
}
#[test]
fn test_sandbox_image_project_overrides_global() {
let global = Config {
sandbox: SandboxConfig {
image: Some("global:latest".to_string()),
..Default::default()
},
..Default::default()
};
let project = Config {
sandbox: SandboxConfig {
image: Some("custom:latest".to_string()),
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert_eq!(merged.sandbox.image, Some("custom:latest".to_string()));
}
#[test]
fn test_sandbox_image_project_used_when_no_global() {
let global = Config::default();
let project = Config {
sandbox: SandboxConfig {
image: Some("custom:latest".to_string()),
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert_eq!(merged.sandbox.image, Some("custom:latest".to_string()));
}
#[test]
fn test_sandbox_image_falls_back_to_global() {
let global = Config {
sandbox: SandboxConfig {
image: Some("global:latest".to_string()),
..Default::default()
},
..Default::default()
};
let project = Config::default();
let merged = global.merge(project);
assert_eq!(merged.sandbox.image, Some("global:latest".to_string()));
}
#[test]
fn test_sandbox_env_passthrough_global_only() {
let global = Config {
sandbox: SandboxConfig {
env_passthrough: Some(vec!["GITHUB_TOKEN".to_string()]),
..Default::default()
},
..Default::default()
};
let project = Config {
sandbox: SandboxConfig {
env_passthrough: Some(vec!["AWS_SECRET_ACCESS_KEY".to_string()]),
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert_eq!(
merged.sandbox.env_passthrough,
Some(vec!["GITHUB_TOKEN".to_string()])
);
}
#[test]
fn test_sandbox_env_passthrough_project_ignored_when_no_global() {
let global = Config::default();
let project = Config {
sandbox: SandboxConfig {
env_passthrough: Some(vec!["AWS_SECRET_ACCESS_KEY".to_string()]),
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert!(merged.sandbox.env_passthrough.is_none());
}
#[test]
fn test_sandbox_env_passthrough_uses_global() {
let global = Config {
sandbox: SandboxConfig {
env_passthrough: Some(vec!["GITHUB_TOKEN".to_string()]),
..Default::default()
},
..Default::default()
};
let project = Config::default();
let merged = global.merge(project);
assert_eq!(
merged.sandbox.env_passthrough,
Some(vec!["GITHUB_TOKEN".to_string()])
);
}
#[test]
fn sandbox_env_global_only() {
let global = Config {
sandbox: SandboxConfig {
env: Some(HashMap::from([(
"GH_TOKEN".to_string(),
"global_token".to_string(),
)])),
..Default::default()
},
..Default::default()
};
let project = Config {
sandbox: SandboxConfig {
env: Some(HashMap::from([(
"GH_TOKEN".to_string(),
"project_token".to_string(),
)])),
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
let env = merged.sandbox.env.unwrap();
assert_eq!(env.get("GH_TOKEN").unwrap(), "global_token");
}
#[test]
fn sandbox_env_project_ignored_when_no_global() {
let global = Config::default();
let project = Config {
sandbox: SandboxConfig {
env: Some(HashMap::from([(
"GH_TOKEN".to_string(),
"project_token".to_string(),
)])),
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert!(merged.sandbox.env.is_none());
}
#[test]
fn sandbox_env_uses_global() {
let global = Config {
sandbox: SandboxConfig {
env: Some(HashMap::from([(
"GH_TOKEN".to_string(),
"global_token".to_string(),
)])),
..Default::default()
},
..Default::default()
};
let project = Config::default();
let merged = global.merge(project);
let env = merged.sandbox.env.unwrap();
assert_eq!(env.get("GH_TOKEN").unwrap(), "global_token");
}
#[test]
fn sandbox_env_vars_accessor() {
let config = SandboxConfig {
env: Some(HashMap::from([("KEY".to_string(), "VALUE".to_string())])),
..Default::default()
};
let vars = config.env_vars();
assert_eq!(vars.len(), 1);
assert_eq!(vars[0], ("KEY", "VALUE"));
}
#[test]
fn sandbox_env_vars_accessor_empty() {
let config = SandboxConfig::default();
assert!(config.env_vars().is_empty());
}
#[test]
fn test_extra_mount_parse_simple_string() {
let yaml = r#"extra_mounts: ["/tmp/notes"]"#;
let config: SandboxConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.extra_mounts().len(), 1);
let (host, guest, read_only) = config.extra_mounts()[0].resolve().unwrap();
assert_eq!(host, std::path::PathBuf::from("/tmp/notes"));
assert_eq!(guest, std::path::PathBuf::from("/tmp/notes"));
assert!(read_only);
}
#[test]
fn test_extra_mount_parse_spec() {
let yaml = r#"
extra_mounts:
- host_path: /tmp/data
guest_path: /mnt/data
writable: true
"#;
let config: SandboxConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.extra_mounts().len(), 1);
let (host, guest, read_only) = config.extra_mounts()[0].resolve().unwrap();
assert_eq!(host, std::path::PathBuf::from("/tmp/data"));
assert_eq!(guest, std::path::PathBuf::from("/mnt/data"));
assert!(!read_only);
}
#[test]
fn test_extra_mount_spec_defaults() {
let yaml = r#"
extra_mounts:
- host_path: /tmp/data
"#;
let config: SandboxConfig = serde_yaml::from_str(yaml).unwrap();
let (host, guest, read_only) = config.extra_mounts()[0].resolve().unwrap();
assert_eq!(host, std::path::PathBuf::from("/tmp/data"));
assert_eq!(guest, std::path::PathBuf::from("/tmp/data"));
assert!(read_only);
}
#[test]
fn test_extra_mount_tilde_expansion() {
let mount = ExtraMount::Path("~/notes".to_string());
let (host, guest, _) = mount.resolve().unwrap();
assert!(!host.to_string_lossy().starts_with('~'));
assert!(host.to_string_lossy().ends_with("/notes"));
assert_eq!(host, guest);
}
#[test]
fn test_extra_mount_mixed_list() {
let yaml = r#"
extra_mounts:
- /tmp/notes
- host_path: /tmp/data
guest_path: /mnt/data
writable: true
"#;
let config: SandboxConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.extra_mounts().len(), 2);
let (host0, _, ro0) = config.extra_mounts()[0].resolve().unwrap();
assert_eq!(host0, std::path::PathBuf::from("/tmp/notes"));
assert!(ro0);
let (host1, guest1, ro1) = config.extra_mounts()[1].resolve().unwrap();
assert_eq!(host1, std::path::PathBuf::from("/tmp/data"));
assert_eq!(guest1, std::path::PathBuf::from("/mnt/data"));
assert!(!ro1);
}
#[test]
fn test_extra_mounts_default_empty() {
let config = SandboxConfig::default();
assert!(config.extra_mounts().is_empty());
}
#[test]
fn test_extra_mounts_global_only() {
let global = Config {
sandbox: SandboxConfig {
extra_mounts: Some(vec![ExtraMount::Path("/global/path".to_string())]),
..Default::default()
},
..Default::default()
};
let project = Config {
sandbox: SandboxConfig {
extra_mounts: Some(vec![ExtraMount::Path("/project/path".to_string())]),
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert_eq!(merged.sandbox.extra_mounts().len(), 1);
let (host, _, _) = merged.sandbox.extra_mounts()[0].resolve().unwrap();
assert_eq!(host, std::path::PathBuf::from("/global/path"));
}
#[test]
fn test_extra_mounts_project_ignored_when_no_global() {
let global = Config::default(); let project = Config {
sandbox: SandboxConfig {
extra_mounts: Some(vec![ExtraMount::Path("/project/path".to_string())]),
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert!(merged.sandbox.extra_mounts().is_empty());
}
#[test]
fn test_extra_mounts_uses_global() {
let global = Config {
sandbox: SandboxConfig {
extra_mounts: Some(vec![ExtraMount::Path("/global/path".to_string())]),
..Default::default()
},
..Default::default()
};
let project = Config::default();
let merged = global.merge(project);
assert_eq!(merged.sandbox.extra_mounts().len(), 1);
let (host, _, _) = merged.sandbox.extra_mounts()[0].resolve().unwrap();
assert_eq!(host, std::path::PathBuf::from("/global/path"));
}
#[test]
fn test_resolved_agent_config_dir_with_placeholder() {
let config = SandboxConfig {
agent_config_dir: Some("~/sandbox/{agent}".to_string()),
..Default::default()
};
let dir = config.resolved_agent_config_dir("claude").unwrap();
let home = home::home_dir().unwrap();
assert_eq!(dir, home.join("sandbox/claude"));
}
#[test]
fn test_resolved_agent_config_dir_without_placeholder() {
let config = SandboxConfig {
agent_config_dir: Some("~/my-config".to_string()),
..Default::default()
};
let dir = config.resolved_agent_config_dir("claude").unwrap();
let home = home::home_dir().unwrap();
assert_eq!(dir, home.join("my-config"));
}
#[test]
fn test_resolved_agent_config_dir_default() {
let config = SandboxConfig::default();
let dir = config.resolved_agent_config_dir("claude").unwrap();
let home = home::home_dir().unwrap();
assert_eq!(dir, home.join(".claude"));
}
#[test]
fn test_resolved_agent_config_dir_unknown_agent_default() {
let config = SandboxConfig::default();
assert!(config.resolved_agent_config_dir("unknown").is_none());
}
#[test]
fn test_resolved_agent_config_dir_unknown_agent_custom() {
let config = SandboxConfig {
agent_config_dir: Some("/custom/{agent}".to_string()),
..Default::default()
};
let dir = config.resolved_agent_config_dir("unknown").unwrap();
assert_eq!(dir, std::path::PathBuf::from("/custom/unknown"));
}
#[test]
fn test_agent_config_dir_global_only() {
let global = Config {
sandbox: SandboxConfig {
agent_config_dir: Some("~/global/{agent}".to_string()),
..Default::default()
},
..Default::default()
};
let project = Config {
sandbox: SandboxConfig {
agent_config_dir: Some("~/project/{agent}".to_string()),
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert_eq!(
merged.sandbox.agent_config_dir,
Some("~/global/{agent}".to_string())
);
}
#[test]
fn test_agent_config_dir_project_ignored_when_no_global() {
let global = Config::default();
let project = Config {
sandbox: SandboxConfig {
agent_config_dir: Some("~/project/{agent}".to_string()),
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert!(merged.sandbox.agent_config_dir.is_none());
}
#[test]
fn test_extra_mount_rejects_relative_host_path() {
let mount = ExtraMount::Path("relative/path".to_string());
let result = mount.resolve();
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("must be absolute"));
}
#[test]
fn test_extra_mount_rejects_relative_guest_path() {
let mount = ExtraMount::Spec {
host_path: "/tmp/data".to_string(),
guest_path: Some("relative/guest".to_string()),
writable: None,
};
let result = mount.resolve();
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("guest_path must be absolute"));
}
#[test]
fn sandbox_nested_yaml_format() {
let yaml = r#"
enabled: true
backend: lima
lima:
isolation: shared
cpus: 16
memory: 16GiB
container:
runtime: podman
"#;
let config: SandboxConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.is_enabled());
assert_eq!(config.lima.isolation(), super::IsolationLevel::Shared);
assert_eq!(config.lima.cpus(), 16);
assert_eq!(config.lima.memory(), "16GiB");
assert_eq!(config.container.runtime(), SandboxRuntime::Podman);
}
#[test]
fn sandbox_lima_config_merge() {
let global = LimaConfig {
isolation: Some(super::IsolationLevel::Shared),
cpus: Some(4),
memory: Some("4GiB".to_string()),
..Default::default()
};
let project = LimaConfig {
cpus: Some(8),
provision: Some("echo project".to_string()),
..Default::default()
};
let merged = LimaConfig::merge(global, project);
assert_eq!(merged.cpus(), 8);
assert_eq!(merged.provision_script(), Some("echo project"));
assert_eq!(merged.isolation(), super::IsolationLevel::Shared);
assert_eq!(merged.memory(), "4GiB");
}
#[test]
fn sandbox_container_config_merge() {
let global = ContainerConfig {
runtime: Some(SandboxRuntime::Docker),
..Default::default()
};
let project = ContainerConfig {
runtime: Some(SandboxRuntime::Podman),
..Default::default()
};
let merged = ContainerConfig::merge(global, project);
assert_eq!(merged.runtime(), SandboxRuntime::Podman);
}
#[test]
fn network_policy_defaults_to_allow() {
let config = SandboxConfig::default();
assert_eq!(config.network.policy(), NetworkPolicy::Allow);
assert!(!config.network_policy_is_deny());
}
#[test]
fn network_policy_deny() {
let config = SandboxConfig {
network: NetworkConfig {
policy: Some(NetworkPolicy::Deny),
..Default::default()
},
..Default::default()
};
assert_eq!(config.network.policy(), NetworkPolicy::Deny);
assert!(config.network_policy_is_deny());
}
#[test]
fn network_allowed_domains_default_empty() {
let config = NetworkConfig::default();
assert!(config.allowed_domains().is_empty());
}
#[test]
fn network_config_global_only() {
let global = Config {
sandbox: SandboxConfig {
network: NetworkConfig {
policy: Some(NetworkPolicy::Deny),
allowed_domains: Some(vec!["api.anthropic.com".to_string()]),
},
..Default::default()
},
..Default::default()
};
let project = Config {
sandbox: SandboxConfig {
network: NetworkConfig {
policy: Some(NetworkPolicy::Allow),
allowed_domains: Some(vec!["evil.com".to_string()]),
},
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert_eq!(merged.sandbox.network.policy(), NetworkPolicy::Deny);
assert_eq!(
merged.sandbox.network.allowed_domains(),
&["api.anthropic.com".to_string()]
);
}
#[test]
fn network_config_project_ignored_when_no_global() {
let global = Config::default();
let project = Config {
sandbox: SandboxConfig {
network: NetworkConfig {
policy: Some(NetworkPolicy::Deny),
allowed_domains: Some(vec!["evil.com".to_string()]),
},
..Default::default()
},
..Default::default()
};
let merged = global.merge(project);
assert_eq!(merged.sandbox.network.policy(), NetworkPolicy::Allow);
assert!(merged.sandbox.network.allowed_domains().is_empty());
}
#[test]
fn network_config_uses_global() {
let global = Config {
sandbox: SandboxConfig {
network: NetworkConfig {
policy: Some(NetworkPolicy::Deny),
allowed_domains: Some(vec!["github.com".to_string()]),
},
..Default::default()
},
..Default::default()
};
let project = Config::default();
let merged = global.merge(project);
assert_eq!(merged.sandbox.network.policy(), NetworkPolicy::Deny);
assert_eq!(
merged.sandbox.network.allowed_domains(),
&["github.com".to_string()]
);
}
#[test]
fn validate_domain_rejects_ip_literal() {
assert!(validate_domain("192.168.1.1").is_err());
assert!(validate_domain("127.0.0.1").is_err());
assert!(validate_domain("::1").is_err());
}
#[test]
fn validate_domain_rejects_trailing_dot() {
assert!(validate_domain("example.com.").is_err());
}
#[test]
fn validate_domain_rejects_malformed_wildcard() {
assert!(validate_domain("foo.*.com").is_err());
assert!(validate_domain("*foo.com").is_err());
}
#[test]
fn validate_domain_rejects_empty() {
assert!(validate_domain("").is_err());
}
#[test]
fn validate_domain_accepts_valid() {
assert!(validate_domain("example.com").is_ok());
assert!(validate_domain("api.anthropic.com").is_ok());
assert!(validate_domain("*.googleapis.com").is_ok());
assert!(validate_domain("*.github.com").is_ok());
}
#[test]
fn network_config_validate_catches_bad_domains() {
let config = NetworkConfig {
policy: Some(NetworkPolicy::Deny),
allowed_domains: Some(vec!["good.com".to_string(), "192.168.1.1".to_string()]),
};
assert!(config.validate().is_err());
}
#[test]
fn network_config_validate_passes_good_domains() {
let config = NetworkConfig {
policy: Some(NetworkPolicy::Deny),
allowed_domains: Some(vec![
"api.anthropic.com".to_string(),
"*.github.com".to_string(),
"registry.npmjs.org".to_string(),
]),
};
assert!(config.validate().is_ok());
}
#[test]
fn network_config_yaml_roundtrip() {
let yaml = r#"
network:
policy: deny
allowed_domains:
- api.anthropic.com
- "*.github.com"
"#;
let config: SandboxConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.network.policy(), NetworkPolicy::Deny);
assert_eq!(config.network.allowed_domains().len(), 2);
}
use super::{WindowConfig, validate_windows_config};
#[test]
fn parse_windows_config_named() {
let yaml = r#"
windows:
- name: editor
panes:
- command: <agent>
focus: true
- split: horizontal
size: 20
- name: tests
panes:
- command: just test --watch
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
let windows = config.windows.unwrap();
assert_eq!(windows.len(), 2);
assert_eq!(windows[0].name.as_deref(), Some("editor"));
assert_eq!(windows[0].panes.as_ref().unwrap().len(), 2);
assert_eq!(windows[1].name.as_deref(), Some("tests"));
assert_eq!(windows[1].panes.as_ref().unwrap().len(), 1);
}
#[test]
fn parse_windows_config_unnamed() {
let yaml = r#"
windows:
- panes:
- command: tail -f app.log
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
let windows = config.windows.unwrap();
assert_eq!(windows.len(), 1);
assert!(windows[0].name.is_none());
}
#[test]
fn parse_windows_config_mixed() {
let yaml = r#"
windows:
- name: editor
panes:
- command: <agent>
focus: true
- panes:
- command: tail -f app.log
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
let windows = config.windows.unwrap();
assert_eq!(windows.len(), 2);
assert_eq!(windows[0].name.as_deref(), Some("editor"));
assert!(windows[1].name.is_none());
}
#[test]
fn validate_windows_config_empty_errors() {
let result = validate_windows_config(&[]);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("must not be empty")
);
}
#[test]
fn validate_windows_config_valid() {
let windows = vec![
WindowConfig {
name: Some("editor".to_string()),
panes: Some(vec![super::PaneConfig {
command: Some("<agent>".to_string()),
focus: true,
..Default::default()
}]),
},
WindowConfig {
name: None,
panes: Some(vec![super::PaneConfig {
command: Some("tail -f app.log".to_string()),
..Default::default()
}]),
},
];
assert!(validate_windows_config(&windows).is_ok());
}
#[test]
fn validate_windows_config_bad_pane_errors() {
let windows = vec![WindowConfig {
name: Some("bad".to_string()),
panes: Some(vec![super::PaneConfig {
split: Some(super::SplitDirection::Horizontal), ..Default::default()
}]),
}];
let result = validate_windows_config(&windows);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Window 0"));
}
#[test]
fn merge_project_windows_overrides_global_panes() {
let global = Config {
panes: Some(vec![super::PaneConfig {
command: Some("vim".to_string()),
focus: true,
..Default::default()
}]),
..Default::default()
};
let project = Config {
windows: Some(vec![
WindowConfig {
name: Some("editor".to_string()),
panes: None,
},
WindowConfig {
name: Some("tests".to_string()),
panes: None,
},
]),
..Default::default()
};
let merged = global.merge(project);
assert!(merged.windows.is_some());
assert!(merged.panes.is_none());
assert_eq!(merged.windows.unwrap().len(), 2);
}
#[test]
fn merge_project_panes_overrides_global_windows() {
let global = Config {
windows: Some(vec![WindowConfig {
name: Some("global-window".to_string()),
panes: None,
}]),
..Default::default()
};
let project = Config {
panes: Some(vec![super::PaneConfig {
command: Some("vim".to_string()),
focus: true,
..Default::default()
}]),
..Default::default()
};
let merged = global.merge(project);
assert!(merged.panes.is_some());
assert!(merged.windows.is_none());
}
#[test]
fn merge_global_windows_inherited_when_no_project_layout() {
let global = Config {
windows: Some(vec![WindowConfig {
name: Some("global-window".to_string()),
panes: None,
}]),
..Default::default()
};
let project = Config::default();
let merged = global.merge(project);
assert!(merged.windows.is_some());
assert!(merged.panes.is_none());
}
#[test]
fn parse_runtime_apple_container() {
let yaml = r#"
sandbox:
container:
runtime: apple-container
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
config.sandbox.container.runtime,
Some(SandboxRuntime::AppleContainer)
);
}
#[test]
fn runtime_binary_names() {
assert_eq!(SandboxRuntime::Docker.binary_name(), "docker");
assert_eq!(SandboxRuntime::Podman.binary_name(), "podman");
assert_eq!(SandboxRuntime::AppleContainer.binary_name(), "container");
}
#[test]
fn runtime_rpc_host_addresses() {
assert_eq!(
SandboxRuntime::Docker.rpc_host_address(),
"host.docker.internal"
);
assert_eq!(
SandboxRuntime::Podman.rpc_host_address(),
"host.containers.internal"
);
assert_eq!(
SandboxRuntime::AppleContainer.rpc_host_address(),
"192.168.64.1"
);
}
#[test]
fn runtime_capability_flags() {
assert!(SandboxRuntime::Docker.needs_add_host());
assert!(!SandboxRuntime::Podman.needs_add_host());
assert!(!SandboxRuntime::AppleContainer.needs_add_host());
assert!(!SandboxRuntime::Docker.needs_userns_keep_id());
assert!(SandboxRuntime::Podman.needs_userns_keep_id());
assert!(!SandboxRuntime::AppleContainer.needs_userns_keep_id());
assert!(SandboxRuntime::Docker.needs_deny_mode_caps());
assert!(SandboxRuntime::Podman.needs_deny_mode_caps());
assert!(!SandboxRuntime::AppleContainer.needs_deny_mode_caps());
}
#[test]
fn runtime_pull_args() {
assert_eq!(
SandboxRuntime::Docker.pull_args("img:latest"),
vec!["pull", "img:latest"]
);
assert_eq!(
SandboxRuntime::Podman.pull_args("img:latest"),
vec!["pull", "img:latest"]
);
assert_eq!(
SandboxRuntime::AppleContainer.pull_args("img:latest"),
vec!["image", "pull", "img:latest"]
);
}
#[test]
fn runtime_serde_name_roundtrip() {
for runtime in [
SandboxRuntime::Docker,
SandboxRuntime::Podman,
SandboxRuntime::AppleContainer,
] {
let name = runtime.serde_name();
let parsed = SandboxRuntime::from_serde_name(name).unwrap();
assert_eq!(parsed, runtime);
}
}
#[test]
fn runtime_from_serde_name_unknown() {
assert_eq!(SandboxRuntime::from_serde_name("unknown"), None);
assert_eq!(SandboxRuntime::from_serde_name(""), None);
}
#[test]
fn runtime_default_memory() {
assert_eq!(SandboxRuntime::AppleContainer.default_memory(), Some("16G"));
assert_eq!(SandboxRuntime::Docker.default_memory(), None);
assert_eq!(SandboxRuntime::Podman.default_memory(), None);
}
#[test]
fn container_config_merge_resources() {
let global = ContainerConfig {
runtime: Some(SandboxRuntime::Docker),
memory: Some("8G".to_string()),
cpus: Some(4),
..Default::default()
};
let project = ContainerConfig {
runtime: None,
memory: Some("16G".to_string()),
cpus: None,
..Default::default()
};
let merged = ContainerConfig::merge(global, project);
assert_eq!(merged.memory.as_deref(), Some("16G")); assert_eq!(merged.cpus, Some(4)); assert_eq!(merged.runtime, Some(SandboxRuntime::Docker));
}
use super::{ThemeConfig, ThemeMode, ThemeScheme};
#[test]
fn theme_scheme_slug_roundtrip() {
for scheme in &ThemeScheme::ALL {
let slug = scheme.slug();
assert_eq!(
ThemeScheme::from_slug(slug),
Some(*scheme),
"slug roundtrip failed for {:?}",
scheme
);
}
}
#[test]
fn theme_scheme_next_wraps() {
let mut current = ThemeScheme::Default;
for _ in 0..ThemeScheme::ALL.len() {
current = current.next();
}
assert_eq!(current, ThemeScheme::Default);
}
#[test]
fn theme_scheme_all_is_exhaustive() {
for scheme in &ThemeScheme::ALL {
match scheme {
ThemeScheme::Default
| ThemeScheme::Emberforge
| ThemeScheme::GlacierSignal
| ThemeScheme::ObsidianPop
| ThemeScheme::SlateGarden
| ThemeScheme::PhosphorArcade
| ThemeScheme::Lasergrid
| ThemeScheme::Mossfire
| ThemeScheme::NightSorbet
| ThemeScheme::GraphiteCode
| ThemeScheme::FestivalCircuit
| ThemeScheme::TealDrift => {}
}
}
assert_eq!(ThemeScheme::ALL.len(), 12);
}
#[test]
fn theme_config_string_scheme() {
let config: ThemeConfig = serde_yaml::from_str("emberforge").unwrap();
assert_eq!(config.scheme, ThemeScheme::Emberforge);
assert_eq!(config.mode, None);
}
#[test]
fn theme_config_string_legacy_dark() {
let config: ThemeConfig = serde_yaml::from_str("dark").unwrap();
assert_eq!(config.scheme, ThemeScheme::Default);
assert_eq!(config.mode, Some(ThemeMode::Dark));
}
#[test]
fn theme_config_string_legacy_light() {
let config: ThemeConfig = serde_yaml::from_str("light").unwrap();
assert_eq!(config.scheme, ThemeScheme::Default);
assert_eq!(config.mode, Some(ThemeMode::Light));
}
#[test]
fn theme_config_structured() {
let yaml = "scheme: glacier-signal\nmode: light";
let config: ThemeConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.scheme, ThemeScheme::GlacierSignal);
assert_eq!(config.mode, Some(ThemeMode::Light));
}
#[test]
fn theme_config_structured_scheme_only() {
let yaml = "scheme: night-sorbet";
let config: ThemeConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.scheme, ThemeScheme::NightSorbet);
assert_eq!(config.mode, None);
}
#[test]
fn theme_config_unknown_scheme_defaults() {
let config: ThemeConfig = serde_yaml::from_str("nonexistent").unwrap();
assert_eq!(config.scheme, ThemeScheme::Default);
assert_eq!(config.mode, None);
}
#[test]
fn theme_config_full_config_file() {
let yaml = "agent: claude\ntheme: teal-drift\nnerdfont: true";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.theme.scheme, ThemeScheme::TealDrift);
assert_eq!(config.theme.mode, None);
}
#[test]
fn theme_config_full_config_structured() {
let yaml = "agent: claude\ntheme:\n scheme: obsidian-pop\n mode: dark\nnerdfont: true";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.theme.scheme, ThemeScheme::ObsidianPop);
assert_eq!(config.theme.mode, Some(ThemeMode::Dark));
}
#[test]
fn theme_config_full_config_legacy() {
let yaml = "agent: claude\ntheme: light";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.theme.scheme, ThemeScheme::Default);
assert_eq!(config.theme.mode, Some(ThemeMode::Light));
}
#[test]
fn theme_config_missing_defaults() {
let yaml = "agent: claude";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.theme.scheme, ThemeScheme::Default);
assert_eq!(config.theme.mode, None);
}
#[test]
fn deserialize_layouts() {
let yaml = r#"
layouts:
design:
panes:
- command: "<agent:claude>"
focus: true
- command: "<agent:codex>"
split: vertical
review:
panes:
- command: "<agent:claude>"
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
let layouts = config.layouts.unwrap();
assert_eq!(layouts.len(), 2);
assert!(layouts.contains_key("design"));
assert_eq!(layouts["design"].panes.len(), 2);
assert_eq!(layouts["review"].panes.len(), 1);
}
#[test]
fn deserialize_layouts_absent() {
let yaml = "agent: claude";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.layouts.is_none());
}
#[test]
fn validate_layouts_valid() {
let mut layouts = HashMap::new();
layouts.insert(
"test".to_string(),
LayoutConfig {
panes: vec![
PaneConfig {
command: Some("<agent:claude>".into()),
focus: true,
..Default::default()
},
PaneConfig {
command: Some("vim".into()),
split: Some(SplitDirection::Horizontal),
..Default::default()
},
],
},
);
assert!(validate_layouts_config(&layouts).is_ok());
}
#[test]
fn validate_panes_multiple_zoom_fails() {
let panes = vec![
PaneConfig {
command: Some("vim".to_string()),
zoom: true,
..Default::default()
},
PaneConfig {
command: Some("echo hi".to_string()),
split: Some(SplitDirection::Horizontal),
zoom: true,
..Default::default()
},
];
let err = super::validate_panes_config(&panes).unwrap_err();
assert!(err.to_string().contains("Only one pane"));
}
#[test]
fn validate_panes_single_zoom_ok() {
let panes = vec![
PaneConfig {
command: Some("vim".to_string()),
zoom: true,
..Default::default()
},
PaneConfig {
command: Some("echo hi".to_string()),
split: Some(SplitDirection::Horizontal),
..Default::default()
},
];
assert!(super::validate_panes_config(&panes).is_ok());
}
#[test]
fn zoom_deserializes_from_yaml() {
let yaml = r#"
panes:
- command: vim
zoom: true
- command: echo hi
split: horizontal
"#;
let config: super::Config = serde_yaml::from_str(yaml).unwrap();
let panes = config.panes.unwrap();
assert!(panes[0].zoom);
assert!(!panes[1].zoom);
}
#[test]
fn validate_layouts_invalid_first_pane_has_split() {
let mut layouts = HashMap::new();
layouts.insert(
"bad".to_string(),
LayoutConfig {
panes: vec![PaneConfig {
split: Some(SplitDirection::Horizontal),
..Default::default()
}],
},
);
let err = validate_layouts_config(&layouts).unwrap_err();
assert!(
err.to_string().contains("layout 'bad'"),
"error should mention layout name: {}",
err
);
}
#[test]
fn merge_layouts_project_extends_global() {
let global = Config {
layouts: Some(HashMap::from([(
"a".into(),
LayoutConfig { panes: vec![] },
)])),
..Default::default()
};
let project = Config {
layouts: Some(HashMap::from([(
"b".into(),
LayoutConfig { panes: vec![] },
)])),
..Default::default()
};
let merged = global.merge(project);
let layouts = merged.layouts.unwrap();
assert!(layouts.contains_key("a"));
assert!(layouts.contains_key("b"));
}
#[test]
fn merge_layouts_project_overrides_collision() {
let global = Config {
layouts: Some(HashMap::from([(
"shared".into(),
LayoutConfig {
panes: vec![PaneConfig {
command: Some("global-cmd".into()),
..Default::default()
}],
},
)])),
..Default::default()
};
let project = Config {
layouts: Some(HashMap::from([(
"shared".into(),
LayoutConfig {
panes: vec![PaneConfig {
command: Some("project-cmd".into()),
..Default::default()
}],
},
)])),
..Default::default()
};
let merged = global.merge(project);
let layouts = merged.layouts.unwrap();
assert_eq!(
layouts["shared"].panes[0].command.as_deref(),
Some("project-cmd")
);
}
#[test]
fn merge_layouts_global_used_when_project_has_none() {
let global = Config {
layouts: Some(HashMap::from([(
"a".into(),
LayoutConfig { panes: vec![] },
)])),
..Default::default()
};
let project = Config::default();
let merged = global.merge(project);
let layouts = merged.layouts.unwrap();
assert!(layouts.contains_key("a"));
}
}