Skip to main content

yui/
config.rs

1//! TOML schema for yui configuration.
2//!
3//! Loading flow:
4//!   1. list `config.toml` + `config.*.toml` (alphabetical) + `config.local.toml` (last)
5//!   2. for each file: Tera-render with `yui.*` + `env(…)` + accumulated `vars.*`
6//!      from prior files → parse TOML → merge into accumulator (deep merge,
7//!      arrays append).
8//!   3. deserialize the final merged table into `Config`.
9//!
10//! Note: a file cannot reference its own `[vars]` keys from non-`[vars]`
11//! sections (the file is rendered before its own vars are accumulated).
12//! Use prior files in merge order if you need cross-section references.
13
14use camino::{Utf8Path, Utf8PathBuf};
15use serde::Deserialize;
16
17use crate::vars::YuiVars;
18use crate::{Error, Result, template};
19
20#[derive(Debug, Deserialize, Default)]
21pub struct Config {
22    #[serde(default)]
23    pub vars: toml::Table,
24
25    #[serde(default)]
26    pub link: LinkConfig,
27
28    #[serde(default)]
29    pub mount: MountConfig,
30
31    #[serde(default)]
32    pub absorb: AbsorbConfig,
33
34    #[serde(default)]
35    pub render: RenderConfig,
36
37    #[serde(default)]
38    pub backup: BackupConfig,
39}
40
41#[derive(Debug, Deserialize, Default)]
42pub struct LinkConfig {
43    #[serde(default)]
44    pub file_mode: FileLinkMode,
45    #[serde(default)]
46    pub dir_mode: DirLinkMode,
47}
48
49#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
50#[serde(rename_all = "lowercase")]
51pub enum FileLinkMode {
52    #[default]
53    Auto,
54    Symlink,
55    Hardlink,
56}
57
58#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
59#[serde(rename_all = "lowercase")]
60pub enum DirLinkMode {
61    #[default]
62    Auto,
63    Symlink,
64    Junction,
65}
66
67#[derive(Debug, Deserialize)]
68pub struct MountConfig {
69    #[serde(default)]
70    pub default_strategy: MountStrategy,
71    #[serde(default = "default_marker_filename")]
72    pub marker_filename: String,
73    #[serde(default)]
74    pub entry: Vec<MountEntry>,
75}
76
77impl Default for MountConfig {
78    fn default() -> Self {
79        Self {
80            default_strategy: MountStrategy::default(),
81            marker_filename: default_marker_filename(),
82            entry: Vec::new(),
83        }
84    }
85}
86
87fn default_marker_filename() -> String {
88    ".yuilink".to_string()
89}
90
91#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
92#[serde(rename_all = "kebab-case")]
93pub enum MountStrategy {
94    #[default]
95    Marker,
96    PerFile,
97}
98
99#[derive(Debug, Deserialize)]
100pub struct MountEntry {
101    pub src: Utf8PathBuf,
102    pub dst: String,
103    #[serde(default)]
104    pub when: Option<String>,
105    #[serde(default)]
106    pub strategy: Option<MountStrategy>,
107}
108
109#[derive(Debug, Deserialize)]
110pub struct AbsorbConfig {
111    #[serde(default = "default_true")]
112    pub auto: bool,
113    #[serde(default = "default_true")]
114    pub require_clean_git: bool,
115    #[serde(default)]
116    pub on_anomaly: AnomalyAction,
117}
118
119impl Default for AbsorbConfig {
120    fn default() -> Self {
121        Self {
122            auto: true,
123            require_clean_git: true,
124            on_anomaly: AnomalyAction::default(),
125        }
126    }
127}
128
129#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
130#[serde(rename_all = "lowercase")]
131pub enum AnomalyAction {
132    #[default]
133    Ask,
134    Skip,
135    Force,
136}
137
138#[derive(Debug, Deserialize)]
139pub struct RenderConfig {
140    #[serde(default = "default_true")]
141    pub manage_gitignore: bool,
142    #[serde(default)]
143    pub rule: Vec<RenderRule>,
144}
145
146impl Default for RenderConfig {
147    fn default() -> Self {
148        Self {
149            manage_gitignore: true,
150            rule: Vec::new(),
151        }
152    }
153}
154
155#[derive(Debug, Deserialize)]
156pub struct RenderRule {
157    pub r#match: String,
158    #[serde(default)]
159    pub when: Option<String>,
160}
161
162#[derive(Debug, Deserialize)]
163pub struct BackupConfig {
164    #[serde(default = "default_backup_dir")]
165    pub dir: String,
166    #[serde(default = "default_ts_format")]
167    pub timestamp_format: String,
168}
169
170impl Default for BackupConfig {
171    fn default() -> Self {
172        Self {
173            dir: default_backup_dir(),
174            timestamp_format: default_ts_format(),
175        }
176    }
177}
178
179fn default_backup_dir() -> String {
180    ".yui/backup".to_string()
181}
182
183fn default_ts_format() -> String {
184    "%Y%m%d_%H%M%S%3f".to_string()
185}
186
187fn default_true() -> bool {
188    true
189}
190
191/// Load + merge config files from `$DOTFILES`.
192pub fn load(source: &Utf8Path, yui: &YuiVars) -> Result<Config> {
193    let files = list_config_files(source)?;
194    if files.is_empty() {
195        return Err(Error::Config(format!(
196            "no config.toml / config.*.toml found at {source}"
197        )));
198    }
199
200    let mut engine = template::Engine::new();
201    let mut merged = toml::Table::new();
202    let mut vars_acc = toml::Table::new();
203
204    for file in &files {
205        let raw = std::fs::read_to_string(file)
206            .map_err(|e| Error::Config(format!("read {file}: {e}")))?;
207        let ctx = template::template_context(yui, &vars_acc);
208        let rendered = engine.render(&raw, &ctx)?;
209        let parsed: toml::Table =
210            toml::from_str(&rendered).map_err(|e| Error::Config(format!("parse {file}: {e}")))?;
211
212        if let Some(toml::Value::Table(file_vars)) = parsed.get("vars") {
213            deep_merge_table(&mut vars_acc, file_vars.clone());
214        }
215        deep_merge_table(&mut merged, parsed);
216    }
217
218    let cfg: Config = toml::Value::Table(merged)
219        .try_into()
220        .map_err(|e| Error::Config(format!("schema: {e}")))?;
221    Ok(cfg)
222}
223
224/// List config files in merge order:
225///   `config.toml` (rank 0)
226/// → `config.*.toml` alphabetically (rank 1, excluding `config.local.toml`)
227/// → `config.local.toml` (rank 2, last/highest priority)
228fn list_config_files(source: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
229    let entries =
230        std::fs::read_dir(source).map_err(|e| Error::Config(format!("read_dir {source}: {e}")))?;
231    let mut files: Vec<Utf8PathBuf> = Vec::new();
232    for entry in entries {
233        let entry = entry.map_err(Error::Io)?;
234        let name_os = entry.file_name();
235        let Some(name) = name_os.to_str() else {
236            continue;
237        };
238        let is_match = name == "config.toml"
239            || (name.starts_with("config.") && name.ends_with(".toml") && name.len() > 12);
240        if !is_match {
241            continue;
242        }
243        let path = Utf8PathBuf::from_path_buf(entry.path())
244            .map_err(|p| Error::Config(format!("non-UTF8 config path: {}", p.display())))?;
245        files.push(path);
246    }
247    files.sort_by(|a, b| {
248        let an = a.file_name().unwrap_or("");
249        let bn = b.file_name().unwrap_or("");
250        file_rank(an).cmp(&file_rank(bn)).then_with(|| an.cmp(bn))
251    });
252    Ok(files)
253}
254
255fn file_rank(name: &str) -> u8 {
256    match name {
257        "config.toml" => 0,
258        "config.local.toml" => 2,
259        _ => 1,
260    }
261}
262
263/// Deep-merge `overlay` into `base`. Tables recurse; arrays append; scalars
264/// overlay-wins.
265fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) {
266    for (k, v) in overlay {
267        match (base.remove(&k), v) {
268            (Some(toml::Value::Table(mut bt)), toml::Value::Table(ot)) => {
269                deep_merge_table(&mut bt, ot);
270                base.insert(k, toml::Value::Table(bt));
271            }
272            (Some(toml::Value::Array(mut ba)), toml::Value::Array(oa)) => {
273                ba.extend(oa);
274                base.insert(k, toml::Value::Array(ba));
275            }
276            (_, v) => {
277                base.insert(k, v);
278            }
279        }
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use tempfile::TempDir;
287
288    fn yui_vars(source: &Utf8Path) -> YuiVars {
289        YuiVars {
290            os: "linux".into(),
291            arch: "x86_64".into(),
292            host: "test".into(),
293            user: "u".into(),
294            source: source.to_string(),
295        }
296    }
297
298    fn write(tmp: &TempDir, name: &str, body: &str) {
299        std::fs::write(tmp.path().join(name), body).unwrap();
300    }
301
302    fn root(tmp: &TempDir) -> Utf8PathBuf {
303        Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap()
304    }
305
306    #[test]
307    fn loads_single_file() {
308        let tmp = TempDir::new().unwrap();
309        write(
310            &tmp,
311            "config.toml",
312            r#"
313[vars]
314git_email = "a@example.com"
315
316[[mount.entry]]
317src = "home"
318dst = "/home/u"
319"#,
320        );
321        let r = root(&tmp);
322        let cfg = load(&r, &yui_vars(&r)).unwrap();
323        assert_eq!(
324            cfg.vars.get("git_email").unwrap().as_str(),
325            Some("a@example.com")
326        );
327        assert_eq!(cfg.mount.entry.len(), 1);
328        assert_eq!(cfg.mount.entry[0].dst, "/home/u");
329    }
330
331    #[test]
332    fn local_overrides_base() {
333        let tmp = TempDir::new().unwrap();
334        write(
335            &tmp,
336            "config.toml",
337            r#"
338[vars]
339git_email = "a@example.com"
340work_mode = false
341"#,
342        );
343        write(
344            &tmp,
345            "config.local.toml",
346            r#"
347[vars]
348git_email = "b@work.com"
349"#,
350        );
351        let r = root(&tmp);
352        let cfg = load(&r, &yui_vars(&r)).unwrap();
353        assert_eq!(
354            cfg.vars.get("git_email").unwrap().as_str(),
355            Some("b@work.com")
356        );
357        // unchanged keys preserved
358        assert_eq!(cfg.vars.get("work_mode").unwrap().as_bool(), Some(false));
359    }
360
361    #[test]
362    fn alphabetical_middle_files_apply_after_base_before_local() {
363        let tmp = TempDir::new().unwrap();
364        write(
365            &tmp,
366            "config.toml",
367            r#"[vars]
368val = "base""#,
369        );
370        write(
371            &tmp,
372            "config.aaa.toml",
373            r#"[vars]
374val = "aaa""#,
375        );
376        write(
377            &tmp,
378            "config.zzz.toml",
379            r#"[vars]
380val = "zzz""#,
381        );
382        write(
383            &tmp,
384            "config.local.toml",
385            r#"[vars]
386val = "local""#,
387        );
388        let r = root(&tmp);
389        let cfg = load(&r, &yui_vars(&r)).unwrap();
390        assert_eq!(cfg.vars.get("val").unwrap().as_str(), Some("local"));
391    }
392
393    #[test]
394    fn yui_vars_available_in_render() {
395        let tmp = TempDir::new().unwrap();
396        write(
397            &tmp,
398            "config.toml",
399            r#"
400[[mount.entry]]
401src = "home"
402dst = "/{{ yui.os }}/dst"
403"#,
404        );
405        let r = root(&tmp);
406        let cfg = load(&r, &yui_vars(&r)).unwrap();
407        assert_eq!(cfg.mount.entry[0].dst, "/linux/dst");
408    }
409
410    #[test]
411    fn mount_entries_append_across_files() {
412        let tmp = TempDir::new().unwrap();
413        write(
414            &tmp,
415            "config.toml",
416            r#"
417[[mount.entry]]
418src = "home"
419dst = "/h"
420"#,
421        );
422        write(
423            &tmp,
424            "config.local.toml",
425            r#"
426[[mount.entry]]
427src = "appdata"
428dst = "/a"
429"#,
430        );
431        let r = root(&tmp);
432        let cfg = load(&r, &yui_vars(&r)).unwrap();
433        assert_eq!(cfg.mount.entry.len(), 2);
434    }
435
436    #[test]
437    fn missing_config_errors() {
438        let tmp = TempDir::new().unwrap();
439        let r = root(&tmp);
440        let err = load(&r, &yui_vars(&r)).unwrap_err();
441        assert!(matches!(err, Error::Config(_)));
442    }
443
444    #[test]
445    fn defaults_apply_when_sections_absent() {
446        let tmp = TempDir::new().unwrap();
447        write(&tmp, "config.toml", "");
448        let r = root(&tmp);
449        let cfg = load(&r, &yui_vars(&r)).unwrap();
450        assert!(cfg.absorb.auto);
451        assert!(cfg.absorb.require_clean_git);
452        assert!(cfg.render.manage_gitignore);
453        assert_eq!(cfg.backup.dir, ".yui/backup");
454        assert_eq!(cfg.mount.marker_filename, ".yuilink");
455    }
456}