1pub mod template;
2pub mod vars;
3
4use std::{
5 fmt, fs,
6 path::{Path, PathBuf},
7};
8
9use crate::error::{Error, Result};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum DeployMethod {
16 Symlink,
17 Copy,
18}
19
20impl DeployMethod {
21 pub fn as_str(self) -> &'static str {
22 match self {
23 Self::Symlink => "symlink",
24 Self::Copy => "copy",
25 }
26 }
27
28 fn parse(s: &str) -> Option<Self> {
29 match s.to_ascii_lowercase().as_str() {
30 "symlink" => Some(Self::Symlink),
31 "copy" => Some(Self::Copy),
32 _ => None,
33 }
34 }
35}
36
37impl fmt::Display for DeployMethod {
38 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39 f.write_str(self.as_str())
40 }
41}
42
43#[derive(Debug, Clone)]
45pub struct Entry {
46 pub source: String,
48 pub target: String,
50 pub method: Option<DeployMethod>,
52 pub encrypted: bool,
54 pub directory: bool,
56 pub template: bool,
58 pub os: Option<String>,
60 pub permissions: Option<u32>,
62 pub before: Option<String>,
64 pub after: Option<String>,
66}
67
68#[derive(Debug, Clone)]
70pub struct Settings {
71 pub method: DeployMethod,
73}
74
75impl Default for Settings {
76 fn default() -> Self {
77 Self {
78 method: DeployMethod::Symlink,
79 }
80 }
81}
82
83#[derive(Debug, Clone, Default)]
85pub struct Hooks {
86 pub init: Option<String>,
87 pub before: Option<String>,
88 pub after: Option<String>,
89}
90
91#[derive(Debug, Clone)]
93pub struct Config {
94 pub settings: Settings,
95 pub entries: Vec<Entry>,
96 pub hooks: Hooks,
97 pub vars: Vec<(String, String)>,
100 path: PathBuf,
102}
103
104impl Config {
105 pub fn new(path: PathBuf) -> Self {
107 Self {
108 settings: Settings::default(),
109 entries: Vec::new(),
110 hooks: Hooks::default(),
111 vars: Vec::new(),
112 path,
113 }
114 }
115
116 pub fn load(path: &Path) -> Result<Self> {
118 let content = fs::read_to_string(path).map_err(|e| Error::io(path, "read config", e))?;
119 let mut config = parse_config(&content, path)?;
120 config.path = path.to_path_buf();
121 Ok(config)
122 }
123
124 pub fn save(&self) -> Result<()> {
126 let content = serialize_config(self);
127 crate::fs::atomic_write(&self.path, content.as_bytes())
128 }
129
130 pub fn add_entry(&mut self, entry: Entry) -> Result<()> {
132 if self.entries.iter().any(|e| e.source == entry.source) {
133 return Err(Error::User(format!(
134 "`{}` is already tracked",
135 entry.source
136 )));
137 }
138 if self.entries.iter().any(|e| e.target == entry.target) {
139 return Err(Error::User(format!(
140 "target `{}` is already in use by `{}`",
141 entry.target,
142 self.entries
143 .iter()
144 .find(|e| e.target == entry.target)
145 .map_or("?", |e| e.source.as_str()),
146 )));
147 }
148 self.entries.push(entry);
149 Ok(())
150 }
151
152 pub fn remove_entry(&mut self, source: &str) -> Option<Entry> {
154 if let Some(i) = self.entries.iter().position(|e| e.source == source) {
155 Some(self.entries.remove(i))
156 } else {
157 None
158 }
159 }
160
161 pub fn find_entry(&self, query: &str) -> Option<&Entry> {
163 let resolved_query = crate::path::resolve(Path::new(query)).ok();
164 let repo_root = self.path.parent();
165
166 self.entries.iter().find(|e| {
167 if e.source == query || e.target == query {
168 return true;
169 }
170 if let Some(rq) = &resolved_query {
171 if let Ok(resolved_target) = crate::path::resolve(Path::new(&e.target)) {
172 if rq == &resolved_target {
173 return true;
174 }
175 }
176 if let Some(root) = repo_root {
177 if let Ok(resolved_source) = crate::path::resolve(&root.join(&e.source)) {
178 if rq == &resolved_source {
179 return true;
180 }
181 }
182 }
183 }
184 false
185 })
186 }
187
188 pub fn find_entry_mut(&mut self, query: &str) -> Option<&mut Entry> {
190 let resolved_query = crate::path::resolve(Path::new(query)).ok();
191 let repo_root = self.path.parent();
192
193 self.entries.iter_mut().find(|e| {
194 if e.source == query || e.target == query {
195 return true;
196 }
197 if let Some(rq) = &resolved_query {
198 if let Ok(resolved_target) = crate::path::resolve(Path::new(&e.target)) {
199 if rq == &resolved_target {
200 return true;
201 }
202 }
203 if let Some(root) = repo_root {
204 if let Ok(resolved_source) = crate::path::resolve(&root.join(&e.source)) {
205 if rq == &resolved_source {
206 return true;
207 }
208 }
209 }
210 }
211 false
212 })
213 }
214}
215
216fn parse_config(input: &str, path: &Path) -> Result<Config> {
220 let mut settings = Settings::default();
221 let mut entries = Vec::new();
222 let mut hooks = Hooks::default();
223 let mut vars: Vec<(String, String)> = Vec::new();
224
225 let mut current_section: Option<String> = None;
226 let mut current_entry: Option<EntryBuilder> = None;
227
228 for (line_num, raw_line) in input.lines().enumerate() {
229 let line_num = line_num + 1; let line = raw_line.split('#').next().unwrap_or("").trim();
231
232 if line.is_empty() {
233 continue;
234 }
235
236 if line.starts_with("[[") && line.ends_with("]]") {
238 if let Some(builder) = current_entry.take() {
240 entries.push(builder.build(path, line_num)?);
241 }
242 let name = &line[2..line.len() - 2].trim();
243 if *name == "entries" {
244 current_entry = Some(EntryBuilder::default());
245 current_section = Some("entries".into());
246 } else {
247 return Err(Error::Config {
248 message: format!("unknown section `[[{name}]]`"),
249 line: Some(line_num),
250 });
251 }
252 continue;
253 }
254
255 if line.starts_with('[') && line.ends_with(']') {
257 if let Some(builder) = current_entry.take() {
259 entries.push(builder.build(path, line_num)?);
260 }
261 let name = &line[1..line.len() - 1].trim();
262 current_section = Some((*name).to_string());
263 continue;
264 }
265
266 if let Some((key, value)) = parse_kv(line) {
268 if current_section.as_deref() == Some("vars") {
269 vars.push((key.to_string(), value));
270 } else {
271 handle_kv(
272 key,
273 &value,
274 current_section.as_deref(),
275 &mut settings,
276 &mut current_entry,
277 &mut hooks,
278 line_num,
279 )?;
280 }
281 }
282 }
283
284 if let Some(builder) = current_entry.take() {
286 entries.push(builder.build(path, input.lines().count())?);
287 }
288
289 Ok(Config {
290 settings,
291 entries,
292 hooks,
293 vars,
294 path: path.to_path_buf(),
295 })
296}
297
298#[allow(clippy::too_many_arguments)]
299fn handle_kv(
300 key: &str,
301 value: &str,
302 current_section: Option<&str>,
303 settings: &mut Settings,
304 current_entry: &mut Option<EntryBuilder>,
305 hooks: &mut Hooks,
306 line_num: usize,
307) -> Result<()> {
308 match current_section {
309 Some("settings") => match key {
310 "method" => {
311 settings.method = DeployMethod::parse(value).ok_or_else(|| Error::Config {
312 message: format!("invalid method `{value}`"),
313 line: Some(line_num),
314 })?;
315 }
316 _ => {
317 return Err(Error::Config {
318 message: format!("unknown setting `{key}`"),
319 line: Some(line_num),
320 });
321 }
322 },
323 Some("hooks") => match key {
324 "init" => hooks.init = Some(value.to_string()),
325 "before" => hooks.before = Some(value.to_string()),
326 "after" => hooks.after = Some(value.to_string()),
327 _ => {
328 return Err(Error::Config {
329 message: format!("unknown hook `{key}`"),
330 line: Some(line_num),
331 });
332 }
333 },
334 Some("entries") => {
335 let builder = current_entry.as_mut().ok_or_else(|| Error::Config {
336 message: "key-value outside [[entries]]".into(),
337 line: Some(line_num),
338 })?;
339 match key {
340 "source" => builder.source = Some(value.to_string()),
341 "target" => builder.target = Some(value.to_string()),
342 "method" => builder.method = Some(value.to_string()),
343 "encrypted" => builder.encrypted = parse_bool(value),
344 "directory" => builder.directory = parse_bool(value),
345 "template" => builder.template = parse_bool(value),
346 "os" => builder.os = Some(value.to_string()),
347 "permissions" => {
348 builder.permissions = u32::from_str_radix(value, 8).ok();
349 if builder.permissions.is_none() {
350 return Err(Error::Config {
351 message: format!("invalid permissions `{value}`"),
352 line: Some(line_num),
353 });
354 }
355 }
356 "before" => builder.before = Some(value.to_string()),
357 "after" => builder.after = Some(value.to_string()),
358 _ => {
359 return Err(Error::Config {
360 message: format!("unknown entry field `{key}`"),
361 line: Some(line_num),
362 });
363 }
364 }
365 }
366 _ => {}
367 }
368 Ok(())
369}
370
371#[derive(Default)]
372struct EntryBuilder {
373 source: Option<String>,
374 target: Option<String>,
375 method: Option<String>,
376 encrypted: bool,
377 directory: bool,
378 template: bool,
379 os: Option<String>,
380 permissions: Option<u32>,
381 before: Option<String>,
382 after: Option<String>,
383}
384
385impl EntryBuilder {
386 fn build(self, path: &Path, line: usize) -> Result<Entry> {
387 let source = self.source.ok_or_else(|| Error::Config {
388 message: "entry missing `source`".into(),
389 line: Some(line),
390 })?;
391 let target = self.target.ok_or_else(|| Error::Config {
392 message: format!("entry `{source}` missing `target`"),
393 line: Some(line),
394 })?;
395 let method = self
396 .method
397 .as_deref()
398 .map(|s| {
399 DeployMethod::parse(s).ok_or_else(|| Error::Config {
400 message: format!("invalid method `{s}` for entry `{source}`"),
401 line: Some(line),
402 })
403 })
404 .transpose()?;
405
406 let _ = path; Ok(Entry {
409 source: source.clone(),
410 target,
411 method,
412 encrypted: self.encrypted,
413 directory: self.directory,
414 template: self.template,
415 os: self.os,
416 permissions: self.permissions,
417 before: self.before,
418 after: self.after,
419 })
420 }
421}
422
423fn parse_kv(line: &str) -> Option<(&str, String)> {
425 let (key, rest) = line.split_once('=')?;
426 let key = key.trim();
427 let value = rest.trim();
428
429 let value = if (value.starts_with('"') && value.ends_with('"'))
431 || (value.starts_with('\'') && value.ends_with('\''))
432 {
433 unescape_string(&value[1..value.len() - 1])
434 } else {
435 value.to_string()
436 };
437
438 Some((key, value))
439}
440
441fn parse_bool(s: &str) -> bool {
443 matches!(s.to_ascii_lowercase().as_str(), "true" | "1" | "yes")
444}
445
446fn unescape_string(s: &str) -> String {
448 let mut result = String::with_capacity(s.len());
449 let mut chars = s.chars();
450 while let Some(c) = chars.next() {
451 if c == '\\' {
452 match chars.next() {
453 Some('n') => result.push('\n'),
454 Some('t') => result.push('\t'),
455 Some(ch @ ('\\' | '"')) => result.push(ch),
456 Some(other) => {
457 result.push('\\');
458 result.push(other);
459 }
460 None => result.push('\\'),
461 }
462 } else {
463 result.push(c);
464 }
465 }
466 result
467}
468
469fn serialize_config(config: &Config) -> String {
473 use std::fmt::Write;
474 let mut out = String::new();
475 let _ = writeln!(
476 out,
477 "# dotling.toml — managed by dotling, safe to hand-edit\n"
478 );
479
480 if config.settings.method != DeployMethod::Symlink {
482 let _ = writeln!(out, "[settings]");
483 let _ = writeln!(out, "method = \"{}\"\n", config.settings.method.as_str());
484 }
485
486 if config.hooks.init.is_some() || config.hooks.before.is_some() || config.hooks.after.is_some()
488 {
489 let _ = writeln!(out, "[hooks]");
490 if let Some(ref init) = config.hooks.init {
491 let _ = writeln!(out, "init = \"{}\"", escape_string(init));
492 }
493 if let Some(ref before) = config.hooks.before {
494 let _ = writeln!(out, "before = \"{}\"", escape_string(before));
495 }
496 if let Some(ref after) = config.hooks.after {
497 let _ = writeln!(out, "after = \"{}\"", escape_string(after));
498 }
499 let _ = writeln!(out);
500 }
501
502 if !config.vars.is_empty() {
504 let _ = writeln!(out, "[vars]");
505 let _ = writeln!(
506 out,
507 "# Shared defaults — override in ~/.dotling/vars.toml on each machine"
508 );
509 for (key, value) in &config.vars {
510 let _ = writeln!(out, "{key} = \"{}\"", escape_string(value));
511 }
512 let _ = writeln!(out);
513 }
514
515 for entry in &config.entries {
517 let _ = writeln!(out, "[[entries]]");
518 let _ = writeln!(out, "source = \"{}\"", escape_string(&entry.source));
519 let _ = writeln!(out, "target = \"{}\"", escape_string(&entry.target));
520
521 if let Some(method) = entry.method {
522 let _ = writeln!(out, "method = \"{}\"", method.as_str());
523 }
524 if entry.encrypted {
525 let _ = writeln!(out, "encrypted = true");
526 }
527 if entry.directory {
528 let _ = writeln!(out, "directory = true");
529 }
530 if entry.template {
531 let _ = writeln!(out, "template = true");
532 }
533 if let Some(ref os) = entry.os {
534 let _ = writeln!(out, "os = \"{os}\"");
535 }
536 if let Some(perms) = entry.permissions {
537 let _ = writeln!(out, "permissions = \"{perms:04o}\"");
538 }
539 if let Some(ref before) = entry.before {
540 let _ = writeln!(out, "before = \"{}\"", escape_string(before));
541 }
542 if let Some(ref after) = entry.after {
543 let _ = writeln!(out, "after = \"{}\"", escape_string(after));
544 }
545 let _ = writeln!(out);
546 }
547
548 out
549}
550
551fn escape_string(s: &str) -> String {
553 s.replace('\\', "\\\\")
554 .replace('"', "\\\"")
555 .replace('\n', "\\n")
556 .replace('\t', "\\t")
557}
558
559#[cfg(test)]
562mod tests {
563 use super::*;
564
565 #[test]
566 fn parse_empty_config() {
567 let config = parse_config("", Path::new("test.toml")).unwrap();
568 assert!(config.entries.is_empty());
569 assert_eq!(config.settings.method, DeployMethod::Symlink);
570 }
571
572 #[test]
573 fn parse_basic_config() {
574 let input = r#"
575# dotling.toml
576
577[settings]
578method = "symlink"
579
580[[entries]]
581source = "shell/zshrc"
582target = "~/.zshrc"
583
584[[entries]]
585source = "config/nvim"
586target = "~/.config/nvim"
587directory = true
588method = "copy"
589os = "macos"
590"#;
591
592 let config = parse_config(input, Path::new("test.toml")).unwrap();
593 assert_eq!(config.settings.method, DeployMethod::Symlink);
594 assert_eq!(config.entries.len(), 2);
595
596 assert_eq!(config.entries[0].source, "shell/zshrc");
597 assert_eq!(config.entries[0].target, "~/.zshrc");
598 assert!(!config.entries[0].directory);
599 assert!(config.entries[0].method.is_none());
600
601 assert_eq!(config.entries[1].source, "config/nvim");
602 assert_eq!(config.entries[1].target, "~/.config/nvim");
603 assert!(config.entries[1].directory);
604 assert_eq!(config.entries[1].method, Some(DeployMethod::Copy));
605 assert_eq!(config.entries[1].os.as_deref(), Some("macos"));
606 }
607
608 #[test]
609 fn serialize_roundtrip() {
610 let config = Config {
611 settings: Settings {
612 method: DeployMethod::Symlink,
613 },
614 entries: vec![
615 Entry {
616 source: "shell/zshrc".into(),
617 target: "~/.zshrc".into(),
618 method: None,
619 encrypted: false,
620 directory: false,
621 template: false,
622 os: None,
623 permissions: None,
624 before: Some("echo 'entry before'".into()),
625 after: Some("echo 'entry after'".into()),
626 },
627 Entry {
628 source: "config/nvim".into(),
629 target: "~/.config/nvim".into(),
630 method: Some(DeployMethod::Copy),
631 encrypted: true,
632 directory: true,
633 template: false,
634 os: Some("linux".into()),
635 permissions: Some(0o600),
636 before: None,
637 after: None,
638 },
639 ],
640 hooks: Hooks {
641 init: Some("echo 'init'".into()),
642 before: Some("echo 'global before'".into()),
643 after: Some("echo 'global after'".into()),
644 },
645 vars: vec![],
646 path: PathBuf::from("test.toml"),
647 };
648
649 let serialized = serialize_config(&config);
650 let parsed = parse_config(&serialized, Path::new("test.toml")).unwrap();
651
652 assert_eq!(parsed.entries.len(), 2);
653 assert_eq!(parsed.entries[0].source, "shell/zshrc");
654 assert_eq!(
655 parsed.entries[0].before.as_deref(),
656 Some("echo 'entry before'")
657 );
658 assert_eq!(
659 parsed.entries[0].after.as_deref(),
660 Some("echo 'entry after'")
661 );
662 assert!(parsed.entries[1].encrypted);
663 assert!(parsed.entries[1].directory);
664 assert_eq!(parsed.entries[1].permissions, Some(0o600));
665 assert_eq!(parsed.hooks.init.as_deref(), Some("echo 'init'"));
666 assert_eq!(parsed.hooks.before.as_deref(), Some("echo 'global before'"));
667 assert_eq!(parsed.hooks.after.as_deref(), Some("echo 'global after'"));
668 }
669
670 #[test]
671 fn duplicate_source_rejected() {
672 let mut config = Config::new(PathBuf::from("test.toml"));
673 config
674 .add_entry(Entry {
675 source: "a".into(),
676 target: "~/.a".into(),
677 method: None,
678 encrypted: false,
679 directory: false,
680 template: false,
681 os: None,
682 permissions: None,
683 before: None,
684 after: None,
685 })
686 .unwrap();
687
688 let err = config
689 .add_entry(Entry {
690 source: "a".into(),
691 target: "~/.b".into(),
692 method: None,
693 encrypted: false,
694 directory: false,
695 template: false,
696 os: None,
697 permissions: None,
698 before: None,
699 after: None,
700 })
701 .unwrap_err();
702
703 assert!(err.to_string().contains("already tracked"));
704 }
705
706 #[test]
707 fn duplicate_target_rejected() {
708 let mut config = Config::new(PathBuf::from("test.toml"));
709 config
710 .add_entry(Entry {
711 source: "a".into(),
712 target: "~/.a".into(),
713 method: None,
714 encrypted: false,
715 directory: false,
716 template: false,
717 os: None,
718 permissions: None,
719 before: None,
720 after: None,
721 })
722 .unwrap();
723
724 let err = config
725 .add_entry(Entry {
726 source: "b".into(),
727 target: "~/.a".into(),
728 method: None,
729 encrypted: false,
730 directory: false,
731 template: false,
732 os: None,
733 permissions: None,
734 before: None,
735 after: None,
736 })
737 .unwrap_err();
738
739 assert!(err.to_string().contains("already in use"));
740 }
741
742 #[test]
743 fn find_by_source_or_target() {
744 let mut config = Config::new(PathBuf::from("test.toml"));
745 config
746 .add_entry(Entry {
747 source: "shell/zshrc".into(),
748 target: "~/.zshrc".into(),
749 method: None,
750 encrypted: false,
751 directory: false,
752 template: false,
753 os: None,
754 permissions: None,
755 before: None,
756 after: None,
757 })
758 .unwrap();
759
760 assert!(config.find_entry("shell/zshrc").is_some());
761 assert!(config.find_entry("~/.zshrc").is_some());
762 assert!(config.find_entry("nope").is_none());
763 }
764
765 #[test]
766 fn remove_entry() {
767 let mut config = Config::new(PathBuf::from("test.toml"));
768 config
769 .add_entry(Entry {
770 source: "a".into(),
771 target: "~/.a".into(),
772 method: None,
773 encrypted: false,
774 directory: false,
775 template: false,
776 os: None,
777 permissions: None,
778 before: None,
779 after: None,
780 })
781 .unwrap();
782
783 assert!(config.remove_entry("a").is_some());
784 assert!(config.entries.is_empty());
785 assert!(config.remove_entry("a").is_none());
786 }
787}