1use anyhow::Result;
2use serde::Deserialize;
3use std::path::PathBuf;
4
5#[derive(Debug, Deserialize, Default)]
7pub struct ConfigFile {
8 pub discovery: Option<DiscoveryConfig>,
10 pub worktree: Option<WorktreeConfig>,
12}
13
14#[derive(Debug, Deserialize)]
16pub struct DiscoveryConfig {
17 pub max_depth: Option<usize>,
19 pub ignore: Option<Vec<String>>,
21}
22
23#[derive(Debug, Deserialize)]
25pub struct WorktreeConfig {
26 pub root: Option<String>,
28 pub auto_init: Option<bool>,
30}
31
32#[derive(Debug)]
34pub struct Config {
35 pub max_depth: usize,
37 pub ignore: Vec<String>,
39 pub worktree_root: PathBuf,
41 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
58pub 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
71pub 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
109pub 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 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}