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