use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Deserialize, Serialize)]
pub struct Config {
#[serde(default = "default_tool")]
pub default_tool: String,
pub verticals: HashMap<String, Vertical>,
#[serde(default)]
pub remotes: HashMap<String, Remote>,
#[serde(default)]
pub hooks: Hooks,
}
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct Hooks {
#[serde(default)]
pub pre_create: Vec<String>,
#[serde(default)]
pub path: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Vertical {
pub dir: String,
pub color: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Remote {
pub project: String,
pub zone: String,
pub user: String,
pub color: String,
#[serde(default = "default_connect")]
pub connect: String,
#[serde(default)]
pub instance_prefix: Option<String>,
}
fn default_tool() -> String {
"claude".to_string()
}
fn default_connect() -> String {
"mosh".to_string()
}
impl Remote {
pub fn instance_name(&self, context: &str) -> String {
let slug = context.replace('/', "-");
match &self.instance_prefix {
Some(prefix) => format!("{prefix}{slug}"),
None => slug,
}
}
}
impl Config {
pub fn load() -> Result<Self> {
let path = Self::path()?;
if !path.exists() {
anyhow::bail!(
"No config found at {}\nRun `muxr init` to create one.",
path.display()
);
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let config: Config = toml::from_str(&content)
.with_context(|| format!("Failed to parse {}", path.display()))?;
for name in config.remotes.keys() {
if config.verticals.contains_key(name) {
anyhow::bail!(
"Name collision: '{name}' is defined as both a vertical and a remote"
);
}
}
Ok(config)
}
pub fn path() -> Result<PathBuf> {
let home = dirs::home_dir().context("Could not determine home directory")?;
let config_dir = home.join(".config").join("muxr");
Ok(config_dir.join("config.toml"))
}
pub fn state_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("Could not determine home directory")?;
let config_dir = home.join(".config").join("muxr");
Ok(config_dir.join("state.json"))
}
pub fn resolve_dir(&self, vertical: &str) -> Result<PathBuf> {
let v = self
.verticals
.get(vertical)
.with_context(|| format!("Unknown vertical: {vertical}"))?;
let expanded = shellexpand::tilde(&v.dir);
Ok(PathBuf::from(expanded.as_ref()))
}
pub fn all_names(&self) -> Vec<&str> {
let mut names: Vec<&str> = self
.verticals
.keys()
.chain(self.remotes.keys())
.map(|s| s.as_str())
.collect();
names.sort();
names.dedup();
names
}
pub fn is_remote(&self, name: &str) -> bool {
self.remotes.contains_key(name)
}
pub fn remote(&self, name: &str) -> Option<&Remote> {
self.remotes.get(name)
}
pub fn run_pre_create_hooks(&self, dir: &std::path::Path) {
if self.hooks.pre_create.is_empty() {
return;
}
let path = self.hooks_path();
for cmd in &self.hooks.pre_create {
eprintln!(" hook: {cmd}");
let result = std::process::Command::new("sh")
.args(["-c", cmd])
.current_dir(dir)
.env("PATH", &path)
.status();
match result {
Ok(s) if !s.success() => eprintln!(" hook warning: {cmd} exited {s}"),
Err(e) => eprintln!(" hook warning: {cmd} failed: {e}"),
_ => {}
}
}
}
fn hooks_path(&self) -> String {
let system = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
if self.hooks.path.is_empty() {
std::env::var("PATH").unwrap_or_else(|_| system.to_string())
} else {
let expanded: Vec<String> = self
.hooks
.path
.iter()
.map(|p| shellexpand::tilde(p).to_string())
.collect();
format!("{}:{}", expanded.join(":"), system)
}
}
pub fn color_for(&self, name: &str) -> &str {
self.verticals
.get(name)
.map(|v| v.color.as_str())
.or_else(|| self.remotes.get(name).map(|r| r.color.as_str()))
.unwrap_or("#8a7f83")
}
pub fn default_template() -> String {
let example = Config {
default_tool: default_tool(),
verticals: HashMap::new(),
remotes: HashMap::new(),
hooks: Hooks::default(),
};
let base = toml::to_string_pretty(&example).unwrap_or_default();
format!(
r##"# muxr configuration
# Verticals define your project estates.
# Each vertical maps to a directory and a status bar color.
{base}
# Add your verticals here. Examples:
#
# [verticals.work]
# dir = "~/projects/work"
# color = "#7aa2f7"
#
# [verticals.personal]
# dir = "~/projects/personal"
# color = "#9ece6a"
"##
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_config() -> Config {
let toml_str = r##"
default_tool = "claude"
[verticals.work]
dir = "~/projects/work"
color = "#7aa2f7"
[verticals.personal]
dir = "~/projects/personal"
color = "#9ece6a"
[remotes.lab]
project = "my-project"
zone = "us-central1-a"
user = "deploy"
color = "#d29922"
"##;
toml::from_str(toml_str).unwrap()
}
#[test]
fn parse_valid_config() {
let config = sample_config();
assert_eq!(config.default_tool, "claude");
assert_eq!(config.verticals.len(), 2);
assert_eq!(config.remotes.len(), 1);
}
#[test]
fn default_tool_is_claude() {
let config: Config = toml::from_str("[verticals]").unwrap();
assert_eq!(config.default_tool, "claude");
}
#[test]
fn default_connect_is_mosh() {
let config = sample_config();
let lab = config.remotes.get("lab").unwrap();
assert_eq!(lab.connect, "mosh");
}
#[test]
fn all_names_sorted_and_deduped() {
let config = sample_config();
let names = config.all_names();
assert_eq!(names, vec!["lab", "personal", "work"]);
}
#[test]
fn is_remote_distinguishes() {
let config = sample_config();
assert!(config.is_remote("lab"));
assert!(!config.is_remote("work"));
assert!(!config.is_remote("nonexistent"));
}
#[test]
fn color_for_vertical() {
let config = sample_config();
assert_eq!(config.color_for("work"), "#7aa2f7");
}
#[test]
fn color_for_remote() {
let config = sample_config();
assert_eq!(config.color_for("lab"), "#d29922");
}
#[test]
fn color_for_unknown_returns_default() {
let config = sample_config();
assert_eq!(config.color_for("nonexistent"), "#8a7f83");
}
#[test]
fn instance_name_simple() {
let remote = Remote {
project: "p".into(),
zone: "z".into(),
user: "u".into(),
color: "#fff".into(),
connect: "mosh".into(),
instance_prefix: None,
};
assert_eq!(remote.instance_name("bootc"), "bootc");
}
#[test]
fn instance_name_with_prefix() {
let remote = Remote {
project: "p".into(),
zone: "z".into(),
user: "u".into(),
color: "#fff".into(),
connect: "mosh".into(),
instance_prefix: Some("lab-".into()),
};
assert_eq!(remote.instance_name("bootc"), "lab-bootc");
}
#[test]
fn instance_name_replaces_slashes() {
let remote = Remote {
project: "p".into(),
zone: "z".into(),
user: "u".into(),
color: "#fff".into(),
connect: "mosh".into(),
instance_prefix: None,
};
assert_eq!(remote.instance_name("api/auth"), "api-auth");
}
#[test]
fn name_collision_rejected() {
let toml_str = r##"
[verticals.lab]
dir = "~/lab"
color = "#fff"
[remotes.lab]
project = "p"
zone = "z"
user = "u"
color = "#fff"
"##;
let config: Config = toml::from_str(toml_str).unwrap();
let has_collision = config
.remotes
.keys()
.any(|name| config.verticals.contains_key(name));
assert!(has_collision);
}
#[test]
fn hooks_default_empty() {
let config: Config = toml::from_str("[verticals]").unwrap();
assert!(config.hooks.pre_create.is_empty());
assert!(config.hooks.path.is_empty());
}
#[test]
fn default_template_contains_default_tool() {
let template = Config::default_template();
assert!(template.contains("default_tool = \"claude\""));
}
#[test]
fn default_template_parseable() {
let template = Config::default_template();
let non_comment: String = template
.lines()
.filter(|l| !l.starts_with('#'))
.collect::<Vec<_>>()
.join("\n");
let _config: Config = toml::from_str(&non_comment).unwrap();
}
#[test]
fn hooks_parsed() {
let toml_str = r##"
[verticals]
[hooks]
pre_create = ["mise install"]
path = ["~/.local/share/mise/shims"]
"##;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.hooks.pre_create, vec!["mise install"]);
assert_eq!(config.hooks.path, vec!["~/.local/share/mise/shims"]);
}
}