1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6const DEFAULT_NAMING: &str = "{repo}-{branch}";
7
8#[derive(Debug, Clone, Default, Serialize, Deserialize)]
9pub struct Config {
10 #[serde(default)]
11 pub roots: Vec<String>,
12 #[serde(default)]
13 pub worktree: WorktreeConfig,
14}
15
16#[derive(Debug, Clone, Default, Serialize, Deserialize)]
17pub struct WorktreeConfig {
18 #[serde(default)]
21 pub naming: String,
22 #[serde(default)]
27 pub scripts: HashMap<String, String>,
28}
29
30impl Config {
31 pub fn defaults() -> Self {
32 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
33 Self {
34 roots: vec![home.join("dev").to_string_lossy().into_owned()],
35 worktree: WorktreeConfig {
36 naming: DEFAULT_NAMING.to_string(),
37 scripts: HashMap::new(),
38 },
39 }
40 }
41
42 pub fn load() -> Self {
43 Self::load_from(&config_path())
44 }
45
46 pub fn load_from(path: &Path) -> Self {
47 let Ok(data) = std::fs::read_to_string(path) else {
51 return Self::defaults();
52 };
53 let Ok(partial) = toml::from_str::<PartialConfig>(&data) else {
54 return Self::defaults();
55 };
56
57 let mut cfg = Self::defaults();
58 if let Some(roots) = partial.roots {
59 cfg.roots = roots;
60 }
61 if let Some(wt) = partial.worktree {
62 if let Some(naming) = wt.naming {
63 cfg.worktree.naming = naming;
64 }
65 if let Some(scripts) = wt.scripts {
66 cfg.worktree.scripts = scripts;
67 }
68 }
69
70 if cfg.worktree.naming.is_empty() {
71 cfg.worktree.naming = DEFAULT_NAMING.to_string();
72 }
73
74 cfg.roots = cfg.roots.iter().map(|p| expand_home(p)).collect();
75 cfg.worktree.scripts = cfg
76 .worktree
77 .scripts
78 .iter()
79 .map(|(k, v)| (expand_home(k), v.clone()))
80 .collect();
81
82 cfg
83 }
84}
85
86#[derive(Debug, Default, Deserialize)]
87struct PartialConfig {
88 roots: Option<Vec<String>>,
89 worktree: Option<PartialWorktreeConfig>,
90}
91
92#[derive(Debug, Default, Deserialize)]
93struct PartialWorktreeConfig {
94 naming: Option<String>,
95 scripts: Option<HashMap<String, String>>,
96}
97
98pub fn config_path() -> PathBuf {
99 dirs::home_dir()
100 .unwrap_or_else(|| PathBuf::from("."))
101 .join(".config")
102 .join("tmx")
103 .join("config.toml")
104}
105
106pub fn ensure_config_dir() {
107 if let Some(dir) = config_path().parent() {
108 std::fs::create_dir_all(dir).ok();
109 }
110}
111
112pub fn expand_home(input: &str) -> String {
114 if !input.starts_with('~') {
115 return input.to_string();
116 }
117 let Some(home) = dirs::home_dir() else {
118 return input.to_string();
119 };
120 if input == "~" {
121 return home.to_string_lossy().into_owned();
122 }
123 if let Some(rest) = input.strip_prefix("~/") {
124 return home.join(rest).to_string_lossy().into_owned();
125 }
126 input.to_string()
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use tempfile::TempDir;
133
134 #[test]
135 fn expand_home_passes_through_non_tilde() {
136 assert_eq!(expand_home("/abs/path"), "/abs/path");
137 assert_eq!(expand_home("rel/path"), "rel/path");
138 assert_eq!(expand_home(""), "");
139 }
140
141 #[test]
142 fn expand_home_expands_tilde_prefix() {
143 let home = dirs::home_dir().unwrap();
144 assert_eq!(expand_home("~"), home.to_string_lossy());
145 assert_eq!(
146 expand_home("~/foo/bar"),
147 home.join("foo/bar").to_string_lossy()
148 );
149 }
150
151 #[test]
152 fn expand_home_does_not_expand_tilde_user() {
153 assert_eq!(expand_home("~someone/x"), "~someone/x");
154 }
155
156 #[test]
157 fn load_from_missing_returns_defaults() {
158 let dir = TempDir::new().unwrap();
159 let cfg = Config::load_from(&dir.path().join("nope.toml"));
160 assert!(!cfg.roots.is_empty());
161 assert_eq!(cfg.worktree.naming, DEFAULT_NAMING);
162 }
163
164 #[test]
165 fn load_from_invalid_toml_returns_defaults() {
166 let dir = TempDir::new().unwrap();
167 let path = dir.path().join("config.toml");
168 std::fs::write(&path, "this is = not = valid").unwrap();
169 let cfg = Config::load_from(&path);
170 assert_eq!(cfg.worktree.naming, DEFAULT_NAMING);
171 }
172
173 #[test]
174 fn load_from_valid_toml_parses_and_expands() {
175 let dir = TempDir::new().unwrap();
176 let path = dir.path().join("config.toml");
177 std::fs::write(
178 &path,
179 r#"
180roots = ["~/code", "/opt/projects"]
181
182[worktree]
183naming = "{repo}_{branch}"
184
185[worktree.scripts]
186"~/code/myrepo" = "scripts/setup.sh"
187"#,
188 )
189 .unwrap();
190
191 let cfg = Config::load_from(&path);
192 let home = dirs::home_dir().unwrap();
193
194 assert_eq!(cfg.worktree.naming, "{repo}_{branch}");
195 assert_eq!(cfg.roots.len(), 2);
196 assert_eq!(cfg.roots[0], home.join("code").to_string_lossy());
197 assert_eq!(cfg.roots[1], "/opt/projects");
198 let key = home.join("code/myrepo").to_string_lossy().into_owned();
199 assert_eq!(
200 cfg.worktree.scripts.get(&key).map(String::as_str),
201 Some("scripts/setup.sh")
202 );
203 }
204
205 #[test]
206 fn load_from_partial_config_preserves_default_roots() {
207 let dir = TempDir::new().unwrap();
210 let path = dir.path().join("config.toml");
211 std::fs::write(&path, "[worktree]\nnaming = \"wt_{repo}_{branch}\"\n").unwrap();
212
213 let cfg = Config::load_from(&path);
214 assert_eq!(cfg.worktree.naming, "wt_{repo}_{branch}");
215
216 let defaults = Config::defaults();
217 assert_eq!(
218 cfg.roots, defaults.roots,
219 "partial config dropped the default roots"
220 );
221 }
222
223 #[test]
224 fn load_from_explicit_empty_roots_overrides_defaults() {
225 let dir = TempDir::new().unwrap();
227 let path = dir.path().join("config.toml");
228 std::fs::write(&path, "roots = []\n").unwrap();
229
230 let cfg = Config::load_from(&path);
231 assert!(cfg.roots.is_empty());
232 }
233
234 #[test]
235 fn load_from_empty_naming_falls_back_to_default() {
236 let dir = TempDir::new().unwrap();
237 let path = dir.path().join("config.toml");
238 std::fs::write(&path, "roots = []\n[worktree]\nnaming = \"\"\n").unwrap();
239 let cfg = Config::load_from(&path);
240 assert_eq!(cfg.worktree.naming, DEFAULT_NAMING);
241 }
242}