1use std::path::{Path, PathBuf};
14
15use clapfig::{Boundary, Clapfig, SearchMode, SearchPath};
16use confique::Config;
17use serde::{Deserialize, Serialize};
18
19use crate::handlers::HandlerConfig;
20use crate::rules::Rule;
21use crate::{DodotError, Result};
22
23#[derive(Config, Debug, Clone, Serialize, Deserialize)]
28pub struct DodotConfig {
29 #[config(nested)]
30 pub pack: PackSection,
31
32 #[config(nested)]
33 pub symlink: SymlinkSection,
34
35 #[config(nested)]
36 pub path: PathSection,
37
38 #[config(nested)]
39 pub mappings: MappingsSection,
40}
41
42#[derive(Config, Debug, Clone, Serialize, Deserialize)]
44pub struct PackSection {
45 #[config(default = [
48 ".git", ".svn", ".hg", "node_modules", ".DS_Store",
49 "*.swp", "*~", "#*#", ".env*", ".terraform"
50 ])]
51 pub ignore: Vec<String>,
52}
53
54#[derive(Config, Debug, Clone, Serialize, Deserialize)]
56pub struct SymlinkSection {
57 #[config(default = ["ssh", "aws", "kube", "bashrc", "zshrc", "profile", "bash_profile", "bash_login", "bash_logout", "inputrc"])]
61 pub force_home: Vec<String>,
62
63 #[config(default = [
65 ".ssh/id_rsa", ".ssh/id_ed25519", ".ssh/id_dsa", ".ssh/id_ecdsa",
66 ".ssh/authorized_keys", ".gnupg", ".aws/credentials",
67 ".password-store", ".config/gh/hosts.yml",
68 ".kube/config", ".docker/config.json"
69 ])]
70 pub protected_paths: Vec<String>,
71
72 #[config(default = {})]
77 pub targets: std::collections::HashMap<String, String>,
78}
79
80#[derive(Config, Debug, Clone, Serialize, Deserialize)]
82pub struct PathSection {
83 #[config(default = true)]
110 pub auto_chmod_exec: bool,
111}
112
113#[derive(Config, Debug, Clone, Serialize, Deserialize)]
115pub struct MappingsSection {
116 #[config(default = "bin")]
118 pub path: String,
119
120 #[config(default = "install.sh")]
122 pub install: String,
123
124 #[config(default = ["aliases.sh", "profile.sh", "login.sh"])]
126 pub shell: Vec<String>,
127
128 #[config(default = "Brewfile")]
130 pub homebrew: String,
131
132 #[config(default = [])]
135 pub skip: Vec<String>,
136}
137
138impl DodotConfig {
141 pub fn to_handler_config(&self) -> HandlerConfig {
143 HandlerConfig {
144 force_home: self.symlink.force_home.clone(),
145 protected_paths: self.symlink.protected_paths.clone(),
146 targets: self.symlink.targets.clone(),
147 auto_chmod_exec: self.path.auto_chmod_exec,
148 }
149 }
150}
151
152pub fn mappings_to_rules(mappings: &MappingsSection) -> Vec<Rule> {
157 use std::collections::HashMap;
158
159 let mut rules = Vec::new();
160
161 if !mappings.path.is_empty() {
163 let pattern = if mappings.path.ends_with('/') {
164 mappings.path.clone()
165 } else {
166 format!("{}/", mappings.path)
167 };
168 rules.push(Rule {
169 pattern,
170 handler: "path".into(),
171 priority: 10,
172 options: HashMap::new(),
173 });
174 }
175
176 if !mappings.install.is_empty() {
178 rules.push(Rule {
179 pattern: mappings.install.clone(),
180 handler: "install".into(),
181 priority: 10,
182 options: HashMap::new(),
183 });
184 }
185
186 for pattern in &mappings.shell {
188 if !pattern.is_empty() {
189 rules.push(Rule {
190 pattern: pattern.clone(),
191 handler: "shell".into(),
192 priority: 10,
193 options: HashMap::new(),
194 });
195 }
196 }
197
198 if !mappings.homebrew.is_empty() {
200 rules.push(Rule {
201 pattern: mappings.homebrew.clone(),
202 handler: "homebrew".into(),
203 priority: 10,
204 options: HashMap::new(),
205 });
206 }
207
208 for pattern in &mappings.skip {
210 if !pattern.is_empty() {
211 rules.push(Rule {
212 pattern: format!("!{pattern}"),
213 handler: "exclude".into(),
214 priority: 100, options: HashMap::new(),
216 });
217 }
218 }
219
220 rules.push(Rule {
222 pattern: "*".into(),
223 handler: "symlink".into(),
224 priority: 0,
225 options: HashMap::new(),
226 });
227
228 rules
229}
230
231pub struct ConfigManager {
239 resolver: clapfig::Resolver<DodotConfig>,
240 dotfiles_root: PathBuf,
241}
242
243impl ConfigManager {
244 pub fn new(dotfiles_root: &Path) -> Result<Self> {
251 let resolver = Clapfig::builder::<DodotConfig>()
252 .app_name("dodot")
253 .file_name(".dodot.toml")
254 .search_paths(vec![SearchPath::Ancestors(Boundary::Marker(".git"))])
255 .search_mode(SearchMode::Merge)
256 .no_env()
257 .build_resolver()
258 .map_err(|e| DodotError::Config(format!("failed to build config resolver: {e}")))?;
259
260 Ok(Self {
261 resolver,
262 dotfiles_root: dotfiles_root.to_path_buf(),
263 })
264 }
265
266 pub fn root_config(&self) -> Result<DodotConfig> {
268 self.resolver
269 .resolve_at(&self.dotfiles_root)
270 .map_err(|e| DodotError::Config(format!("failed to load root config: {e}")))
271 }
272
273 pub fn config_for_pack(&self, pack_path: &Path) -> Result<DodotConfig> {
279 self.resolver
280 .resolve_at(pack_path)
281 .map_err(|e| DodotError::Config(format!("failed to load pack config: {e}")))
282 }
283
284 pub fn dotfiles_root(&self) -> &Path {
285 &self.dotfiles_root
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::fs::Fs;
293 use crate::testing::TempEnvironment;
294
295 #[test]
296 fn default_config_has_expected_values() {
297 let env = TempEnvironment::builder().build();
299 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
300 let cfg = mgr.root_config().unwrap();
301
302 let expected_ignore: Vec<String> = vec![
304 ".git",
305 ".svn",
306 ".hg",
307 "node_modules",
308 ".DS_Store",
309 "*.swp",
310 "*~",
311 "#*#",
312 ".env*",
313 ".terraform",
314 ]
315 .into_iter()
316 .map(Into::into)
317 .collect();
318 assert_eq!(cfg.pack.ignore, expected_ignore);
319
320 let expected_force_home: Vec<String> = vec![
322 "ssh",
323 "aws",
324 "kube",
325 "bashrc",
326 "zshrc",
327 "profile",
328 "bash_profile",
329 "bash_login",
330 "bash_logout",
331 "inputrc",
332 ]
333 .into_iter()
334 .map(Into::into)
335 .collect();
336 assert_eq!(cfg.symlink.force_home, expected_force_home);
337
338 let expected_protected: Vec<String> = vec![
340 ".ssh/id_rsa",
341 ".ssh/id_ed25519",
342 ".ssh/id_dsa",
343 ".ssh/id_ecdsa",
344 ".ssh/authorized_keys",
345 ".gnupg",
346 ".aws/credentials",
347 ".password-store",
348 ".config/gh/hosts.yml",
349 ".kube/config",
350 ".docker/config.json",
351 ]
352 .into_iter()
353 .map(Into::into)
354 .collect();
355 assert_eq!(cfg.symlink.protected_paths, expected_protected);
356
357 assert!(cfg.symlink.targets.is_empty());
359
360 assert!(cfg.path.auto_chmod_exec);
362
363 assert_eq!(cfg.mappings.path, "bin");
365 assert_eq!(cfg.mappings.install, "install.sh");
366 assert_eq!(cfg.mappings.homebrew, "Brewfile");
367 assert_eq!(
368 cfg.mappings.shell,
369 vec!["aliases.sh", "profile.sh", "login.sh"]
370 );
371 assert!(cfg.mappings.skip.is_empty());
372 }
373
374 #[test]
375 fn root_config_overrides_defaults() {
376 let env = TempEnvironment::builder().build();
377
378 env.fs
380 .write_file(
381 &env.dotfiles_root.join(".dodot.toml"),
382 br#"
383[mappings]
384install = "setup.sh"
385homebrew = "MyBrewfile"
386"#,
387 )
388 .unwrap();
389
390 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
391 let cfg = mgr.root_config().unwrap();
392
393 assert_eq!(cfg.mappings.install, "setup.sh");
394 assert_eq!(cfg.mappings.homebrew, "MyBrewfile");
395 assert_eq!(cfg.mappings.path, "bin");
397 }
398
399 #[test]
400 fn pack_config_overrides_root() {
401 let env = TempEnvironment::builder()
402 .pack("vim")
403 .file("vimrc", "x")
404 .config(
405 r#"
406[pack]
407ignore = ["*.bak"]
408
409[mappings]
410install = "vim-setup.sh"
411"#,
412 )
413 .done()
414 .build();
415
416 env.fs
418 .write_file(
419 &env.dotfiles_root.join(".dodot.toml"),
420 br#"
421[mappings]
422install = "install.sh"
423homebrew = "RootBrewfile"
424"#,
425 )
426 .unwrap();
427
428 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
429
430 let root_cfg = mgr.root_config().unwrap();
432 assert_eq!(root_cfg.mappings.install, "install.sh");
433
434 let pack_path = env.dotfiles_root.join("vim");
436 let pack_cfg = mgr.config_for_pack(&pack_path).unwrap();
437 assert_eq!(pack_cfg.mappings.install, "vim-setup.sh"); assert_eq!(pack_cfg.mappings.homebrew, "RootBrewfile"); assert_eq!(pack_cfg.pack.ignore, vec!["*.bak"]); }
441
442 #[test]
443 fn mappings_to_rules_produces_expected_rules() {
444 let mappings = MappingsSection {
445 path: "bin".into(),
446 install: "install.sh".into(),
447 shell: vec!["aliases.sh".into(), "profile.sh".into()],
448 homebrew: "Brewfile".into(),
449 skip: vec!["*.tmp".into()],
450 };
451
452 let rules = mappings_to_rules(&mappings);
453
454 assert_eq!(rules.len(), 7, "rules: {rules:#?}");
456
457 let handler_names: Vec<&str> = rules.iter().map(|r| r.handler.as_str()).collect();
458 assert!(handler_names.contains(&"path"));
459 assert!(handler_names.contains(&"install"));
460 assert!(handler_names.contains(&"shell"));
461 assert!(handler_names.contains(&"homebrew"));
462 assert!(handler_names.contains(&"exclude"));
463 assert!(handler_names.contains(&"symlink"));
464
465 let exclude = rules.iter().find(|r| r.handler == "exclude").unwrap();
467 assert!(exclude.pattern.starts_with('!'));
468
469 let catchall = rules.iter().find(|r| r.pattern == "*").unwrap();
471 assert_eq!(catchall.priority, 0);
472 }
473
474 #[test]
475 fn to_handler_config_converts_correctly() {
476 let env = TempEnvironment::builder().build();
477 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
478 let cfg = mgr.root_config().unwrap();
479
480 let hcfg = cfg.to_handler_config();
481 assert_eq!(hcfg.force_home, cfg.symlink.force_home);
482 assert_eq!(hcfg.protected_paths, cfg.symlink.protected_paths);
483 }
484}