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    #[serde(default)]
44    pub hook: Vec<HookConfig>,
45}
46
47/// One hook = one script invocation triggered around `yui apply`.
48///
49/// The script lives at `$DOTFILES/<script>` (kept yui-agnostic — runnable
50/// directly with no yui involvement); `command` + `args` decide how to
51/// invoke it. Both are Tera-rendered with the standard yui context plus
52/// `script_path` / `script_dir` / `script_name` / `script_stem` /
53/// `script_ext`.
54#[derive(Debug, Clone, Deserialize)]
55pub struct HookConfig {
56    /// Unique identifier — used as the state-tracking key and the
57    /// argument to `yui hooks run <name>`.
58    pub name: String,
59    /// Script path relative to `$DOTFILES`. Hashed for `onchange` runs;
60    /// also exposed to `command` / `args` Tera as `script_path` etc.
61    pub script: Utf8PathBuf,
62
63    /// Interpreter / command to invoke. Tera-rendered. Default `"bash"`.
64    #[serde(default = "default_hook_command")]
65    pub command: String,
66    /// Arguments to `command`. Each element Tera-rendered. Default
67    /// `["{{ script_path }}"]`.
68    #[serde(default = "default_hook_args")]
69    pub args: Vec<String>,
70
71    /// Re-run policy. Default `Onchange`.
72    #[serde(default)]
73    pub when_run: WhenRun,
74    /// Apply phase to fire on. Default `Post`.
75    #[serde(default)]
76    pub phase: HookPhase,
77
78    /// Optional Tera bool predicate; absent = always eligible.
79    #[serde(default)]
80    pub when: Option<String>,
81}
82
83fn default_hook_command() -> String {
84    "bash".to_string()
85}
86
87fn default_hook_args() -> Vec<String> {
88    vec!["{{ script_path }}".to_string()]
89}
90
91#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
92#[serde(rename_all = "lowercase")]
93pub enum WhenRun {
94    /// Run exactly once across the lifetime of the source repo. Tracked
95    /// via `last_run_at` in `.yui/state.json`.
96    Once,
97    /// Run when the script content (SHA-256 of `script`) differs from
98    /// the last successful run. Default — best fit for "re-run when I
99    /// edit the bootstrap".
100    #[default]
101    Onchange,
102    /// Run on every apply.
103    Every,
104}
105
106#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
107#[serde(rename_all = "lowercase")]
108pub enum HookPhase {
109    /// Before any render / link work — useful for prerequisite installs.
110    Pre,
111    /// After all linking finishes. Default — "I just `apply`'d, now
112    /// reload the launchd / brew bundle / etc.".
113    #[default]
114    Post,
115}
116
117#[derive(Debug, Deserialize, Default)]
118pub struct UiConfig {
119    #[serde(default)]
120    pub icons: IconsMode,
121}
122
123#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
124#[serde(rename_all = "lowercase")]
125pub enum IconsMode {
126    /// `✓ ✗ → ─` — works on any terminal that renders basic Unicode (default).
127    #[default]
128    Unicode,
129    /// Nerd Font glyphs (`  →`) — requires a Nerd-Font-patched terminal font.
130    Nerd,
131    /// `[+] [-] -> -` — pure ASCII, for CI logs / SSH-into-legacy-tty.
132    Ascii,
133}
134
135#[derive(Debug, Deserialize, Default)]
136pub struct LinkConfig {
137    #[serde(default)]
138    pub file_mode: FileLinkMode,
139    #[serde(default)]
140    pub dir_mode: DirLinkMode,
141}
142
143#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
144#[serde(rename_all = "lowercase")]
145pub enum FileLinkMode {
146    #[default]
147    Auto,
148    Symlink,
149    Hardlink,
150}
151
152#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
153#[serde(rename_all = "lowercase")]
154pub enum DirLinkMode {
155    #[default]
156    Auto,
157    Symlink,
158    Junction,
159}
160
161#[derive(Debug, Deserialize)]
162pub struct MountConfig {
163    #[serde(default)]
164    pub default_strategy: MountStrategy,
165    #[serde(default = "default_marker_filename")]
166    pub marker_filename: String,
167    #[serde(default)]
168    pub entry: Vec<MountEntry>,
169}
170
171impl Default for MountConfig {
172    fn default() -> Self {
173        Self {
174            default_strategy: MountStrategy::default(),
175            marker_filename: default_marker_filename(),
176            entry: Vec::new(),
177        }
178    }
179}
180
181fn default_marker_filename() -> String {
182    ".yuilink".to_string()
183}
184
185#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
186#[serde(rename_all = "kebab-case")]
187pub enum MountStrategy {
188    #[default]
189    Marker,
190    PerFile,
191}
192
193#[derive(Debug, Deserialize)]
194pub struct MountEntry {
195    pub src: Utf8PathBuf,
196    pub dst: String,
197    #[serde(default)]
198    pub when: Option<String>,
199    #[serde(default)]
200    pub strategy: Option<MountStrategy>,
201}
202
203#[derive(Debug, Deserialize)]
204pub struct AbsorbConfig {
205    #[serde(default = "default_true")]
206    pub auto: bool,
207    #[serde(default = "default_true")]
208    pub require_clean_git: bool,
209    #[serde(default)]
210    pub on_anomaly: AnomalyAction,
211}
212
213impl Default for AbsorbConfig {
214    fn default() -> Self {
215        Self {
216            auto: true,
217            require_clean_git: true,
218            on_anomaly: AnomalyAction::default(),
219        }
220    }
221}
222
223#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
224#[serde(rename_all = "lowercase")]
225pub enum AnomalyAction {
226    #[default]
227    Ask,
228    Skip,
229    Force,
230}
231
232#[derive(Debug, Deserialize)]
233pub struct RenderConfig {
234    #[serde(default = "default_true")]
235    pub manage_gitignore: bool,
236    #[serde(default)]
237    pub rule: Vec<RenderRule>,
238}
239
240impl Default for RenderConfig {
241    fn default() -> Self {
242        Self {
243            manage_gitignore: true,
244            rule: Vec::new(),
245        }
246    }
247}
248
249#[derive(Debug, Deserialize)]
250pub struct RenderRule {
251    pub r#match: String,
252    #[serde(default)]
253    pub when: Option<String>,
254}
255
256#[derive(Debug, Deserialize)]
257pub struct BackupConfig {
258    #[serde(default = "default_backup_dir")]
259    pub dir: String,
260    #[serde(default = "default_ts_format")]
261    pub timestamp_format: String,
262}
263
264impl Default for BackupConfig {
265    fn default() -> Self {
266        Self {
267            dir: default_backup_dir(),
268            timestamp_format: default_ts_format(),
269        }
270    }
271}
272
273fn default_backup_dir() -> String {
274    ".yui/backup".to_string()
275}
276
277fn default_ts_format() -> String {
278    "%Y%m%d_%H%M%S%3f".to_string()
279}
280
281fn default_true() -> bool {
282    true
283}
284
285/// Load + merge config files from `$DOTFILES`.
286pub fn load(source: &Utf8Path, yui: &YuiVars) -> Result<Config> {
287    let files = list_config_files(source)?;
288    if files.is_empty() {
289        return Err(Error::Config(format!(
290            "no config.toml / config.*.toml found at {source}"
291        )));
292    }
293
294    let mut engine = template::Engine::new();
295    let mut merged = toml::Table::new();
296    let mut vars_acc = toml::Table::new();
297
298    for file in &files {
299        let raw = std::fs::read_to_string(file)
300            .map_err(|e| Error::Config(format!("read {file}: {e}")))?;
301        let ctx = template::template_context(yui, &vars_acc);
302        let rendered = engine.render(&raw, &ctx)?;
303        let parsed: toml::Table =
304            toml::from_str(&rendered).map_err(|e| Error::Config(format!("parse {file}: {e}")))?;
305
306        if let Some(toml::Value::Table(file_vars)) = parsed.get("vars") {
307            deep_merge_table(&mut vars_acc, file_vars.clone());
308        }
309        deep_merge_table(&mut merged, parsed);
310    }
311
312    let cfg: Config = toml::Value::Table(merged)
313        .try_into()
314        .map_err(|e| Error::Config(format!("schema: {e}")))?;
315    Ok(cfg)
316}
317
318/// List config files in merge order:
319///   `config.toml` (rank 0)
320/// → `config.*.toml` alphabetically (rank 1, excluding `config.local.toml`)
321/// → `config.local.toml` (rank 2, last/highest priority)
322fn list_config_files(source: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
323    let entries =
324        std::fs::read_dir(source).map_err(|e| Error::Config(format!("read_dir {source}: {e}")))?;
325    let mut files: Vec<Utf8PathBuf> = Vec::new();
326    for entry in entries {
327        let entry = entry.map_err(Error::Io)?;
328        let name_os = entry.file_name();
329        let Some(name) = name_os.to_str() else {
330            continue;
331        };
332        let is_match = name == "config.toml"
333            || (name.starts_with("config.") && name.ends_with(".toml") && name.len() > 12);
334        if !is_match {
335            continue;
336        }
337        let path = Utf8PathBuf::from_path_buf(entry.path())
338            .map_err(|p| Error::Config(format!("non-UTF8 config path: {}", p.display())))?;
339        files.push(path);
340    }
341    files.sort_by(|a, b| {
342        let an = a.file_name().unwrap_or("");
343        let bn = b.file_name().unwrap_or("");
344        file_rank(an).cmp(&file_rank(bn)).then_with(|| an.cmp(bn))
345    });
346    Ok(files)
347}
348
349fn file_rank(name: &str) -> u8 {
350    match name {
351        "config.toml" => 0,
352        "config.local.toml" => 2,
353        _ => 1,
354    }
355}
356
357/// Deep-merge `overlay` into `base`. Tables recurse; arrays append; scalars
358/// overlay-wins.
359fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) {
360    for (k, v) in overlay {
361        match (base.remove(&k), v) {
362            (Some(toml::Value::Table(mut bt)), toml::Value::Table(ot)) => {
363                deep_merge_table(&mut bt, ot);
364                base.insert(k, toml::Value::Table(bt));
365            }
366            (Some(toml::Value::Array(mut ba)), toml::Value::Array(oa)) => {
367                ba.extend(oa);
368                base.insert(k, toml::Value::Array(ba));
369            }
370            (_, v) => {
371                base.insert(k, v);
372            }
373        }
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use tempfile::TempDir;
381
382    fn yui_vars(source: &Utf8Path) -> YuiVars {
383        YuiVars {
384            os: "linux".into(),
385            arch: "x86_64".into(),
386            host: "test".into(),
387            user: "u".into(),
388            source: source.to_string(),
389        }
390    }
391
392    fn write(tmp: &TempDir, name: &str, body: &str) {
393        std::fs::write(tmp.path().join(name), body).unwrap();
394    }
395
396    fn root(tmp: &TempDir) -> Utf8PathBuf {
397        Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap()
398    }
399
400    #[test]
401    fn loads_single_file() {
402        let tmp = TempDir::new().unwrap();
403        write(
404            &tmp,
405            "config.toml",
406            r#"
407[vars]
408git_email = "a@example.com"
409
410[[mount.entry]]
411src = "home"
412dst = "/home/u"
413"#,
414        );
415        let r = root(&tmp);
416        let cfg = load(&r, &yui_vars(&r)).unwrap();
417        assert_eq!(
418            cfg.vars.get("git_email").unwrap().as_str(),
419            Some("a@example.com")
420        );
421        assert_eq!(cfg.mount.entry.len(), 1);
422        assert_eq!(cfg.mount.entry[0].dst, "/home/u");
423    }
424
425    #[test]
426    fn local_overrides_base() {
427        let tmp = TempDir::new().unwrap();
428        write(
429            &tmp,
430            "config.toml",
431            r#"
432[vars]
433git_email = "a@example.com"
434work_mode = false
435"#,
436        );
437        write(
438            &tmp,
439            "config.local.toml",
440            r#"
441[vars]
442git_email = "b@work.com"
443"#,
444        );
445        let r = root(&tmp);
446        let cfg = load(&r, &yui_vars(&r)).unwrap();
447        assert_eq!(
448            cfg.vars.get("git_email").unwrap().as_str(),
449            Some("b@work.com")
450        );
451        // unchanged keys preserved
452        assert_eq!(cfg.vars.get("work_mode").unwrap().as_bool(), Some(false));
453    }
454
455    #[test]
456    fn alphabetical_middle_files_apply_after_base_before_local() {
457        let tmp = TempDir::new().unwrap();
458        write(
459            &tmp,
460            "config.toml",
461            r#"[vars]
462val = "base""#,
463        );
464        write(
465            &tmp,
466            "config.aaa.toml",
467            r#"[vars]
468val = "aaa""#,
469        );
470        write(
471            &tmp,
472            "config.zzz.toml",
473            r#"[vars]
474val = "zzz""#,
475        );
476        write(
477            &tmp,
478            "config.local.toml",
479            r#"[vars]
480val = "local""#,
481        );
482        let r = root(&tmp);
483        let cfg = load(&r, &yui_vars(&r)).unwrap();
484        assert_eq!(cfg.vars.get("val").unwrap().as_str(), Some("local"));
485    }
486
487    #[test]
488    fn yui_vars_available_in_render() {
489        let tmp = TempDir::new().unwrap();
490        write(
491            &tmp,
492            "config.toml",
493            r#"
494[[mount.entry]]
495src = "home"
496dst = "/{{ yui.os }}/dst"
497"#,
498        );
499        let r = root(&tmp);
500        let cfg = load(&r, &yui_vars(&r)).unwrap();
501        assert_eq!(cfg.mount.entry[0].dst, "/linux/dst");
502    }
503
504    #[test]
505    fn mount_entries_append_across_files() {
506        let tmp = TempDir::new().unwrap();
507        write(
508            &tmp,
509            "config.toml",
510            r#"
511[[mount.entry]]
512src = "home"
513dst = "/h"
514"#,
515        );
516        write(
517            &tmp,
518            "config.local.toml",
519            r#"
520[[mount.entry]]
521src = "appdata"
522dst = "/a"
523"#,
524        );
525        let r = root(&tmp);
526        let cfg = load(&r, &yui_vars(&r)).unwrap();
527        assert_eq!(cfg.mount.entry.len(), 2);
528    }
529
530    #[test]
531    fn missing_config_errors() {
532        let tmp = TempDir::new().unwrap();
533        let r = root(&tmp);
534        let err = load(&r, &yui_vars(&r)).unwrap_err();
535        assert!(matches!(err, Error::Config(_)));
536    }
537
538    #[test]
539    fn defaults_apply_when_sections_absent() {
540        let tmp = TempDir::new().unwrap();
541        write(&tmp, "config.toml", "");
542        let r = root(&tmp);
543        let cfg = load(&r, &yui_vars(&r)).unwrap();
544        assert!(cfg.absorb.auto);
545        assert!(cfg.absorb.require_clean_git);
546        assert!(cfg.render.manage_gitignore);
547        assert_eq!(cfg.backup.dir, ".yui/backup");
548        assert_eq!(cfg.mount.marker_filename, ".yuilink");
549    }
550}