Skip to main content

tam_worktree/
config.rs

1use anyhow::Result;
2use serde::Deserialize;
3use std::path::PathBuf;
4
5/// Raw TOML config file shape — all fields optional.
6#[derive(Debug, Deserialize, Default)]
7pub struct ConfigFile {
8    /// Discovery settings (`[discovery]` section).
9    pub discovery: Option<DiscoveryConfig>,
10    /// Worktree settings (`[worktree]` section).
11    pub worktree: Option<WorktreeConfig>,
12}
13
14/// TOML `[discovery]` section.
15#[derive(Debug, Deserialize)]
16pub struct DiscoveryConfig {
17    /// Maximum directory depth when scanning for git projects.
18    pub max_depth: Option<usize>,
19    /// Glob patterns for directories to skip during discovery.
20    pub ignore: Option<Vec<String>>,
21}
22
23/// TOML `[worktree]` section.
24#[derive(Debug, Deserialize)]
25pub struct WorktreeConfig {
26    /// Parent directory where new worktrees are created.
27    pub root: Option<String>,
28    /// Whether to run `.tam.toml` after creating a worktree.
29    pub auto_init: Option<bool>,
30}
31
32/// Resolved configuration with defaults applied.
33#[derive(Debug)]
34pub struct Config {
35    /// Maximum directory depth when scanning for git projects.
36    pub max_depth: usize,
37    /// Glob patterns for directories to skip during discovery.
38    pub ignore: Vec<String>,
39    /// Parent directory where new worktrees are created.
40    pub worktree_root: PathBuf,
41    /// Whether to run `.tam.toml` after creating a worktree.
42    pub auto_init: bool,
43}
44
45impl Default for Config {
46    fn default() -> Self {
47        Self {
48            max_depth: 5,
49            ignore: vec![".*".to_string(), "node_modules".to_string()],
50            worktree_root: dirs::home_dir()
51                .unwrap_or_else(|| PathBuf::from("/"))
52                .join("worktrees"),
53            auto_init: false,
54        }
55    }
56}
57
58/// Expand a leading `~` to the user's home directory.
59pub fn expand_tilde(path: &str) -> PathBuf {
60    if let Some(rest) = path.strip_prefix("~/") {
61        dirs::home_dir()
62            .unwrap_or_else(|| PathBuf::from("/"))
63            .join(rest)
64    } else if path == "~" {
65        dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
66    } else {
67        PathBuf::from(path)
68    }
69}
70
71/// Parse a TOML string into a resolved Config.
72pub fn parse_config(toml_str: &str) -> Result<Config> {
73    let file: ConfigFile = toml::from_str(toml_str)?;
74    let defaults = Config::default();
75
76    let max_depth = file
77        .discovery
78        .as_ref()
79        .and_then(|d| d.max_depth)
80        .unwrap_or(defaults.max_depth);
81
82    let ignore = file
83        .discovery
84        .as_ref()
85        .and_then(|d| d.ignore.clone())
86        .unwrap_or(defaults.ignore);
87
88    let worktree_root = file
89        .worktree
90        .as_ref()
91        .and_then(|w| w.root.as_ref())
92        .map(|r| expand_tilde(r))
93        .unwrap_or(defaults.worktree_root);
94
95    let auto_init = file
96        .worktree
97        .as_ref()
98        .and_then(|w| w.auto_init)
99        .unwrap_or(defaults.auto_init);
100
101    Ok(Config {
102        max_depth,
103        ignore,
104        worktree_root,
105        auto_init,
106    })
107}
108
109/// Load config from `$XDG_CONFIG_HOME/tam/config.toml`.
110pub fn load_config() -> Result<Config> {
111    let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("/"));
112    let tam_config = config_dir.join("tam").join("config.toml");
113
114    if tam_config.exists() {
115        let content = std::fs::read_to_string(&tam_config)?;
116        parse_config(&content)
117    } else {
118        Ok(Config::default())
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn test_defaults() {
128        let config = Config::default();
129        assert_eq!(config.max_depth, 5);
130        assert_eq!(config.ignore, vec![".*", "node_modules"]);
131        assert!(config.worktree_root.ends_with("worktrees"));
132    }
133
134    #[test]
135    fn test_parse_empty_toml() {
136        let config = parse_config("").unwrap();
137        assert_eq!(config.max_depth, 5);
138        assert_eq!(config.ignore, vec![".*", "node_modules"]);
139    }
140
141    #[test]
142    fn test_parse_partial_discovery() {
143        let toml = r#"
144[discovery]
145max_depth = 3
146"#;
147        let config = parse_config(toml).unwrap();
148        assert_eq!(config.max_depth, 3);
149        // ignore should still be default since it wasn't specified
150        assert_eq!(config.ignore, vec![".*", "node_modules"]);
151    }
152
153    #[test]
154    fn test_parse_custom_ignore() {
155        let toml = r#"
156[discovery]
157ignore = [".*", "node_modules", "target", "vendor"]
158"#;
159        let config = parse_config(toml).unwrap();
160        assert_eq!(
161            config.ignore,
162            vec![".*", "node_modules", "target", "vendor"]
163        );
164    }
165
166    #[test]
167    fn test_parse_worktree_root_tilde() {
168        let toml = r#"
169[worktree]
170root = "~/my-worktrees"
171"#;
172        let config = parse_config(toml).unwrap();
173        let home = dirs::home_dir().unwrap();
174        assert_eq!(config.worktree_root, home.join("my-worktrees"));
175    }
176
177    #[test]
178    fn test_parse_worktree_root_absolute() {
179        let toml = r#"
180[worktree]
181root = "/tmp/worktrees"
182"#;
183        let config = parse_config(toml).unwrap();
184        assert_eq!(config.worktree_root, PathBuf::from("/tmp/worktrees"));
185    }
186
187    #[test]
188    fn test_parse_full_config() {
189        let toml = r#"
190[discovery]
191max_depth = 10
192ignore = [".*"]
193
194[worktree]
195root = "/opt/worktrees"
196"#;
197        let config = parse_config(toml).unwrap();
198        assert_eq!(config.max_depth, 10);
199        assert_eq!(config.ignore, vec![".*"]);
200        assert_eq!(config.worktree_root, PathBuf::from("/opt/worktrees"));
201    }
202
203    #[test]
204    fn test_parse_auto_init() {
205        let toml = r#"
206[worktree]
207auto_init = true
208"#;
209        let config = parse_config(toml).unwrap();
210        assert!(config.auto_init);
211    }
212
213    #[test]
214    fn test_parse_auto_init_default() {
215        let config = parse_config("").unwrap();
216        assert!(!config.auto_init);
217    }
218
219    #[test]
220    fn test_parse_invalid_toml() {
221        let result = parse_config("this is not valid toml {{{}}}");
222        assert!(result.is_err());
223    }
224
225    #[test]
226    fn test_expand_tilde_with_path() {
227        let home = dirs::home_dir().unwrap();
228        assert_eq!(expand_tilde("~/foo/bar"), home.join("foo/bar"));
229    }
230
231    #[test]
232    fn test_expand_tilde_bare() {
233        let home = dirs::home_dir().unwrap();
234        assert_eq!(expand_tilde("~"), home);
235    }
236
237    #[test]
238    fn test_expand_tilde_absolute_passthrough() {
239        assert_eq!(expand_tilde("/tmp/foo"), PathBuf::from("/tmp/foo"));
240    }
241
242    #[test]
243    fn test_expand_tilde_relative_passthrough() {
244        assert_eq!(expand_tilde("foo/bar"), PathBuf::from("foo/bar"));
245    }
246}