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
302        // Pre-extract this file's own `[vars]` section as plain text and
303        // merge it into `vars_acc` BEFORE rendering. Without this, a
304        // file's `[[mount.entry]] dst = "{{ vars.home_root }}"` couldn't
305        // reference a `home_root` declared at the top of the same file
306        // — it would only see vars from previously-loaded files.
307        if let Some(file_vars) = pre_extract_vars(&raw, file)? {
308            deep_merge_table(&mut vars_acc, file_vars);
309        }
310        // Resolve cross-references within vars (`a = "{{ b }}"`,
311        // `b = "raw"` — possibly across files) by iteratively rendering
312        // every string value in `vars_acc` with `vars_acc` itself as
313        // the context, until nothing changes (or we've burned through
314        // the iteration budget — that catches genuine cycles).
315        resolve_vars_refs(&mut vars_acc, yui, &mut engine)?;
316
317        // Use the config-flavoured context so hook-level placeholders
318        // (`{{ script_path }}` etc.) survive this pass intact. Dotfile
319        // rendering keeps the bare `template_context`.
320        let ctx = template::config_render_context(yui, &vars_acc);
321        let rendered = engine.render(&raw, &ctx)?;
322        let parsed: toml::Table =
323            toml::from_str(&rendered).map_err(|e| Error::Config(format!("parse {file}: {e}")))?;
324
325        // Re-merge vars from the parsed (Tera-rendered) form. Pre-extract
326        // gives us the unrendered shape; the rendered form may have
327        // resolved `{{ env(...) }}` etc. and we want those resolved
328        // values visible to subsequent files.
329        if let Some(toml::Value::Table(file_vars)) = parsed.get("vars") {
330            deep_merge_table(&mut vars_acc, file_vars.clone());
331        }
332        deep_merge_table(&mut merged, parsed);
333    }
334
335    let cfg: Config = toml::Value::Table(merged)
336        .try_into()
337        .map_err(|e| Error::Config(format!("schema: {e}")))?;
338    Ok(cfg)
339}
340
341/// Pull just the `[vars]` (and `[vars.X]` sub-tables) out of a config
342/// file's raw text and parse them as standalone TOML, ignoring the
343/// rest. Returns `None` when the file has no `[vars]` section.
344///
345/// Skips Tera control blocks (`{% ... %}` lines) so a file using
346/// `{% set ... %}` at the top doesn't break the extraction. Any value
347/// inside `[vars]` that itself contains Tera (`{{ ... }}` or `{% ... %}`)
348/// would round-trip through TOML deserialization unchanged — Tera
349/// rendering is the second pass.
350fn pre_extract_vars(raw: &str, file: &Utf8Path) -> Result<Option<toml::Table>> {
351    let mut in_vars = false;
352    let mut found_vars = false;
353    let mut lines: Vec<&str> = Vec::new();
354    for line in raw.lines() {
355        let trimmed = line.trim();
356        // Strip a trailing comment so a section header like
357        // `[options]  # group` still ends the [vars] capture.
358        let header = trimmed.split('#').next().unwrap_or("").trim();
359        if header.starts_with("[") {
360            // Section start. `[vars]` or `[vars.<X>]` opens / continues
361            // the capture; anything else closes it.
362            let normalized: String = header.chars().filter(|c| !c.is_whitespace()).collect();
363            if normalized == "[vars]"
364                || normalized.starts_with("[vars.")
365                || normalized.starts_with("[vars[")
366            {
367                in_vars = true;
368                found_vars = true;
369                lines.push(line);
370                continue;
371            }
372            in_vars = false;
373            continue;
374        }
375        // Tera control block at column 0 — skip so the standalone
376        // TOML parse doesn't see `{% set ... %}` and choke. Inline
377        // `{{ ... }}` inside values is fine because TOML happily
378        // accepts them as plain strings.
379        if trimmed.starts_with("{%") {
380            continue;
381        }
382        if in_vars {
383            lines.push(line);
384        }
385    }
386    if !found_vars {
387        return Ok(None);
388    }
389    let extracted = lines.join("\n");
390    let parsed: toml::Table = toml::from_str(&extracted).map_err(|e| {
391        Error::Config(format!(
392            "pre-extract [vars] from {file}: {e} \
393             (the [vars] block must be parseable on its own — \
394             move computed values into a `set` block above the section)"
395        ))
396    })?;
397    if let Some(toml::Value::Table(vars)) = parsed.get("vars") {
398        Ok(Some(vars.clone()))
399    } else {
400        Ok(None)
401    }
402}
403
404/// Maximum number of resolution iterations. Each iteration evaluates
405/// every templated string value in `vars` with the current vars as the
406/// context. Genuine cycles (`a = "{{ b }}"`, `b = "{{ a }}"`) hit this
407/// budget and bail out — leaving the values as-is rather than looping
408/// forever or panicking.
409const MAX_VARS_RESOLVE_ITERATIONS: usize = 8;
410
411/// Iteratively Tera-render every string value in a vars table using the
412/// vars table itself (plus `yui.*` / `env(…)`) as the rendering context,
413/// until no value changes between iterations.
414fn resolve_vars_refs(
415    vars: &mut toml::Table,
416    yui: &YuiVars,
417    engine: &mut template::Engine,
418) -> Result<()> {
419    for _ in 0..MAX_VARS_RESOLVE_ITERATIONS {
420        // `config_render_context` for parity with the main config
421        // render pass — a vars value that happens to include
422        // `{{ script_path }}` should pass through here for the same
423        // reason it does at the file level.
424        let ctx = template::config_render_context(yui, vars);
425        let mut changed = false;
426        render_strings_in_table(vars, engine, &ctx, &mut changed)?;
427        if !changed {
428            return Ok(());
429        }
430    }
431    // Hit the budget — likely a cycle. We leave the partially-resolved
432    // values in place (rather than erroring) so the rest of yui keeps
433    // working; downstream Tera renders will surface a useful error if
434    // the unresolved value lands somewhere it matters.
435    Ok(())
436}
437
438fn render_strings_in_table(
439    table: &mut toml::Table,
440    engine: &mut template::Engine,
441    ctx: &tera::Context,
442    changed: &mut bool,
443) -> Result<()> {
444    for (_k, value) in table.iter_mut() {
445        render_strings_in_value(value, engine, ctx, changed)?;
446    }
447    Ok(())
448}
449
450fn render_strings_in_value(
451    value: &mut toml::Value,
452    engine: &mut template::Engine,
453    ctx: &tera::Context,
454    changed: &mut bool,
455) -> Result<()> {
456    match value {
457        toml::Value::String(s) => {
458            if !s.contains("{{") && !s.contains("{%") {
459                return Ok(());
460            }
461            let rendered = engine.render(s.as_str(), ctx)?;
462            if rendered != *s {
463                *s = rendered;
464                *changed = true;
465            }
466        }
467        toml::Value::Table(t) => {
468            render_strings_in_table(t, engine, ctx, changed)?;
469        }
470        toml::Value::Array(arr) => {
471            for v in arr.iter_mut() {
472                render_strings_in_value(v, engine, ctx, changed)?;
473            }
474        }
475        _ => {}
476    }
477    Ok(())
478}
479
480/// List config files in merge order:
481///   `config.toml` (rank 0)
482/// → `config.*.toml` alphabetically (rank 1, excluding `config.local.toml`)
483/// → `config.local.toml` (rank 2, last/highest priority)
484fn list_config_files(source: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
485    let entries =
486        std::fs::read_dir(source).map_err(|e| Error::Config(format!("read_dir {source}: {e}")))?;
487    let mut files: Vec<Utf8PathBuf> = Vec::new();
488    for entry in entries {
489        let entry = entry.map_err(Error::Io)?;
490        let name_os = entry.file_name();
491        let Some(name) = name_os.to_str() else {
492            continue;
493        };
494        let is_match = name == "config.toml"
495            || (name.starts_with("config.") && name.ends_with(".toml") && name.len() > 12);
496        if !is_match {
497            continue;
498        }
499        let path = Utf8PathBuf::from_path_buf(entry.path())
500            .map_err(|p| Error::Config(format!("non-UTF8 config path: {}", p.display())))?;
501        files.push(path);
502    }
503    files.sort_by(|a, b| {
504        let an = a.file_name().unwrap_or("");
505        let bn = b.file_name().unwrap_or("");
506        file_rank(an).cmp(&file_rank(bn)).then_with(|| an.cmp(bn))
507    });
508    Ok(files)
509}
510
511fn file_rank(name: &str) -> u8 {
512    match name {
513        "config.toml" => 0,
514        "config.local.toml" => 2,
515        _ => 1,
516    }
517}
518
519/// Deep-merge `overlay` into `base`. Tables recurse; arrays append; scalars
520/// overlay-wins.
521fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) {
522    for (k, v) in overlay {
523        match (base.remove(&k), v) {
524            (Some(toml::Value::Table(mut bt)), toml::Value::Table(ot)) => {
525                deep_merge_table(&mut bt, ot);
526                base.insert(k, toml::Value::Table(bt));
527            }
528            (Some(toml::Value::Array(mut ba)), toml::Value::Array(oa)) => {
529                ba.extend(oa);
530                base.insert(k, toml::Value::Array(ba));
531            }
532            (_, v) => {
533                base.insert(k, v);
534            }
535        }
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542    use tempfile::TempDir;
543
544    fn yui_vars(source: &Utf8Path) -> YuiVars {
545        YuiVars {
546            os: "linux".into(),
547            arch: "x86_64".into(),
548            host: "test".into(),
549            user: "u".into(),
550            source: source.to_string(),
551        }
552    }
553
554    fn write(tmp: &TempDir, name: &str, body: &str) {
555        std::fs::write(tmp.path().join(name), body).unwrap();
556    }
557
558    fn root(tmp: &TempDir) -> Utf8PathBuf {
559        Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap()
560    }
561
562    #[test]
563    fn loads_single_file() {
564        let tmp = TempDir::new().unwrap();
565        write(
566            &tmp,
567            "config.toml",
568            r#"
569[vars]
570git_email = "a@example.com"
571
572[[mount.entry]]
573src = "home"
574dst = "/home/u"
575"#,
576        );
577        let r = root(&tmp);
578        let cfg = load(&r, &yui_vars(&r)).unwrap();
579        assert_eq!(
580            cfg.vars.get("git_email").unwrap().as_str(),
581            Some("a@example.com")
582        );
583        assert_eq!(cfg.mount.entry.len(), 1);
584        assert_eq!(cfg.mount.entry[0].dst, "/home/u");
585    }
586
587    #[test]
588    fn local_overrides_base() {
589        let tmp = TempDir::new().unwrap();
590        write(
591            &tmp,
592            "config.toml",
593            r#"
594[vars]
595git_email = "a@example.com"
596work_mode = false
597"#,
598        );
599        write(
600            &tmp,
601            "config.local.toml",
602            r#"
603[vars]
604git_email = "b@work.com"
605"#,
606        );
607        let r = root(&tmp);
608        let cfg = load(&r, &yui_vars(&r)).unwrap();
609        assert_eq!(
610            cfg.vars.get("git_email").unwrap().as_str(),
611            Some("b@work.com")
612        );
613        // unchanged keys preserved
614        assert_eq!(cfg.vars.get("work_mode").unwrap().as_bool(), Some(false));
615    }
616
617    #[test]
618    fn alphabetical_middle_files_apply_after_base_before_local() {
619        let tmp = TempDir::new().unwrap();
620        write(
621            &tmp,
622            "config.toml",
623            r#"[vars]
624val = "base""#,
625        );
626        write(
627            &tmp,
628            "config.aaa.toml",
629            r#"[vars]
630val = "aaa""#,
631        );
632        write(
633            &tmp,
634            "config.zzz.toml",
635            r#"[vars]
636val = "zzz""#,
637        );
638        write(
639            &tmp,
640            "config.local.toml",
641            r#"[vars]
642val = "local""#,
643        );
644        let r = root(&tmp);
645        let cfg = load(&r, &yui_vars(&r)).unwrap();
646        assert_eq!(cfg.vars.get("val").unwrap().as_str(), Some("local"));
647    }
648
649    #[test]
650    fn yui_vars_available_in_render() {
651        let tmp = TempDir::new().unwrap();
652        write(
653            &tmp,
654            "config.toml",
655            r#"
656[[mount.entry]]
657src = "home"
658dst = "/{{ yui.os }}/dst"
659"#,
660        );
661        let r = root(&tmp);
662        let cfg = load(&r, &yui_vars(&r)).unwrap();
663        assert_eq!(cfg.mount.entry[0].dst, "/linux/dst");
664    }
665
666    #[test]
667    fn mount_entries_append_across_files() {
668        let tmp = TempDir::new().unwrap();
669        write(
670            &tmp,
671            "config.toml",
672            r#"
673[[mount.entry]]
674src = "home"
675dst = "/h"
676"#,
677        );
678        write(
679            &tmp,
680            "config.local.toml",
681            r#"
682[[mount.entry]]
683src = "appdata"
684dst = "/a"
685"#,
686        );
687        let r = root(&tmp);
688        let cfg = load(&r, &yui_vars(&r)).unwrap();
689        assert_eq!(cfg.mount.entry.len(), 2);
690    }
691
692    #[test]
693    fn missing_config_errors() {
694        let tmp = TempDir::new().unwrap();
695        let r = root(&tmp);
696        let err = load(&r, &yui_vars(&r)).unwrap_err();
697        assert!(matches!(err, Error::Config(_)));
698    }
699
700    #[test]
701    fn defaults_apply_when_sections_absent() {
702        let tmp = TempDir::new().unwrap();
703        write(&tmp, "config.toml", "");
704        let r = root(&tmp);
705        let cfg = load(&r, &yui_vars(&r)).unwrap();
706        assert!(cfg.absorb.auto);
707        assert!(cfg.absorb.require_clean_git);
708        assert!(cfg.render.manage_gitignore);
709        assert_eq!(cfg.backup.dir, ".yui/backup");
710        assert_eq!(cfg.mount.marker_filename, ".yuilink");
711    }
712
713    /// Pre-extract: a value declared in `[vars]` should be visible to
714    /// other sections of the same file during Tera rendering. Without
715    /// pre-extract this would fail because the file's own vars aren't
716    /// added to the context until AFTER rendering.
717    #[test]
718    fn vars_visible_to_same_file_render() {
719        let tmp = TempDir::new().unwrap();
720        write(
721            &tmp,
722            "config.toml",
723            r#"
724[vars]
725home_root = "/custom/home"
726
727[[mount.entry]]
728src = "home"
729dst = "{{ vars.home_root }}"
730"#,
731        );
732        let r = root(&tmp);
733        let cfg = load(&r, &yui_vars(&r)).unwrap();
734        assert_eq!(cfg.mount.entry.len(), 1);
735        assert_eq!(cfg.mount.entry[0].dst, "/custom/home");
736    }
737
738    /// Tera `set` blocks at the top of the file (used by some configs
739    /// for computed values) shouldn't break the standalone TOML parse
740    /// of the [vars] block that lives further down.
741    #[test]
742    fn vars_extract_skips_set_blocks() {
743        let tmp = TempDir::new().unwrap();
744        write(
745            &tmp,
746            "config.toml",
747            r#"
748{% set computed = "abc" %}
749[vars]
750plain = "real"
751
752[[mount.entry]]
753src = "home"
754dst = "{{ vars.plain }}"
755"#,
756        );
757        let r = root(&tmp);
758        let cfg = load(&r, &yui_vars(&r)).unwrap();
759        assert_eq!(cfg.mount.entry[0].dst, "real");
760    }
761
762    /// Vars that reference other vars should resolve regardless of
763    /// declaration order (the resolver iterates until convergence).
764    #[test]
765    fn vars_cross_reference_resolves_either_order() {
766        let tmp = TempDir::new().unwrap();
767        write(
768            &tmp,
769            "config.toml",
770            r#"
771[vars]
772a = "{{ vars.b }}"
773b = "raw"
774
775[[mount.entry]]
776src = "home"
777dst = "{{ vars.a }}"
778"#,
779        );
780        let r = root(&tmp);
781        let cfg = load(&r, &yui_vars(&r)).unwrap();
782        assert_eq!(cfg.mount.entry[0].dst, "raw");
783    }
784
785    /// Genuine cycles (`a = {{b}}` + `b = {{a}}`) shouldn't loop or
786    /// panic. The resolver bails after the iteration budget and leaves
787    /// the values as-is; downstream Tera renders that hit the
788    /// unresolved value will surface a clear error if it actually
789    /// matters at that site.
790    #[test]
791    fn vars_cycle_does_not_loop_forever() {
792        let tmp = TempDir::new().unwrap();
793        write(
794            &tmp,
795            "config.toml",
796            r#"
797[vars]
798a = "{{ vars.b }}"
799b = "{{ vars.a }}"
800
801[[mount.entry]]
802src = "home"
803dst = "/anywhere"
804"#,
805        );
806        let r = root(&tmp);
807        // Loads without panicking. The unresolved a/b just stay as
808        // literal Tera strings; load() succeeds because no other
809        // section actually references them.
810        let cfg = load(&r, &yui_vars(&r)).unwrap();
811        assert_eq!(cfg.mount.entry[0].dst, "/anywhere");
812    }
813
814    /// Hook-level Tera tokens (`{{ script_path }}` etc.) must survive
815    /// the config-load render verbatim — otherwise every author would
816    /// have to wrap them in `{% raw %}{% endraw %}`. The placeholders
817    /// are seeded as self-references in `template_context` so Tera
818    /// just emits them back; the hook executor's
819    /// `build_hook_context` overrides them with real paths at run
820    /// time.
821    #[test]
822    fn hook_script_vars_survive_config_load_render_verbatim() {
823        let tmp = TempDir::new().unwrap();
824        write(
825            &tmp,
826            "config.toml",
827            r#"
828[[mount.entry]]
829src = "home"
830dst = "/home/u"
831
832[[hook]]
833name = "deno-build"
834script = ".yui/bin/build.ts"
835command = "deno"
836args = ["run", "-A", "{{ script_path }}"]
837when_run = "onchange"
838"#,
839        );
840        let r = root(&tmp);
841        let cfg = load(&r, &yui_vars(&r)).unwrap();
842        assert_eq!(cfg.hook.len(), 1);
843        // The args literal made it through config-load untouched —
844        // the third arg is `{{ script_path }}`, ready for the hook
845        // executor to render with the real path.
846        assert_eq!(cfg.hook[0].args, vec!["run", "-A", "{{ script_path }}"]);
847    }
848}