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 script. Keys may use `~` for `$HOME`.
23    /// Script paths: relative → resolved against the new worktree directory;
24    /// absolute / `~`-prefixed used as-is.
25    #[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        // Overlay-merge onto defaults: a partial config (e.g. `[worktree] naming = "..."`
47        // with no `roots` key) must keep the default `~/dev` root. Parsing directly into
48        // `Config` would silently zero out absent fields via `#[serde(default)]`.
49        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
111/// Expand a leading `~` to `$HOME`. Other paths returned unchanged.
112pub 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        // Mirrors the Go LoadConfig behaviour: a config with only `[worktree]` but no
207        // `roots` key must keep the default `~/dev` root, not silently disable scanning.
208        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        // `roots = []` is an explicit user choice and must override defaults.
225        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}