use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
const DEFAULT_NAMING: &str = "{repo}-{branch}";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub roots: Vec<String>,
#[serde(default)]
pub worktree: WorktreeConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorktreeConfig {
#[serde(default)]
pub naming: String,
#[serde(default)]
pub scripts: HashMap<String, String>,
}
impl Config {
pub fn defaults() -> Self {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
Self {
roots: vec![home.join("dev").to_string_lossy().into_owned()],
worktree: WorktreeConfig {
naming: DEFAULT_NAMING.to_string(),
scripts: HashMap::new(),
},
}
}
pub fn load() -> Self {
Self::load_from(&config_path())
}
pub fn load_from(path: &Path) -> Self {
let Ok(data) = std::fs::read_to_string(path) else {
return Self::defaults();
};
let Ok(partial) = toml::from_str::<PartialConfig>(&data) else {
return Self::defaults();
};
let mut cfg = Self::defaults();
if let Some(roots) = partial.roots {
cfg.roots = roots;
}
if let Some(wt) = partial.worktree {
if let Some(naming) = wt.naming {
cfg.worktree.naming = naming;
}
if let Some(scripts) = wt.scripts {
cfg.worktree.scripts = scripts;
}
}
if cfg.worktree.naming.is_empty() {
cfg.worktree.naming = DEFAULT_NAMING.to_string();
}
cfg.roots = cfg.roots.iter().map(|p| expand_home(p)).collect();
cfg.worktree.scripts = cfg
.worktree
.scripts
.iter()
.map(|(k, v)| (expand_home(k), v.clone()))
.collect();
cfg
}
}
#[derive(Debug, Default, Deserialize)]
struct PartialConfig {
roots: Option<Vec<String>>,
worktree: Option<PartialWorktreeConfig>,
}
#[derive(Debug, Default, Deserialize)]
struct PartialWorktreeConfig {
naming: Option<String>,
scripts: Option<HashMap<String, String>>,
}
pub fn config_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config")
.join("tmx")
.join("config.toml")
}
pub fn ensure_config_dir() {
if let Some(dir) = config_path().parent() {
std::fs::create_dir_all(dir).ok();
}
}
pub fn expand_home(input: &str) -> String {
if !input.starts_with('~') {
return input.to_string();
}
let Some(home) = dirs::home_dir() else {
return input.to_string();
};
if input == "~" {
return home.to_string_lossy().into_owned();
}
if let Some(rest) = input.strip_prefix("~/") {
return home.join(rest).to_string_lossy().into_owned();
}
input.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn expand_home_passes_through_non_tilde() {
assert_eq!(expand_home("/abs/path"), "/abs/path");
assert_eq!(expand_home("rel/path"), "rel/path");
assert_eq!(expand_home(""), "");
}
#[test]
fn expand_home_expands_tilde_prefix() {
let home = dirs::home_dir().unwrap();
assert_eq!(expand_home("~"), home.to_string_lossy());
assert_eq!(
expand_home("~/foo/bar"),
home.join("foo/bar").to_string_lossy()
);
}
#[test]
fn expand_home_does_not_expand_tilde_user() {
assert_eq!(expand_home("~someone/x"), "~someone/x");
}
#[test]
fn load_from_missing_returns_defaults() {
let dir = TempDir::new().unwrap();
let cfg = Config::load_from(&dir.path().join("nope.toml"));
assert!(!cfg.roots.is_empty());
assert_eq!(cfg.worktree.naming, DEFAULT_NAMING);
}
#[test]
fn load_from_invalid_toml_returns_defaults() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "this is = not = valid").unwrap();
let cfg = Config::load_from(&path);
assert_eq!(cfg.worktree.naming, DEFAULT_NAMING);
}
#[test]
fn load_from_valid_toml_parses_and_expands() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
roots = ["~/code", "/opt/projects"]
[worktree]
naming = "{repo}_{branch}"
[worktree.scripts]
"~/code/myrepo" = "scripts/setup.sh"
"#,
)
.unwrap();
let cfg = Config::load_from(&path);
let home = dirs::home_dir().unwrap();
assert_eq!(cfg.worktree.naming, "{repo}_{branch}");
assert_eq!(cfg.roots.len(), 2);
assert_eq!(cfg.roots[0], home.join("code").to_string_lossy());
assert_eq!(cfg.roots[1], "/opt/projects");
let key = home.join("code/myrepo").to_string_lossy().into_owned();
assert_eq!(
cfg.worktree.scripts.get(&key).map(String::as_str),
Some("scripts/setup.sh")
);
}
#[test]
fn load_from_partial_config_preserves_default_roots() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[worktree]\nnaming = \"wt_{repo}_{branch}\"\n").unwrap();
let cfg = Config::load_from(&path);
assert_eq!(cfg.worktree.naming, "wt_{repo}_{branch}");
let defaults = Config::defaults();
assert_eq!(
cfg.roots, defaults.roots,
"partial config dropped the default roots"
);
}
#[test]
fn load_from_explicit_empty_roots_overrides_defaults() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "roots = []\n").unwrap();
let cfg = Config::load_from(&path);
assert!(cfg.roots.is_empty());
}
#[test]
fn load_from_empty_naming_falls_back_to_default() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "roots = []\n[worktree]\nnaming = \"\"\n").unwrap();
let cfg = Config::load_from(&path);
assert_eq!(cfg.worktree.naming, DEFAULT_NAMING);
}
}