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    #[serde(default)]
47    pub secrets: SecretsConfig,
48}
49
50/// One hook = one script invocation triggered around `yui apply`.
51///
52/// The script lives at `$DOTFILES/<script>` (kept yui-agnostic — runnable
53/// directly with no yui involvement); `command` + `args` decide how to
54/// invoke it. Both are Tera-rendered with the standard yui context plus
55/// `script_path` / `script_dir` / `script_name` / `script_stem` /
56/// `script_ext`.
57#[derive(Debug, Clone, Deserialize)]
58pub struct HookConfig {
59    /// Unique identifier — used as the state-tracking key and the
60    /// argument to `yui hooks run <name>`.
61    pub name: String,
62    /// Script path relative to `$DOTFILES`. Hashed for `onchange` runs;
63    /// also exposed to `command` / `args` Tera as `script_path` etc.
64    pub script: Utf8PathBuf,
65
66    /// Interpreter / command to invoke. Tera-rendered. Default `"bash"`.
67    #[serde(default = "default_hook_command")]
68    pub command: String,
69    /// Arguments to `command`. Each element Tera-rendered. Default
70    /// `["{{ script_path }}"]`.
71    #[serde(default = "default_hook_args")]
72    pub args: Vec<String>,
73
74    /// Re-run policy. Default `Onchange`.
75    #[serde(default)]
76    pub when_run: WhenRun,
77    /// Apply phase to fire on. Default `Post`.
78    #[serde(default)]
79    pub phase: HookPhase,
80
81    /// Optional Tera bool predicate; absent = always eligible.
82    #[serde(default)]
83    pub when: Option<String>,
84}
85
86fn default_hook_command() -> String {
87    "bash".to_string()
88}
89
90fn default_hook_args() -> Vec<String> {
91    vec!["{{ script_path }}".to_string()]
92}
93
94#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
95#[serde(rename_all = "lowercase")]
96pub enum WhenRun {
97    /// Run exactly once across the lifetime of the source repo. Tracked
98    /// via `last_run_at` in `.yui/state.json`.
99    Once,
100    /// Run when the script content (SHA-256 of `script`) differs from
101    /// the last successful run. Default — best fit for "re-run when I
102    /// edit the bootstrap".
103    #[default]
104    Onchange,
105    /// Run on every apply.
106    Every,
107}
108
109#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
110#[serde(rename_all = "lowercase")]
111pub enum HookPhase {
112    /// Before any render / link work — useful for prerequisite installs.
113    Pre,
114    /// After all linking finishes. Default — "I just `apply`'d, now
115    /// reload the launchd / brew bundle / etc.".
116    #[default]
117    Post,
118}
119
120#[derive(Debug, Deserialize, Default)]
121pub struct UiConfig {
122    #[serde(default)]
123    pub icons: IconsMode,
124}
125
126#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
127#[serde(rename_all = "lowercase")]
128pub enum IconsMode {
129    /// `✓ ✗ → ─` — works on any terminal that renders basic Unicode (default).
130    #[default]
131    Unicode,
132    /// Nerd Font glyphs (`  →`) — requires a Nerd-Font-patched terminal font.
133    Nerd,
134    /// `[+] [-] -> -` — pure ASCII, for CI logs / SSH-into-legacy-tty.
135    Ascii,
136}
137
138#[derive(Debug, Deserialize, Default)]
139pub struct LinkConfig {
140    #[serde(default)]
141    pub file_mode: FileLinkMode,
142    #[serde(default)]
143    pub dir_mode: DirLinkMode,
144}
145
146#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
147#[serde(rename_all = "lowercase")]
148pub enum FileLinkMode {
149    #[default]
150    Auto,
151    Symlink,
152    Hardlink,
153}
154
155#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
156#[serde(rename_all = "lowercase")]
157pub enum DirLinkMode {
158    #[default]
159    Auto,
160    Symlink,
161    Junction,
162}
163
164#[derive(Debug, Deserialize)]
165pub struct MountConfig {
166    #[serde(default)]
167    pub default_strategy: MountStrategy,
168    #[serde(default = "default_marker_filename")]
169    pub marker_filename: String,
170    #[serde(default)]
171    pub entry: Vec<MountEntry>,
172}
173
174impl Default for MountConfig {
175    fn default() -> Self {
176        Self {
177            default_strategy: MountStrategy::default(),
178            marker_filename: default_marker_filename(),
179            entry: Vec::new(),
180        }
181    }
182}
183
184fn default_marker_filename() -> String {
185    ".yuilink".to_string()
186}
187
188#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
189#[serde(rename_all = "kebab-case")]
190pub enum MountStrategy {
191    #[default]
192    Marker,
193    PerFile,
194}
195
196#[derive(Debug, Deserialize)]
197pub struct MountEntry {
198    pub src: Utf8PathBuf,
199    pub dst: String,
200    #[serde(default)]
201    pub when: Option<String>,
202    #[serde(default)]
203    pub strategy: Option<MountStrategy>,
204}
205
206#[derive(Debug, Deserialize)]
207pub struct AbsorbConfig {
208    #[serde(default = "default_true")]
209    pub auto: bool,
210    #[serde(default = "default_true")]
211    pub require_clean_git: bool,
212    #[serde(default)]
213    pub on_anomaly: AnomalyAction,
214}
215
216impl Default for AbsorbConfig {
217    fn default() -> Self {
218        Self {
219            auto: true,
220            require_clean_git: true,
221            on_anomaly: AnomalyAction::default(),
222        }
223    }
224}
225
226#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)]
227#[serde(rename_all = "lowercase")]
228pub enum AnomalyAction {
229    #[default]
230    Ask,
231    Skip,
232    Force,
233}
234
235#[derive(Debug, Deserialize)]
236pub struct RenderConfig {
237    #[serde(default = "default_true")]
238    pub manage_gitignore: bool,
239    #[serde(default)]
240    pub rule: Vec<RenderRule>,
241}
242
243impl Default for RenderConfig {
244    fn default() -> Self {
245        Self {
246            manage_gitignore: true,
247            rule: Vec::new(),
248        }
249    }
250}
251
252#[derive(Debug, Deserialize)]
253pub struct RenderRule {
254    pub r#match: String,
255    #[serde(default)]
256    pub when: Option<String>,
257}
258
259#[derive(Debug, Deserialize)]
260pub struct BackupConfig {
261    #[serde(default = "default_backup_dir")]
262    pub dir: String,
263    #[serde(default = "default_ts_format")]
264    pub timestamp_format: String,
265}
266
267impl Default for BackupConfig {
268    fn default() -> Self {
269        Self {
270            dir: default_backup_dir(),
271            timestamp_format: default_ts_format(),
272        }
273    }
274}
275
276fn default_backup_dir() -> String {
277    ".yui/backup".to_string()
278}
279
280/// `[secrets]` — wires the age encryption pipeline into apply.
281///
282/// `identity` is the path to your local age secret key file (NOT
283/// committed). `recipients` is the public-key list every new
284/// encryption is wrapped to — at minimum, the public key matching
285/// `identity`, and any additional machines / users that should
286/// also be able to decrypt. yui defaults the identity path to
287/// `~/.config/yui/age.txt` and treats an empty `recipients` list
288/// as "secrets feature off".
289#[derive(Debug, Clone, Deserialize)]
290pub struct SecretsConfig {
291    /// Path to the X25519 secret key used by `apply` to decrypt
292    /// `*.age` files. Plain (`AGE-SECRET-KEY-1…`) text, gitignored.
293    /// Default `~/.config/yui/age.txt`.
294    #[serde(default = "default_identity_path")]
295    pub identity: String,
296
297    /// Public keys that `*.age` files are encrypted to. X25519
298    /// (`age1…`) is the everyday case and is what `yui secret init`
299    /// adds. Plugin recipients (`age1<plugin>1…`) are also accepted
300    /// — yui doesn't ship first-class commands for them, but if you
301    /// hand-write a YubiKey / FIDO2 / TPM / Secure Enclave / 1P
302    /// recipient here it'll be honored, and the matching
303    /// `age-plugin-*` binary on `$PATH` lets `age` itself decrypt
304    /// those stanzas. (yui's apply uses the X25519 in `identity`
305    /// only, so plugin recipients add a *parallel* decrypt path
306    /// without slowing apply down.)
307    ///
308    /// Empty = secrets feature off.
309    #[serde(default)]
310    pub recipients: Vec<String>,
311
312    /// Vault provider config — when set, `yui secret store` /
313    /// `yui secret unlock` use it to ferry the X25519 identity
314    /// across machines via Bitwarden / 1Password instead of
315    /// asking the user to copy `~/.config/yui/age.txt` by hand.
316    /// Off when absent.
317    #[serde(default)]
318    pub vault: Option<VaultConfig>,
319}
320
321impl Default for SecretsConfig {
322    fn default() -> Self {
323        Self {
324            identity: default_identity_path(),
325            recipients: Vec::new(),
326            vault: None,
327        }
328    }
329}
330
331/// `[secrets.vault]` — points yui at a vault item that holds the
332/// X25519 identity. yui doesn't authenticate against the vault
333/// itself; it shells out to the provider's official CLI (`bw` or
334/// `op`), which already knows how to drive its own auth flow
335/// (master password, biometric, passkey-via-web-vault, SSO).
336///
337/// Storage convention: the X25519 secret file's full content
338/// (header comments + the `AGE-SECRET-KEY-1…` line) goes in the
339/// item's notes field. Picking notes (rather than the password
340/// field) keeps the multi-line content intact and doesn't pollute
341/// the vault's password autofill UI.
342#[derive(Debug, Clone, Deserialize)]
343pub struct VaultConfig {
344    /// `"bitwarden"` or `"1password"`.
345    pub provider: VaultProvider,
346}
347
348/// Vault item name yui stores the X25519 identity under. Hardcoded
349/// rather than configurable — the realistic "I have multiple yui
350/// dotfiles trees sharing one vault account" case is rare enough
351/// that the simplification of one-less config knob wins. Add a
352/// configurable knob back if a user actually hits the collision.
353pub const VAULT_ITEM_NAME: &str = "yui-x25519-identity";
354
355#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
356#[serde(rename_all = "lowercase")]
357pub enum VaultProvider {
358    Bitwarden,
359    #[serde(alias = "1password")]
360    OnePassword,
361}
362
363impl SecretsConfig {
364    /// `[secrets]` is "on" once the user has populated `recipients`
365    /// (which `yui secret init` does). Until then, the apply walker
366    /// won't even look for `*.age` files — keeps every existing
367    /// dotfiles repo behaving exactly the same as before this PR.
368    pub fn enabled(&self) -> bool {
369        !self.recipients.is_empty()
370    }
371}
372
373fn default_identity_path() -> String {
374    // Cross-platform `~/.config/yui/age.txt` — `paths::expand_tilde`
375    // turns `~` into `$HOME` / `$USERPROFILE` at use time so the
376    // string stays portable across machines.
377    "~/.config/yui/age.txt".to_string()
378}
379
380fn default_ts_format() -> String {
381    "%Y%m%d_%H%M%S%3f".to_string()
382}
383
384fn default_true() -> bool {
385    true
386}
387
388/// Load + merge config files from `$DOTFILES`.
389pub fn load(source: &Utf8Path, yui: &YuiVars) -> Result<Config> {
390    let files = list_config_files(source)?;
391    if files.is_empty() {
392        return Err(Error::Config(format!(
393            "no config.toml / config.*.toml found at {source}"
394        )));
395    }
396
397    let mut engine = template::Engine::new();
398    let mut merged = toml::Table::new();
399    let mut vars_acc = toml::Table::new();
400
401    for file in &files {
402        let raw = std::fs::read_to_string(file)
403            .map_err(|e| Error::Config(format!("read {file}: {e}")))?;
404
405        // Pre-extract this file's own `[vars]` section as plain text and
406        // merge it into `vars_acc` BEFORE rendering. Without this, a
407        // file's `[[mount.entry]] dst = "{{ vars.home_root }}"` couldn't
408        // reference a `home_root` declared at the top of the same file
409        // — it would only see vars from previously-loaded files.
410        if let Some(file_vars) = pre_extract_vars(&raw, file)? {
411            deep_merge_table(&mut vars_acc, file_vars);
412        }
413        // Resolve cross-references within vars (`a = "{{ b }}"`,
414        // `b = "raw"` — possibly across files) by iteratively rendering
415        // every string value in `vars_acc` with `vars_acc` itself as
416        // the context, until nothing changes (or we've burned through
417        // the iteration budget — that catches genuine cycles).
418        resolve_vars_refs(&mut vars_acc, yui, &mut engine)?;
419
420        // Use the config-flavoured context so hook-level placeholders
421        // (`{{ script_path }}` etc.) survive this pass intact. Dotfile
422        // rendering keeps the bare `template_context`.
423        let ctx = template::config_render_context(yui, &vars_acc);
424        let rendered = engine.render(&raw, &ctx)?;
425        let parsed: toml::Table =
426            toml::from_str(&rendered).map_err(|e| Error::Config(format!("parse {file}: {e}")))?;
427
428        // Re-merge vars from the parsed (Tera-rendered) form. Pre-extract
429        // gives us the unrendered shape; the rendered form may have
430        // resolved `{{ env(...) }}` etc. and we want those resolved
431        // values visible to subsequent files.
432        if let Some(toml::Value::Table(file_vars)) = parsed.get("vars") {
433            deep_merge_table(&mut vars_acc, file_vars.clone());
434        }
435        deep_merge_table(&mut merged, parsed);
436    }
437
438    let cfg: Config = toml::Value::Table(merged)
439        .try_into()
440        .map_err(|e| Error::Config(format!("schema: {e}")))?;
441    Ok(cfg)
442}
443
444/// Pull just the `[vars]` (and `[vars.X]` sub-tables) out of a config
445/// file's raw text and parse them as standalone TOML, ignoring the
446/// rest. Returns `None` when the file has no `[vars]` section.
447///
448/// Skips Tera control blocks (`{% ... %}` lines) so a file using
449/// `{% set ... %}` at the top doesn't break the extraction. Any value
450/// inside `[vars]` that itself contains Tera (`{{ ... }}` or `{% ... %}`)
451/// would round-trip through TOML deserialization unchanged — Tera
452/// rendering is the second pass.
453fn pre_extract_vars(raw: &str, file: &Utf8Path) -> Result<Option<toml::Table>> {
454    let mut in_vars = false;
455    let mut found_vars = false;
456    let mut lines: Vec<&str> = Vec::new();
457    for line in raw.lines() {
458        let trimmed = line.trim();
459        // Strip a trailing comment so a section header like
460        // `[options]  # group` still ends the [vars] capture.
461        let header = trimmed.split('#').next().unwrap_or("").trim();
462        if header.starts_with("[") {
463            // Section start. `[vars]` or `[vars.<X>]` opens / continues
464            // the capture; anything else closes it.
465            let normalized: String = header.chars().filter(|c| !c.is_whitespace()).collect();
466            if normalized == "[vars]"
467                || normalized.starts_with("[vars.")
468                || normalized.starts_with("[vars[")
469            {
470                in_vars = true;
471                found_vars = true;
472                lines.push(line);
473                continue;
474            }
475            in_vars = false;
476            continue;
477        }
478        // Tera control block at column 0 — skip so the standalone
479        // TOML parse doesn't see `{% set ... %}` and choke. Inline
480        // `{{ ... }}` inside values is fine because TOML happily
481        // accepts them as plain strings.
482        if trimmed.starts_with("{%") {
483            continue;
484        }
485        if in_vars {
486            lines.push(line);
487        }
488    }
489    if !found_vars {
490        return Ok(None);
491    }
492    let extracted = lines.join("\n");
493    let parsed: toml::Table = toml::from_str(&extracted).map_err(|e| {
494        Error::Config(format!(
495            "pre-extract [vars] from {file}: {e} \
496             (the [vars] block must be parseable on its own — \
497             move computed values into a `set` block above the section)"
498        ))
499    })?;
500    if let Some(toml::Value::Table(vars)) = parsed.get("vars") {
501        Ok(Some(vars.clone()))
502    } else {
503        Ok(None)
504    }
505}
506
507/// Maximum number of resolution iterations. Each iteration evaluates
508/// every templated string value in `vars` with the current vars as the
509/// context. Genuine cycles (`a = "{{ b }}"`, `b = "{{ a }}"`) hit this
510/// budget and bail out — leaving the values as-is rather than looping
511/// forever or panicking.
512const MAX_VARS_RESOLVE_ITERATIONS: usize = 8;
513
514/// Iteratively Tera-render every string value in a vars table using the
515/// vars table itself (plus `yui.*` / `env(…)`) as the rendering context,
516/// until no value changes between iterations.
517fn resolve_vars_refs(
518    vars: &mut toml::Table,
519    yui: &YuiVars,
520    engine: &mut template::Engine,
521) -> Result<()> {
522    for _ in 0..MAX_VARS_RESOLVE_ITERATIONS {
523        // `config_render_context` for parity with the main config
524        // render pass — a vars value that happens to include
525        // `{{ script_path }}` should pass through here for the same
526        // reason it does at the file level.
527        let ctx = template::config_render_context(yui, vars);
528        let mut changed = false;
529        render_strings_in_table(vars, engine, &ctx, &mut changed)?;
530        if !changed {
531            return Ok(());
532        }
533    }
534    // Hit the budget — likely a cycle. We leave the partially-resolved
535    // values in place (rather than erroring) so the rest of yui keeps
536    // working; downstream Tera renders will surface a useful error if
537    // the unresolved value lands somewhere it matters.
538    Ok(())
539}
540
541fn render_strings_in_table(
542    table: &mut toml::Table,
543    engine: &mut template::Engine,
544    ctx: &tera::Context,
545    changed: &mut bool,
546) -> Result<()> {
547    for (_k, value) in table.iter_mut() {
548        render_strings_in_value(value, engine, ctx, changed)?;
549    }
550    Ok(())
551}
552
553fn render_strings_in_value(
554    value: &mut toml::Value,
555    engine: &mut template::Engine,
556    ctx: &tera::Context,
557    changed: &mut bool,
558) -> Result<()> {
559    match value {
560        toml::Value::String(s) => {
561            if !s.contains("{{") && !s.contains("{%") {
562                return Ok(());
563            }
564            let rendered = engine.render(s.as_str(), ctx)?;
565            if rendered != *s {
566                *s = rendered;
567                *changed = true;
568            }
569        }
570        toml::Value::Table(t) => {
571            render_strings_in_table(t, engine, ctx, changed)?;
572        }
573        toml::Value::Array(arr) => {
574            for v in arr.iter_mut() {
575                render_strings_in_value(v, engine, ctx, changed)?;
576            }
577        }
578        _ => {}
579    }
580    Ok(())
581}
582
583/// List config files in merge order:
584///   `config.toml` (rank 0)
585/// → `config.*.toml` alphabetically (rank 1, excluding `config.local.toml`)
586/// → `config.local.toml` (rank 2, last/highest priority)
587fn list_config_files(source: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
588    let entries =
589        std::fs::read_dir(source).map_err(|e| Error::Config(format!("read_dir {source}: {e}")))?;
590    let mut files: Vec<Utf8PathBuf> = Vec::new();
591    for entry in entries {
592        let entry = entry.map_err(Error::Io)?;
593        let name_os = entry.file_name();
594        let Some(name) = name_os.to_str() else {
595            continue;
596        };
597        let is_match = name == "config.toml"
598            || (name.starts_with("config.") && name.ends_with(".toml") && name.len() > 12);
599        if !is_match {
600            continue;
601        }
602        let path = Utf8PathBuf::from_path_buf(entry.path())
603            .map_err(|p| Error::Config(format!("non-UTF8 config path: {}", p.display())))?;
604        files.push(path);
605    }
606    files.sort_by(|a, b| {
607        let an = a.file_name().unwrap_or("");
608        let bn = b.file_name().unwrap_or("");
609        file_rank(an).cmp(&file_rank(bn)).then_with(|| an.cmp(bn))
610    });
611    Ok(files)
612}
613
614fn file_rank(name: &str) -> u8 {
615    match name {
616        "config.toml" => 0,
617        "config.local.toml" => 2,
618        _ => 1,
619    }
620}
621
622/// Deep-merge `overlay` into `base`. Tables recurse; arrays append; scalars
623/// overlay-wins.
624fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) {
625    for (k, v) in overlay {
626        match (base.remove(&k), v) {
627            (Some(toml::Value::Table(mut bt)), toml::Value::Table(ot)) => {
628                deep_merge_table(&mut bt, ot);
629                base.insert(k, toml::Value::Table(bt));
630            }
631            (Some(toml::Value::Array(mut ba)), toml::Value::Array(oa)) => {
632                ba.extend(oa);
633                base.insert(k, toml::Value::Array(ba));
634            }
635            (_, v) => {
636                base.insert(k, v);
637            }
638        }
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645    use tempfile::TempDir;
646
647    fn yui_vars(source: &Utf8Path) -> YuiVars {
648        YuiVars {
649            os: "linux".into(),
650            arch: "x86_64".into(),
651            host: "test".into(),
652            user: "u".into(),
653            source: source.to_string(),
654        }
655    }
656
657    fn write(tmp: &TempDir, name: &str, body: &str) {
658        std::fs::write(tmp.path().join(name), body).unwrap();
659    }
660
661    fn root(tmp: &TempDir) -> Utf8PathBuf {
662        Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap()
663    }
664
665    #[test]
666    fn loads_single_file() {
667        let tmp = TempDir::new().unwrap();
668        write(
669            &tmp,
670            "config.toml",
671            r#"
672[vars]
673git_email = "a@example.com"
674
675[[mount.entry]]
676src = "home"
677dst = "/home/u"
678"#,
679        );
680        let r = root(&tmp);
681        let cfg = load(&r, &yui_vars(&r)).unwrap();
682        assert_eq!(
683            cfg.vars.get("git_email").unwrap().as_str(),
684            Some("a@example.com")
685        );
686        assert_eq!(cfg.mount.entry.len(), 1);
687        assert_eq!(cfg.mount.entry[0].dst, "/home/u");
688    }
689
690    #[test]
691    fn local_overrides_base() {
692        let tmp = TempDir::new().unwrap();
693        write(
694            &tmp,
695            "config.toml",
696            r#"
697[vars]
698git_email = "a@example.com"
699work_mode = false
700"#,
701        );
702        write(
703            &tmp,
704            "config.local.toml",
705            r#"
706[vars]
707git_email = "b@work.com"
708"#,
709        );
710        let r = root(&tmp);
711        let cfg = load(&r, &yui_vars(&r)).unwrap();
712        assert_eq!(
713            cfg.vars.get("git_email").unwrap().as_str(),
714            Some("b@work.com")
715        );
716        // unchanged keys preserved
717        assert_eq!(cfg.vars.get("work_mode").unwrap().as_bool(), Some(false));
718    }
719
720    #[test]
721    fn alphabetical_middle_files_apply_after_base_before_local() {
722        let tmp = TempDir::new().unwrap();
723        write(
724            &tmp,
725            "config.toml",
726            r#"[vars]
727val = "base""#,
728        );
729        write(
730            &tmp,
731            "config.aaa.toml",
732            r#"[vars]
733val = "aaa""#,
734        );
735        write(
736            &tmp,
737            "config.zzz.toml",
738            r#"[vars]
739val = "zzz""#,
740        );
741        write(
742            &tmp,
743            "config.local.toml",
744            r#"[vars]
745val = "local""#,
746        );
747        let r = root(&tmp);
748        let cfg = load(&r, &yui_vars(&r)).unwrap();
749        assert_eq!(cfg.vars.get("val").unwrap().as_str(), Some("local"));
750    }
751
752    #[test]
753    fn yui_vars_available_in_render() {
754        let tmp = TempDir::new().unwrap();
755        write(
756            &tmp,
757            "config.toml",
758            r#"
759[[mount.entry]]
760src = "home"
761dst = "/{{ yui.os }}/dst"
762"#,
763        );
764        let r = root(&tmp);
765        let cfg = load(&r, &yui_vars(&r)).unwrap();
766        assert_eq!(cfg.mount.entry[0].dst, "/linux/dst");
767    }
768
769    #[test]
770    fn mount_entries_append_across_files() {
771        let tmp = TempDir::new().unwrap();
772        write(
773            &tmp,
774            "config.toml",
775            r#"
776[[mount.entry]]
777src = "home"
778dst = "/h"
779"#,
780        );
781        write(
782            &tmp,
783            "config.local.toml",
784            r#"
785[[mount.entry]]
786src = "appdata"
787dst = "/a"
788"#,
789        );
790        let r = root(&tmp);
791        let cfg = load(&r, &yui_vars(&r)).unwrap();
792        assert_eq!(cfg.mount.entry.len(), 2);
793    }
794
795    #[test]
796    fn missing_config_errors() {
797        let tmp = TempDir::new().unwrap();
798        let r = root(&tmp);
799        let err = load(&r, &yui_vars(&r)).unwrap_err();
800        assert!(matches!(err, Error::Config(_)));
801    }
802
803    #[test]
804    fn defaults_apply_when_sections_absent() {
805        let tmp = TempDir::new().unwrap();
806        write(&tmp, "config.toml", "");
807        let r = root(&tmp);
808        let cfg = load(&r, &yui_vars(&r)).unwrap();
809        assert!(cfg.absorb.auto);
810        assert!(cfg.absorb.require_clean_git);
811        assert!(cfg.render.manage_gitignore);
812        assert_eq!(cfg.backup.dir, ".yui/backup");
813        assert_eq!(cfg.mount.marker_filename, ".yuilink");
814    }
815
816    /// Pre-extract: a value declared in `[vars]` should be visible to
817    /// other sections of the same file during Tera rendering. Without
818    /// pre-extract this would fail because the file's own vars aren't
819    /// added to the context until AFTER rendering.
820    #[test]
821    fn vars_visible_to_same_file_render() {
822        let tmp = TempDir::new().unwrap();
823        write(
824            &tmp,
825            "config.toml",
826            r#"
827[vars]
828home_root = "/custom/home"
829
830[[mount.entry]]
831src = "home"
832dst = "{{ vars.home_root }}"
833"#,
834        );
835        let r = root(&tmp);
836        let cfg = load(&r, &yui_vars(&r)).unwrap();
837        assert_eq!(cfg.mount.entry.len(), 1);
838        assert_eq!(cfg.mount.entry[0].dst, "/custom/home");
839    }
840
841    /// Tera `set` blocks at the top of the file (used by some configs
842    /// for computed values) shouldn't break the standalone TOML parse
843    /// of the [vars] block that lives further down.
844    #[test]
845    fn vars_extract_skips_set_blocks() {
846        let tmp = TempDir::new().unwrap();
847        write(
848            &tmp,
849            "config.toml",
850            r#"
851{% set computed = "abc" %}
852[vars]
853plain = "real"
854
855[[mount.entry]]
856src = "home"
857dst = "{{ vars.plain }}"
858"#,
859        );
860        let r = root(&tmp);
861        let cfg = load(&r, &yui_vars(&r)).unwrap();
862        assert_eq!(cfg.mount.entry[0].dst, "real");
863    }
864
865    /// Vars that reference other vars should resolve regardless of
866    /// declaration order (the resolver iterates until convergence).
867    #[test]
868    fn vars_cross_reference_resolves_either_order() {
869        let tmp = TempDir::new().unwrap();
870        write(
871            &tmp,
872            "config.toml",
873            r#"
874[vars]
875a = "{{ vars.b }}"
876b = "raw"
877
878[[mount.entry]]
879src = "home"
880dst = "{{ vars.a }}"
881"#,
882        );
883        let r = root(&tmp);
884        let cfg = load(&r, &yui_vars(&r)).unwrap();
885        assert_eq!(cfg.mount.entry[0].dst, "raw");
886    }
887
888    /// Genuine cycles (`a = {{b}}` + `b = {{a}}`) shouldn't loop or
889    /// panic. The resolver bails after the iteration budget and leaves
890    /// the values as-is; downstream Tera renders that hit the
891    /// unresolved value will surface a clear error if it actually
892    /// matters at that site.
893    #[test]
894    fn vars_cycle_does_not_loop_forever() {
895        let tmp = TempDir::new().unwrap();
896        write(
897            &tmp,
898            "config.toml",
899            r#"
900[vars]
901a = "{{ vars.b }}"
902b = "{{ vars.a }}"
903
904[[mount.entry]]
905src = "home"
906dst = "/anywhere"
907"#,
908        );
909        let r = root(&tmp);
910        // Loads without panicking. The unresolved a/b just stay as
911        // literal Tera strings; load() succeeds because no other
912        // section actually references them.
913        let cfg = load(&r, &yui_vars(&r)).unwrap();
914        assert_eq!(cfg.mount.entry[0].dst, "/anywhere");
915    }
916
917    /// Hook-level Tera tokens (`{{ script_path }}` etc.) must survive
918    /// the config-load render verbatim — otherwise every author would
919    /// have to wrap them in `{% raw %}{% endraw %}`. The placeholders
920    /// are seeded as self-references in `template_context` so Tera
921    /// just emits them back; the hook executor's
922    /// `build_hook_context` overrides them with real paths at run
923    /// time.
924    #[test]
925    fn hook_script_vars_survive_config_load_render_verbatim() {
926        let tmp = TempDir::new().unwrap();
927        write(
928            &tmp,
929            "config.toml",
930            r#"
931[[mount.entry]]
932src = "home"
933dst = "/home/u"
934
935[[hook]]
936name = "deno-build"
937script = ".yui/bin/build.ts"
938command = "deno"
939args = ["run", "-A", "{{ script_path }}"]
940when_run = "onchange"
941"#,
942        );
943        let r = root(&tmp);
944        let cfg = load(&r, &yui_vars(&r)).unwrap();
945        assert_eq!(cfg.hook.len(), 1);
946        // The args literal made it through config-load untouched —
947        // the third arg is `{{ script_path }}`, ready for the hook
948        // executor to render with the real path.
949        assert_eq!(cfg.hook[0].args, vec!["run", "-A", "{{ script_path }}"]);
950    }
951}