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