Skip to main content

dodot_lib/preprocessing/
template.rs

1//! Template preprocessor — renders Jinja2-style templates via MiniJinja.
2//!
3//! Matches files with configurable extensions (default: `.tmpl`,
4//! `.template`), renders them against a variable context with three
5//! namespaces:
6//!
7//! - `dodot.*` — built-in values (os, arch, hostname, username, home,
8//!   dotfiles_root), computed once at preprocessor construction.
9//! - `env.*` — dynamic lookup of process environment variables.
10//! - bare names — user-defined variables from
11//!   `[preprocessor.template.vars]` in `.dodot.toml`.
12//!
13//! Uses MiniJinja strict undefined-behaviour: references to missing vars
14//! raise a render error rather than silently producing empty strings.
15
16use std::collections::{BTreeMap, HashMap};
17use std::path::{Path, PathBuf};
18use std::sync::{Arc, OnceLock};
19
20use minijinja::value::{Enumerator, Object, ObjectRepr, Value};
21use minijinja::{Environment, UndefinedBehavior};
22
23use crate::fs::Fs;
24use crate::paths::Pather;
25use crate::preprocessing::{ExpandedFile, Preprocessor, TransformType};
26use crate::{DodotError, Result};
27
28/// Reserved top-level variable names.
29const RESERVED_VARS: &[&str] = &["dodot", "env"];
30
31/// MiniJinja object that looks up process environment variables on
32/// attribute access. `{{ env.SHELL }}` becomes `std::env::var("SHELL")`.
33/// Missing env vars return `None` from `get_value`, which MiniJinja
34/// treats as an undefined attribute (a render error under strict mode).
35/// For optional variables, use `{{ env.NAME | default("...") }}`.
36#[derive(Debug)]
37struct EnvLookup;
38
39impl Object for EnvLookup {
40    fn repr(self: &Arc<Self>) -> ObjectRepr {
41        ObjectRepr::Map
42    }
43
44    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
45        let name = key.as_str()?;
46        std::env::var(name).ok().map(Value::from)
47    }
48
49    fn enumerate(self: &Arc<Self>) -> Enumerator {
50        // Don't enumerate environment variables — printing `{{ env }}`
51        // as a whole shouldn't dump the whole process environment.
52        Enumerator::NonEnumerable
53    }
54}
55
56/// Template rendering preprocessor. Generative (one-way) transform.
57pub struct TemplatePreprocessor {
58    extensions: Vec<String>,
59    env: Environment<'static>,
60}
61
62impl std::fmt::Debug for TemplatePreprocessor {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.debug_struct("TemplatePreprocessor")
65            .field("extensions", &self.extensions)
66            .finish_non_exhaustive()
67    }
68}
69
70impl TemplatePreprocessor {
71    /// Construct a new template preprocessor.
72    ///
73    /// Validates that no user-defined variable uses a reserved name
74    /// (`dodot` or `env`). Populates the MiniJinja environment with
75    /// the `dodot.*` builtins from `pather` + system info, an `env.*`
76    /// dynamic lookup, and each user variable as a bare global.
77    ///
78    /// Extensions are normalized at construction: a leading dot (e.g.
79    /// `".tmpl"`) is stripped so both `"tmpl"` and `".tmpl"` work.
80    pub fn new(
81        extensions: Vec<String>,
82        user_vars: HashMap<String, String>,
83        pather: &dyn Pather,
84    ) -> Result<Self> {
85        for name in user_vars.keys() {
86            if RESERVED_VARS.contains(&name.as_str()) {
87                return Err(DodotError::TemplateReservedVar { name: name.clone() });
88            }
89        }
90
91        let extensions: Vec<String> = extensions
92            .into_iter()
93            .map(|e| e.trim_start_matches('.').to_string())
94            .collect();
95
96        let mut env = Environment::new();
97        env.set_undefined_behavior(UndefinedBehavior::Strict);
98
99        env.add_global("dodot", Value::from(build_dodot_context(pather)));
100        env.add_global("env", Value::from_object(EnvLookup));
101
102        for (name, val) in user_vars {
103            env.add_global(name, Value::from(val));
104        }
105
106        Ok(Self { extensions, env })
107    }
108}
109
110impl Preprocessor for TemplatePreprocessor {
111    fn name(&self) -> &str {
112        "template"
113    }
114
115    fn transform_type(&self) -> TransformType {
116        TransformType::Generative
117    }
118
119    fn matches_extension(&self, filename: &str) -> bool {
120        // Extensions are normalized (no leading dot) at construction.
121        // We require a literal "." before the extension to avoid e.g.
122        // "mpl" matching "foo.tmpl". No per-call allocation.
123        self.extensions.iter().any(|ext| {
124            filename
125                .strip_suffix(ext.as_str())
126                .is_some_and(|prefix| prefix.ends_with('.'))
127        })
128    }
129
130    fn stripped_name(&self, filename: &str) -> String {
131        // If multiple configured extensions match (e.g. "tmpl" and
132        // "j2.tmpl" both suffixes of the same filename), prefer the
133        // longest so behaviour is deterministic and independent of
134        // config ordering.
135        self.extensions
136            .iter()
137            .filter_map(|ext| {
138                filename
139                    .strip_suffix(ext.as_str())
140                    .and_then(|prefix| prefix.strip_suffix('.'))
141                    .map(|stripped| (ext.len(), stripped))
142            })
143            .max_by_key(|(len, _)| *len)
144            .map(|(_, stripped)| stripped.to_string())
145            .unwrap_or_else(|| filename.to_string())
146    }
147
148    fn expand(&self, source: &Path, fs: &dyn Fs) -> Result<Vec<ExpandedFile>> {
149        let template_str = fs.read_to_string(source)?;
150
151        let rendered =
152            self.env
153                .render_str(&template_str, ())
154                .map_err(|e| DodotError::TemplateRender {
155                    source_file: source.to_path_buf(),
156                    message: format_minijinja_error(&e),
157                })?;
158
159        let filename = source
160            .file_name()
161            .unwrap_or_default()
162            .to_string_lossy()
163            .into_owned();
164        let stripped = self.stripped_name(&filename);
165
166        Ok(vec![ExpandedFile {
167            relative_path: PathBuf::from(stripped),
168            content: rendered.into_bytes(),
169            is_dir: false,
170        }])
171    }
172}
173
174/// Build the `dodot.*` namespace map.
175///
176/// Keys we can always resolve (os, arch, home, dotfiles_root) are
177/// always inserted. Keys that depend on environment detection
178/// (hostname, username) are inserted only when a non-empty value is
179/// found — otherwise they are omitted so that template access via
180/// `{{ dodot.hostname }}` triggers a strict-undefined render error,
181/// rather than silently injecting an empty string. Users who want a
182/// fallback can write `{{ dodot.hostname | default("unknown") }}`.
183///
184/// Hostname and username detection is cached process-wide via
185/// [`OnceLock`] so that building a template preprocessor for each pack
186/// does not respawn `hostname(1)` every time.
187fn build_dodot_context(pather: &dyn Pather) -> BTreeMap<String, String> {
188    let mut ctx = BTreeMap::new();
189    ctx.insert("os".into(), std::env::consts::OS.into());
190    ctx.insert("arch".into(), std::env::consts::ARCH.into());
191    if let Some(h) = cached_hostname() {
192        ctx.insert("hostname".into(), h.clone());
193    }
194    if let Some(u) = cached_username() {
195        ctx.insert("username".into(), u.clone());
196    }
197    ctx.insert("home".into(), pather.home_dir().display().to_string());
198    ctx.insert(
199        "dotfiles_root".into(),
200        pather.dotfiles_root().display().to_string(),
201    );
202    ctx
203}
204
205/// Process-wide cached hostname. First call resolves and pins the
206/// result for the lifetime of the process.
207fn cached_hostname() -> Option<&'static String> {
208    static CACHE: OnceLock<Option<String>> = OnceLock::new();
209    CACHE.get_or_init(detect_hostname).as_ref()
210}
211
212/// Process-wide cached username. Same caching semantics as
213/// [`cached_hostname`].
214fn cached_username() -> Option<&'static String> {
215    static CACHE: OnceLock<Option<String>> = OnceLock::new();
216    CACHE.get_or_init(detect_username).as_ref()
217}
218
219fn detect_hostname() -> Option<String> {
220    if let Ok(h) = std::env::var("HOSTNAME") {
221        if !h.is_empty() {
222            return Some(h);
223        }
224    }
225    // Fallback: shell out. Ignore errors.
226    let output = std::process::Command::new("hostname").output().ok()?;
227    if !output.status.success() {
228        return None;
229    }
230    let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
231    if name.is_empty() {
232        None
233    } else {
234        Some(name)
235    }
236}
237
238fn detect_username() -> Option<String> {
239    for var in ["USER", "USERNAME", "LOGNAME"] {
240        if let Ok(v) = std::env::var(var) {
241            if !v.is_empty() {
242                return Some(v);
243            }
244        }
245    }
246    None
247}
248
249/// Compact a MiniJinja error into a single human-readable string with
250/// a suggestion for the common "undefined variable" case.
251fn format_minijinja_error(err: &minijinja::Error) -> String {
252    use minijinja::ErrorKind;
253
254    let base = match err.kind() {
255        ErrorKind::UndefinedError => {
256            // Best-effort: MiniJinja's error message already says
257            // "undefined value" but doesn't always name the variable.
258            // The Display impl includes line info.
259            let mut msg = err.to_string();
260            msg.push_str(
261                "\n  hint: define the variable in [preprocessor.template.vars] in .dodot.toml,\n  or reference an environment variable with {{ env.NAME }} (with a default filter if optional)",
262            );
263            msg
264        }
265        ErrorKind::SyntaxError => err.to_string(),
266        _ => err.to_string(),
267    };
268
269    // MiniJinja sometimes appends "referenced from" traces; strip them
270    // to keep the error message compact.
271    base.lines().take(10).collect::<Vec<_>>().join("\n  ")
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::paths::XdgPather;
278
279    fn make_pather() -> XdgPather {
280        XdgPather::builder()
281            .home("/home/alice")
282            .dotfiles_root("/home/alice/dotfiles")
283            .xdg_config_home("/home/alice/.config")
284            .data_dir("/home/alice/.local/share/dodot")
285            .build()
286            .unwrap()
287    }
288
289    fn new_pp(vars: HashMap<String, String>) -> TemplatePreprocessor {
290        TemplatePreprocessor::new(vec!["tmpl".into(), "template".into()], vars, &make_pather())
291            .unwrap()
292    }
293
294    // ── Trait basics ────────────────────────────────────────────
295
296    #[test]
297    fn trait_properties() {
298        let pp = new_pp(HashMap::new());
299        assert_eq!(pp.name(), "template");
300        assert_eq!(pp.transform_type(), TransformType::Generative);
301    }
302
303    #[test]
304    fn matches_default_extensions() {
305        let pp = new_pp(HashMap::new());
306        assert!(pp.matches_extension("config.toml.tmpl"));
307        assert!(pp.matches_extension("config.toml.template"));
308        assert!(!pp.matches_extension("config.toml"));
309        assert!(!pp.matches_extension("config.tmpl.bak"));
310    }
311
312    #[test]
313    fn matches_custom_extension() {
314        let pp =
315            TemplatePreprocessor::new(vec!["j2".into()], HashMap::new(), &make_pather()).unwrap();
316        assert!(pp.matches_extension("nginx.conf.j2"));
317        assert!(!pp.matches_extension("nginx.conf.tmpl"));
318    }
319
320    #[test]
321    fn stripped_name_removes_either_extension() {
322        let pp = new_pp(HashMap::new());
323        assert_eq!(pp.stripped_name("config.toml.tmpl"), "config.toml");
324        assert_eq!(pp.stripped_name("config.toml.template"), "config.toml");
325        assert_eq!(pp.stripped_name("already-stripped"), "already-stripped");
326    }
327
328    // ── Reserved variable names ─────────────────────────────────
329
330    #[test]
331    fn reserved_dodot_var_rejected() {
332        let mut vars = HashMap::new();
333        vars.insert("dodot".into(), "x".into());
334        let err = TemplatePreprocessor::new(vec!["tmpl".into()], vars, &make_pather()).unwrap_err();
335        assert!(
336            matches!(err, DodotError::TemplateReservedVar { ref name } if name == "dodot"),
337            "got: {err}"
338        );
339    }
340
341    #[test]
342    fn reserved_env_var_rejected() {
343        let mut vars = HashMap::new();
344        vars.insert("env".into(), "x".into());
345        let err = TemplatePreprocessor::new(vec!["tmpl".into()], vars, &make_pather()).unwrap_err();
346        assert!(matches!(err, DodotError::TemplateReservedVar { .. }));
347    }
348
349    // ── Rendering ───────────────────────────────────────────────
350
351    #[test]
352    fn renders_user_var() {
353        let env = crate::testing::TempEnvironment::builder()
354            .pack("app")
355            .file("greeting.tmpl", "hello {{ name }}")
356            .done()
357            .build();
358
359        let mut vars = HashMap::new();
360        vars.insert("name".into(), "Alice".into());
361        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
362
363        let source = env.dotfiles_root.join("app/greeting.tmpl");
364        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
365
366        assert_eq!(result.len(), 1);
367        assert_eq!(result[0].relative_path, PathBuf::from("greeting"));
368        assert_eq!(String::from_utf8_lossy(&result[0].content), "hello Alice");
369    }
370
371    #[test]
372    fn renders_dodot_builtins() {
373        let env = crate::testing::TempEnvironment::builder()
374            .pack("app")
375            .file(
376                "info.tmpl",
377                "home={{ dodot.home }} root={{ dodot.dotfiles_root }} os={{ dodot.os }}",
378            )
379            .done()
380            .build();
381
382        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
383            .unwrap();
384
385        let source = env.dotfiles_root.join("app/info.tmpl");
386        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
387
388        let rendered = String::from_utf8_lossy(&result[0].content);
389        let home = env.paths.home_dir().display().to_string();
390        let root = env.paths.dotfiles_root().display().to_string();
391        assert!(
392            rendered.contains(&format!("home={home}")),
393            "rendered: {rendered}"
394        );
395        assert!(
396            rendered.contains(&format!("root={root}")),
397            "rendered: {rendered}"
398        );
399        assert!(rendered.contains(&format!("os={}", std::env::consts::OS)));
400    }
401
402    #[test]
403    fn renders_env_var() {
404        // Use a likely-present env var with a fallback for determinism.
405        // PATH should always be set during tests.
406        let env = crate::testing::TempEnvironment::builder()
407            .pack("app")
408            .file("has_path.tmpl", "path={{ env.PATH }}")
409            .done()
410            .build();
411
412        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
413            .unwrap();
414
415        let source = env.dotfiles_root.join("app/has_path.tmpl");
416        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
417        let rendered = String::from_utf8_lossy(&result[0].content).into_owned();
418
419        assert!(rendered.starts_with("path="));
420        assert!(
421            rendered.len() > "path=".len(),
422            "env.PATH should have some value"
423        );
424    }
425
426    #[test]
427    fn missing_env_var_errors() {
428        let env = crate::testing::TempEnvironment::builder()
429            .pack("app")
430            .file("bad.tmpl", "value={{ env.DEFINITELY_UNSET_VAR_ZZZ_12345 }}")
431            .done()
432            .build();
433
434        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
435            .unwrap();
436
437        let source = env.dotfiles_root.join("app/bad.tmpl");
438        // Ensure the env var is genuinely unset
439        std::env::remove_var("DEFINITELY_UNSET_VAR_ZZZ_12345");
440        let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
441        assert!(
442            matches!(err, DodotError::TemplateRender { ref source_file, .. } if source_file == &source),
443            "got: {err}"
444        );
445    }
446
447    #[test]
448    fn undefined_user_var_errors() {
449        let env = crate::testing::TempEnvironment::builder()
450            .pack("app")
451            .file("bad.tmpl", "value={{ not_defined }}")
452            .done()
453            .build();
454
455        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
456            .unwrap();
457
458        let source = env.dotfiles_root.join("app/bad.tmpl");
459        let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
460        assert!(
461            matches!(err, DodotError::TemplateRender { ref message, .. } if message.contains("not_defined") || message.contains("undefined")),
462            "got: {err}"
463        );
464    }
465
466    #[test]
467    fn syntax_error_reports_source_file() {
468        let env = crate::testing::TempEnvironment::builder()
469            .pack("app")
470            .file("broken.tmpl", "{% if %}unterminated")
471            .done()
472            .build();
473
474        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
475            .unwrap();
476
477        let source = env.dotfiles_root.join("app/broken.tmpl");
478        let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
479        assert!(
480            matches!(err, DodotError::TemplateRender { ref source_file, .. } if source_file == &source),
481            "got: {err}"
482        );
483    }
484
485    #[test]
486    fn renders_filters_and_conditionals() {
487        let env = crate::testing::TempEnvironment::builder()
488            .pack("app")
489            .file(
490                "multi.tmpl",
491                "NAME={{ name | upper }}\n{% if show %}shown{% else %}hidden{% endif %}",
492            )
493            .done()
494            .build();
495
496        let mut vars = HashMap::new();
497        vars.insert("name".into(), "alice".into());
498        vars.insert("show".into(), "true".into());
499        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
500
501        let source = env.dotfiles_root.join("app/multi.tmpl");
502        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
503        let rendered = String::from_utf8_lossy(&result[0].content);
504        assert!(rendered.contains("NAME=ALICE"), "rendered: {rendered}");
505        assert!(rendered.contains("shown"), "rendered: {rendered}");
506    }
507
508    #[test]
509    fn renders_empty_template() {
510        let env = crate::testing::TempEnvironment::builder()
511            .pack("app")
512            .file("empty.tmpl", "")
513            .done()
514            .build();
515
516        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
517            .unwrap();
518
519        let source = env.dotfiles_root.join("app/empty.tmpl");
520        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
521        assert_eq!(result.len(), 1);
522        assert!(result[0].content.is_empty());
523    }
524
525    #[test]
526    fn renders_template_without_substitutions() {
527        let env = crate::testing::TempEnvironment::builder()
528            .pack("app")
529            .file("plain.tmpl", "just plain text\nno vars here")
530            .done()
531            .build();
532
533        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
534            .unwrap();
535
536        let source = env.dotfiles_root.join("app/plain.tmpl");
537        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
538        assert_eq!(
539            String::from_utf8_lossy(&result[0].content),
540            "just plain text\nno vars here"
541        );
542    }
543
544    #[test]
545    fn extension_with_leading_dot_still_matches() {
546        // Tolerate config that writes extensions as `.tmpl` instead of
547        // `tmpl`. Without the leading-dot trim, `.ends_with("..tmpl")`
548        // would silently never match and templates would not be processed.
549        let pp = TemplatePreprocessor::new(
550            vec![".tmpl".into(), ".template".into()],
551            HashMap::new(),
552            &make_pather(),
553        )
554        .unwrap();
555        assert!(pp.matches_extension("config.toml.tmpl"));
556        assert!(pp.matches_extension("app.template"));
557        assert_eq!(pp.stripped_name("config.toml.tmpl"), "config.toml");
558    }
559
560    #[test]
561    fn overlapping_suffix_does_not_false_match() {
562        // If a user configures an extension that is a suffix of another
563        // legitimate filename part (e.g. "mpl" as a suffix of "tmpl"),
564        // the matcher must require the literal "." boundary before the
565        // extension — otherwise "foo.tmpl" would be wrongly recognised
566        // as a "mpl" template and stripped to "foo.t".
567        let pp =
568            TemplatePreprocessor::new(vec!["mpl".into()], HashMap::new(), &make_pather()).unwrap();
569        assert!(!pp.matches_extension("foo.tmpl"));
570        assert_eq!(pp.stripped_name("foo.tmpl"), "foo.tmpl");
571
572        // Files that legitimately end with `.mpl` still match.
573        assert!(pp.matches_extension("song.mpl"));
574        assert_eq!(pp.stripped_name("song.mpl"), "song");
575    }
576
577    #[test]
578    fn overlapping_extensions_prefer_longest_match() {
579        // If a filename ends with both configured extensions (e.g.
580        // "foo.j2.tmpl" matches both "tmpl" and "j2.tmpl"), prefer the
581        // longest match so behaviour is deterministic regardless of
582        // config ordering.
583        let pp = TemplatePreprocessor::new(
584            vec!["tmpl".into(), "j2.tmpl".into()],
585            HashMap::new(),
586            &make_pather(),
587        )
588        .unwrap();
589        assert_eq!(pp.stripped_name("config.j2.tmpl"), "config");
590
591        // Opposite config order yields the same result.
592        let pp_reversed = TemplatePreprocessor::new(
593            vec!["j2.tmpl".into(), "tmpl".into()],
594            HashMap::new(),
595            &make_pather(),
596        )
597        .unwrap();
598        assert_eq!(pp_reversed.stripped_name("config.j2.tmpl"), "config");
599    }
600
601    #[test]
602    fn missing_dodot_key_raises_strict_error() {
603        // The `build_dodot_context` fix omits `hostname`/`username` from
604        // the map when they cannot be detected (rather than injecting
605        // empty strings, which would silently deploy broken configs).
606        //
607        // We avoid manipulating `std::env` here (not thread-safe; other
608        // tests read USER) and instead verify the underlying invariant:
609        // any missing key on the `dodot` object triggers the
610        // strict-undefined error. Under this invariant, an undetected
611        // username/hostname behaves the same as any other missing key.
612        let env = crate::testing::TempEnvironment::builder()
613            .pack("app")
614            .file("uses_missing.tmpl", "value={{ dodot.nonexistent_key_zzz }}")
615            .done()
616            .build();
617
618        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
619            .unwrap();
620
621        let source = env.dotfiles_root.join("app/uses_missing.tmpl");
622        let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
623        assert!(
624            matches!(err, DodotError::TemplateRender { .. }),
625            "accessing a missing dodot.* key must error, got: {err}"
626        );
627    }
628
629    #[test]
630    fn missing_dodot_key_can_be_defaulted() {
631        // Ergonomic escape hatch: Jinja's `default` filter lets users
632        // tolerate potentially-missing fields without raising.
633        let env = crate::testing::TempEnvironment::builder()
634            .pack("app")
635            .file(
636                "defaulted.tmpl",
637                "value={{ dodot.nonexistent_key_zzz | default(\"unknown\") }}",
638            )
639            .done()
640            .build();
641
642        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
643            .unwrap();
644
645        let source = env.dotfiles_root.join("app/defaulted.tmpl");
646        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
647        assert_eq!(String::from_utf8_lossy(&result[0].content), "value=unknown");
648    }
649
650    #[test]
651    fn env_var_default_filter_bridges_missing_vars() {
652        // The documented escape hatch for optional env vars is
653        // `{{ env.NAME | default("...") }}`. If `default` doesn't work,
654        // users have no way to reference env vars that might not be set —
655        // so this specific pattern must stay functional.
656        let env = crate::testing::TempEnvironment::builder()
657            .pack("app")
658            .file(
659                "cfg.tmpl",
660                "editor={{ env.DODOT_MISSING_VAR_ZZZ | default(\"vim\") }}",
661            )
662            .done()
663            .build();
664
665        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
666            .unwrap();
667
668        let source = env.dotfiles_root.join("app/cfg.tmpl");
669        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
670        assert_eq!(String::from_utf8_lossy(&result[0].content), "editor=vim");
671    }
672
673    #[test]
674    fn renders_for_loop_over_user_var() {
675        // Regression guard: MiniJinja supports loops, but we want to
676        // confirm that user-defined vars (which are plain strings) still
677        // work inside a minimal control-flow structure. Strings are
678        // iterable as sequences of characters — confirm our value-layer
679        // doesn't silently block that.
680        let env = crate::testing::TempEnvironment::builder()
681            .pack("app")
682            .file(
683                "loop.tmpl",
684                "{% for c in word %}{{ c | upper }}{% endfor %}",
685            )
686            .done()
687            .build();
688
689        let mut vars = HashMap::new();
690        vars.insert("word".into(), "hi".into());
691        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
692
693        let source = env.dotfiles_root.join("app/loop.tmpl");
694        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
695        assert_eq!(String::from_utf8_lossy(&result[0].content), "HI");
696    }
697
698    #[test]
699    fn renders_unicode_content_and_vars() {
700        // Template content and user vars may contain non-ASCII. Confirm
701        // both pass through without mangling.
702        let env = crate::testing::TempEnvironment::builder()
703            .pack("app")
704            .file("greet.tmpl", "こんにちは {{ name }}! 🎉")
705            .done()
706            .build();
707
708        let mut vars = HashMap::new();
709        vars.insert("name".into(), "世界".into());
710        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
711
712        let source = env.dotfiles_root.join("app/greet.tmpl");
713        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
714        assert_eq!(
715            String::from_utf8_lossy(&result[0].content),
716            "こんにちは 世界! 🎉"
717        );
718    }
719
720    #[test]
721    fn rendering_is_deterministic_across_calls() {
722        // Calling `expand` multiple times with the same inputs must
723        // produce byte-identical output. This guards against any
724        // hidden state leaking between renders (e.g. a stale globals
725        // cache, a reseeded RNG, or a leaked side-effect into the
726        // Environment).
727        let env = crate::testing::TempEnvironment::builder()
728            .pack("app")
729            .file(
730                "cfg.tmpl",
731                "name={{ name }} os={{ dodot.os }} home={{ dodot.home }}",
732            )
733            .done()
734            .build();
735
736        let mut vars = HashMap::new();
737        vars.insert("name".into(), "Alice".into());
738        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
739
740        let source = env.dotfiles_root.join("app/cfg.tmpl");
741        let first = pp.expand(&source, env.fs.as_ref()).unwrap();
742        let second = pp.expand(&source, env.fs.as_ref()).unwrap();
743        let third = pp.expand(&source, env.fs.as_ref()).unwrap();
744
745        assert_eq!(first[0].content, second[0].content);
746        assert_eq!(second[0].content, third[0].content);
747    }
748
749    #[test]
750    fn stripped_name_of_literal_extension_returns_empty() {
751        // Edge case recording the current (defensive) behavior: a file
752        // named exactly `.tmpl` (extension and nothing else) strips to
753        // the empty string. In normal packs the scanner filters dotfiles
754        // out before they reach the preprocessor, so this won't happen
755        // via user flows. But a misconfigured preprocessor extension or
756        // an archive entry with no stem could still produce an empty
757        // path downstream, and the pipeline is expected to reject that
758        // with a useful error — see
759        // `pipeline::rejects_empty_path_from_preprocessor`.
760        let pp = new_pp(HashMap::new());
761        assert_eq!(pp.stripped_name(".tmpl"), "");
762        assert!(pp.matches_extension(".tmpl"));
763    }
764
765    #[test]
766    fn build_dodot_context_omits_undetected_optional_keys() {
767        // Directly exercise the map-building helper: given a Pather but
768        // the detection helpers return None (simulated via testing the
769        // helper return invariants), verify the map structure.
770        //
771        // Since `detect_username`/`detect_hostname` read real env/system
772        // state, we can only assert: if they return Some, the key is
773        // present; if they return None, the key is absent.
774        let ctx = build_dodot_context(&make_pather());
775
776        // These are always present:
777        assert!(ctx.contains_key("os"));
778        assert!(ctx.contains_key("arch"));
779        assert!(ctx.contains_key("home"));
780        assert!(ctx.contains_key("dotfiles_root"));
781
782        // Optional keys: present iff the detection helper returned Some.
783        assert_eq!(ctx.contains_key("username"), detect_username().is_some());
784        assert_eq!(ctx.contains_key("hostname"), detect_hostname().is_some());
785    }
786}