Skip to main content

tmx/
config.rs

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    /// Naming pattern for the worktree directory.
19    /// Tokens: `{repo}` (source repo name), `{branch}` (branch name, slashes replaced with dashes).
20    #[serde(default)]
21    pub naming: String,
22    /// Map of source repo path → post-create shell command. Keys may use `~` for `$HOME`.
23    /// The value is passed to `bash -c` with the new worktree as the cwd and
24    /// `WORKTREE_PATH` exported, so it can be an inline command
25    /// (`"mise trust && yarn"`) or a script invocation (`"bash scripts/setup.sh"`).
26    #[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        // Overlay-merge onto defaults: a partial config (e.g. `[worktree] naming = "..."`
48        // with no `roots` key) must keep the default `~/dev` root. Parsing directly into
49        // `Config` would silently zero out absent fields via `#[serde(default)]`.
50        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
112/// Expand a leading `~` to `$HOME`. Other paths returned unchanged.
113pub 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        // Mirrors the Go LoadConfig behaviour: a config with only `[worktree]` but no
208        // `roots` key must keep the default `~/dev` root, not silently disable scanning.
209        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        // `roots = []` is an explicit user choice and must override defaults.
226        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}