Skip to main content

dotling/config/
mod.rs

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// ── Data model ────────────────────────────────────────────────────
12
13/// How an entry is deployed to the filesystem.
14#[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/// A single tracked dotfile entry.
44#[derive(Debug, Clone)]
45pub struct Entry {
46    /// Repo-relative source path (e.g., `shell/zshrc`).
47    pub source: String,
48    /// Deploy target path (e.g., `~/.zshrc`).
49    pub target: String,
50    /// Deploy method override (uses repo default if `None`).
51    pub method: Option<DeployMethod>,
52    /// Whether this entry is encrypted.
53    pub encrypted: bool,
54    /// Whether this is a directory entry.
55    pub directory: bool,
56    /// Whether this is a template entry, explicitly set in TOML config.
57    pub template: bool,
58    /// OS restriction (e.g., `"linux"`, `"macos"`). `None` means all.
59    pub os: Option<String>,
60    /// File permissions as an octal u32 (e.g. 0o600).
61    pub permissions: Option<u32>,
62    /// Command to run before syncing this entry.
63    pub before: Option<String>,
64    /// Command to run after syncing this entry.
65    pub after: Option<String>,
66}
67
68/// Repo-level settings.
69#[derive(Debug, Clone)]
70pub struct Settings {
71    /// Default deploy method for entries without an explicit override.
72    pub method: DeployMethod,
73}
74
75impl Default for Settings {
76    fn default() -> Self {
77        Self {
78            method: DeployMethod::Symlink,
79        }
80    }
81}
82
83/// Global lifecycle hooks.
84#[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/// The top-level configuration stored in `dotling.toml`.
92#[derive(Debug, Clone)]
93pub struct Config {
94    pub settings: Settings,
95    pub entries: Vec<Entry>,
96    pub hooks: Hooks,
97    /// Shared variable defaults from `[vars]` (committed to git).
98    /// Machine-local overrides live in `~/.dotling/vars.toml`.
99    pub vars: Vec<(String, String)>,
100    /// Path to the config file itself.
101    path: PathBuf,
102}
103
104impl Config {
105    /// Create a new, empty config.
106    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    /// Load config from a file.
117    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    /// Save config to its file.
125    pub fn save(&self) -> Result<()> {
126        let content = serialize_config(self);
127        crate::fs::atomic_write(&self.path, content.as_bytes())
128    }
129
130    /// Add an entry. Returns an error if the source already exists.
131    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    /// Remove an entry by source path. Returns the removed entry.
153    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    /// Find an entry by source path or target path.
162    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    /// Find an entry mutably by source path or target path.
189    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
216// ── TOML parser (minimal subset) ──────────────────────────────────
217
218/// Parse a dotling.toml config string.
219fn 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; // 1-indexed
230        let line = raw_line.split('#').next().unwrap_or("").trim();
231
232        if line.is_empty() {
233            continue;
234        }
235
236        // Array-of-tables: [[entries]]
237        if line.starts_with("[[") && line.ends_with("]]") {
238            // Flush previous entry
239            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        // Table: [section]
256        if line.starts_with('[') && line.ends_with(']') {
257            // Flush previous entry
258            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        // Key-value pair
267        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    // Flush last entry
285    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; // Silence unused warning
407
408        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
423/// Parse a `key = value` line.
424fn 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    // Strip quotes
430    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
441/// Parse a boolean value.
442fn parse_bool(s: &str) -> bool {
443    matches!(s.to_ascii_lowercase().as_str(), "true" | "1" | "yes")
444}
445
446/// Unescape basic TOML string escapes.
447fn 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
469// ── TOML serializer ───────────────────────────────────────────────
470
471/// Serialize a config to TOML.
472fn 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    // [settings]
481    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    // [hooks]
487    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    // [vars] — shared defaults (non-sensitive)
503    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    // [[entries]]
516    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
551/// Escape a string for TOML output.
552fn escape_string(s: &str) -> String {
553    s.replace('\\', "\\\\")
554        .replace('"', "\\\"")
555        .replace('\n', "\\n")
556        .replace('\t', "\\t")
557}
558
559// ── Tests ─────────────────────────────────────────────────────────
560
561#[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}