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 #[config(nested)]
42 pub preprocessor: PreprocessorSection,
43}
44
45#[derive(Config, Debug, Clone, Serialize, Deserialize)]
47pub struct PackSection {
48 #[config(default = [
51 ".git", ".svn", ".hg", "node_modules", ".DS_Store",
52 "*.swp", "*~", "#*#", ".env*", ".terraform"
53 ])]
54 pub ignore: Vec<String>,
55}
56
57#[derive(Config, Debug, Clone, Serialize, Deserialize)]
59pub struct SymlinkSection {
60 #[config(default = ["ssh", "aws", "kube", "bashrc", "zshrc", "profile", "bash_profile", "bash_login", "bash_logout", "inputrc"])]
64 pub force_home: Vec<String>,
65
66 #[config(default = [
68 ".ssh/id_rsa", ".ssh/id_ed25519", ".ssh/id_dsa", ".ssh/id_ecdsa",
69 ".ssh/authorized_keys", ".gnupg", ".aws/credentials",
70 ".password-store", ".config/gh/hosts.yml",
71 ".kube/config", ".docker/config.json"
72 ])]
73 pub protected_paths: Vec<String>,
74
75 #[config(default = {})]
80 pub targets: std::collections::HashMap<String, String>,
81}
82
83#[derive(Config, Debug, Clone, Serialize, Deserialize)]
85pub struct PathSection {
86 #[config(default = true)]
113 pub auto_chmod_exec: bool,
114}
115
116#[derive(Config, Debug, Clone, Serialize, Deserialize)]
118pub struct PreprocessorSection {
119 #[config(default = true)]
121 pub enabled: bool,
122
123 #[config(nested)]
124 pub template: PreprocessorTemplateSection,
125}
126
127#[derive(Config, Debug, Clone, Serialize, Deserialize)]
129pub struct PreprocessorTemplateSection {
130 #[config(default = ["tmpl", "template"])]
133 pub extensions: Vec<String>,
134
135 #[config(default = {})]
141 pub vars: std::collections::HashMap<String, String>,
142}
143
144#[derive(Config, Debug, Clone, Serialize, Deserialize)]
146pub struct MappingsSection {
147 #[config(default = "bin")]
149 pub path: String,
150
151 #[config(default = "install.sh")]
153 pub install: String,
154
155 #[config(default = ["aliases.sh", "profile.sh", "login.sh", "env.sh"])]
157 pub shell: Vec<String>,
158
159 #[config(default = "Brewfile")]
161 pub homebrew: String,
162
163 #[config(default = [])]
166 pub skip: Vec<String>,
167}
168
169impl DodotConfig {
172 pub fn to_handler_config(&self) -> HandlerConfig {
174 HandlerConfig {
175 force_home: self.symlink.force_home.clone(),
176 protected_paths: self.symlink.protected_paths.clone(),
177 targets: self.symlink.targets.clone(),
178 auto_chmod_exec: self.path.auto_chmod_exec,
179 pack_ignore: self.pack.ignore.clone(),
180 }
181 }
182}
183
184pub fn mappings_to_rules(mappings: &MappingsSection) -> Vec<Rule> {
189 use std::collections::HashMap;
190
191 let mut rules = Vec::new();
192
193 if !mappings.path.is_empty() {
195 let pattern = if mappings.path.ends_with('/') {
196 mappings.path.clone()
197 } else {
198 format!("{}/", mappings.path)
199 };
200 rules.push(Rule {
201 pattern,
202 handler: "path".into(),
203 priority: 10,
204 options: HashMap::new(),
205 });
206 }
207
208 if !mappings.install.is_empty() {
210 rules.push(Rule {
211 pattern: mappings.install.clone(),
212 handler: "install".into(),
213 priority: 10,
214 options: HashMap::new(),
215 });
216 }
217
218 for pattern in &mappings.shell {
220 if !pattern.is_empty() {
221 rules.push(Rule {
222 pattern: pattern.clone(),
223 handler: "shell".into(),
224 priority: 10,
225 options: HashMap::new(),
226 });
227 }
228 }
229
230 if !mappings.homebrew.is_empty() {
232 rules.push(Rule {
233 pattern: mappings.homebrew.clone(),
234 handler: "homebrew".into(),
235 priority: 10,
236 options: HashMap::new(),
237 });
238 }
239
240 for pattern in &mappings.skip {
242 if !pattern.is_empty() {
243 rules.push(Rule {
244 pattern: format!("!{pattern}"),
245 handler: "exclude".into(),
246 priority: 100, options: HashMap::new(),
248 });
249 }
250 }
251
252 rules.push(Rule {
254 pattern: "*".into(),
255 handler: "symlink".into(),
256 priority: 0,
257 options: HashMap::new(),
258 });
259
260 rules
261}
262
263pub struct ConfigManager {
271 resolver: clapfig::Resolver<DodotConfig>,
272 dotfiles_root: PathBuf,
273}
274
275impl ConfigManager {
276 pub fn new(dotfiles_root: &Path) -> Result<Self> {
283 let resolver = Clapfig::builder::<DodotConfig>()
284 .app_name("dodot")
285 .file_name(".dodot.toml")
286 .search_paths(vec![SearchPath::Ancestors(Boundary::Marker(".git"))])
287 .search_mode(SearchMode::Merge)
288 .no_env()
289 .build_resolver()
290 .map_err(|e| DodotError::Config(format!("failed to build config resolver: {e}")))?;
291
292 Ok(Self {
293 resolver,
294 dotfiles_root: dotfiles_root.to_path_buf(),
295 })
296 }
297
298 pub fn root_config(&self) -> Result<DodotConfig> {
300 self.resolver
301 .resolve_at(&self.dotfiles_root)
302 .map_err(|e| DodotError::Config(format!("failed to load root config: {e}")))
303 }
304
305 pub fn config_for_pack(&self, pack_path: &Path) -> Result<DodotConfig> {
311 self.resolver
312 .resolve_at(pack_path)
313 .map_err(|e| DodotError::Config(format!("failed to load pack config: {e}")))
314 }
315
316 pub fn dotfiles_root(&self) -> &Path {
317 &self.dotfiles_root
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use crate::fs::Fs;
325 use crate::testing::TempEnvironment;
326
327 #[test]
328 fn default_config_has_expected_values() {
329 let env = TempEnvironment::builder().build();
331 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
332 let cfg = mgr.root_config().unwrap();
333
334 let expected_ignore: Vec<String> = vec![
336 ".git",
337 ".svn",
338 ".hg",
339 "node_modules",
340 ".DS_Store",
341 "*.swp",
342 "*~",
343 "#*#",
344 ".env*",
345 ".terraform",
346 ]
347 .into_iter()
348 .map(Into::into)
349 .collect();
350 assert_eq!(cfg.pack.ignore, expected_ignore);
351
352 let expected_force_home: Vec<String> = vec![
354 "ssh",
355 "aws",
356 "kube",
357 "bashrc",
358 "zshrc",
359 "profile",
360 "bash_profile",
361 "bash_login",
362 "bash_logout",
363 "inputrc",
364 ]
365 .into_iter()
366 .map(Into::into)
367 .collect();
368 assert_eq!(cfg.symlink.force_home, expected_force_home);
369
370 let expected_protected: Vec<String> = vec![
372 ".ssh/id_rsa",
373 ".ssh/id_ed25519",
374 ".ssh/id_dsa",
375 ".ssh/id_ecdsa",
376 ".ssh/authorized_keys",
377 ".gnupg",
378 ".aws/credentials",
379 ".password-store",
380 ".config/gh/hosts.yml",
381 ".kube/config",
382 ".docker/config.json",
383 ]
384 .into_iter()
385 .map(Into::into)
386 .collect();
387 assert_eq!(cfg.symlink.protected_paths, expected_protected);
388
389 assert!(cfg.symlink.targets.is_empty());
391
392 assert!(cfg.path.auto_chmod_exec);
394
395 assert_eq!(cfg.mappings.path, "bin");
397 assert_eq!(cfg.mappings.install, "install.sh");
398 assert_eq!(cfg.mappings.homebrew, "Brewfile");
399 assert_eq!(
400 cfg.mappings.shell,
401 vec!["aliases.sh", "profile.sh", "login.sh", "env.sh"]
402 );
403 assert!(cfg.mappings.skip.is_empty());
404 }
405
406 #[test]
407 fn root_config_overrides_defaults() {
408 let env = TempEnvironment::builder().build();
409
410 env.fs
412 .write_file(
413 &env.dotfiles_root.join(".dodot.toml"),
414 br#"
415[mappings]
416install = "setup.sh"
417homebrew = "MyBrewfile"
418"#,
419 )
420 .unwrap();
421
422 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
423 let cfg = mgr.root_config().unwrap();
424
425 assert_eq!(cfg.mappings.install, "setup.sh");
426 assert_eq!(cfg.mappings.homebrew, "MyBrewfile");
427 assert_eq!(cfg.mappings.path, "bin");
429 }
430
431 #[test]
432 fn pack_config_overrides_root() {
433 let env = TempEnvironment::builder()
434 .pack("vim")
435 .file("vimrc", "x")
436 .config(
437 r#"
438[pack]
439ignore = ["*.bak"]
440
441[mappings]
442install = "vim-setup.sh"
443"#,
444 )
445 .done()
446 .build();
447
448 env.fs
450 .write_file(
451 &env.dotfiles_root.join(".dodot.toml"),
452 br#"
453[mappings]
454install = "install.sh"
455homebrew = "RootBrewfile"
456"#,
457 )
458 .unwrap();
459
460 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
461
462 let root_cfg = mgr.root_config().unwrap();
464 assert_eq!(root_cfg.mappings.install, "install.sh");
465
466 let pack_path = env.dotfiles_root.join("vim");
468 let pack_cfg = mgr.config_for_pack(&pack_path).unwrap();
469 assert_eq!(pack_cfg.mappings.install, "vim-setup.sh"); assert_eq!(pack_cfg.mappings.homebrew, "RootBrewfile"); assert_eq!(pack_cfg.pack.ignore, vec!["*.bak"]); }
473
474 #[test]
475 fn mappings_to_rules_produces_expected_rules() {
476 let mappings = MappingsSection {
477 path: "bin".into(),
478 install: "install.sh".into(),
479 shell: vec!["aliases.sh".into(), "profile.sh".into()],
480 homebrew: "Brewfile".into(),
481 skip: vec!["*.tmp".into()],
482 };
483
484 let rules = mappings_to_rules(&mappings);
485
486 assert_eq!(rules.len(), 7, "rules: {rules:#?}");
488
489 let handler_names: Vec<&str> = rules.iter().map(|r| r.handler.as_str()).collect();
490 assert!(handler_names.contains(&"path"));
491 assert!(handler_names.contains(&"install"));
492 assert!(handler_names.contains(&"shell"));
493 assert!(handler_names.contains(&"homebrew"));
494 assert!(handler_names.contains(&"exclude"));
495 assert!(handler_names.contains(&"symlink"));
496
497 let exclude = rules.iter().find(|r| r.handler == "exclude").unwrap();
499 assert!(exclude.pattern.starts_with('!'));
500
501 let catchall = rules.iter().find(|r| r.pattern == "*").unwrap();
503 assert_eq!(catchall.priority, 0);
504 }
505
506 #[test]
507 fn to_handler_config_converts_correctly() {
508 let env = TempEnvironment::builder().build();
509 let mgr = ConfigManager::new(&env.dotfiles_root).unwrap();
510 let cfg = mgr.root_config().unwrap();
511
512 let hcfg = cfg.to_handler_config();
513 assert_eq!(hcfg.force_home, cfg.symlink.force_home);
514 assert_eq!(hcfg.protected_paths, cfg.symlink.protected_paths);
515 }
516}