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