Skip to main content

dodot_lib/preprocessing/
template.rs

1//! Template preprocessor — renders Jinja2-style templates via MiniJinja
2//! through burgertocow's [`Tracker`].
3//!
4//! Matches files with configurable extensions (default: `.tmpl`,
5//! `.template`), renders them against a variable context with three
6//! namespaces:
7//!
8//! - `dodot.*` — built-in values (os, arch, hostname, username, home,
9//!   dotfiles_root), computed once at preprocessor construction.
10//! - `env.*` — dynamic lookup of process environment variables.
11//! - bare names — user-defined variables from
12//!   `[preprocessor.template.vars]` in `.dodot.toml`.
13//!
14//! Uses MiniJinja strict undefined-behaviour: references to missing vars
15//! raise a render error rather than silently producing empty strings.
16//!
17//! # Tracked render
18//!
19//! Rendering goes through [`burgertocow::Tracker`] rather than a raw
20//! `minijinja::Environment`. The tracker installs a custom formatter that
21//! wraps every variable emission in marker bytes, producing a
22//! [`TrackedRender`] alongside the visible output. The visible output is
23//! identical to the plain-MiniJinja path (modulo the
24//! `keep_trailing_newline` setting that Tracker also applies). The
25//! marker-annotated string is persisted in the baseline cache so the
26//! reverse-merge pipeline (`dodot transform check`, the clean filter)
27//! can compute template-space diffs without re-rendering — re-rendering
28//! at clean-filter time would re-trigger any secret-provider auth
29//! prompts on every `git status`.
30
31use std::collections::{BTreeMap, HashMap};
32use std::path::{Path, PathBuf};
33use std::sync::{Arc, OnceLock};
34
35use burgertocow::Tracker;
36use minijinja::value::{Enumerator, Object, ObjectRepr, Value};
37use minijinja::UndefinedBehavior;
38use sha2::{Digest, Sha256};
39
40use crate::fs::Fs;
41use crate::paths::Pather;
42use crate::preprocessing::{ExpandedFile, Preprocessor, TransformType};
43use crate::{DodotError, Result};
44
45/// Reserved top-level variable names.
46const RESERVED_VARS: &[&str] = &["dodot", "env"];
47
48/// MiniJinja object that looks up process environment variables on
49/// attribute access. `{{ env.SHELL }}` becomes `std::env::var("SHELL")`.
50/// Missing env vars return `None` from `get_value`, which MiniJinja
51/// treats as an undefined attribute (a render error under strict mode).
52/// For optional variables, use `{{ env.NAME | default("...") }}`.
53#[derive(Debug)]
54struct EnvLookup;
55
56impl Object for EnvLookup {
57    fn repr(self: &Arc<Self>) -> ObjectRepr {
58        ObjectRepr::Map
59    }
60
61    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
62        let name = key.as_str()?;
63        std::env::var(name).ok().map(Value::from)
64    }
65
66    fn enumerate(self: &Arc<Self>) -> Enumerator {
67        // Don't enumerate environment variables — printing `{{ env }}`
68        // as a whole shouldn't dump the whole process environment.
69        Enumerator::NonEnumerable
70    }
71}
72
73/// Template rendering preprocessor. Generative (one-way) transform.
74///
75/// Holds the resolved `dodot.*` map, the user-defined variables, and a
76/// pre-computed context hash. Each `expand` call constructs a fresh
77/// [`Tracker`], installs the namespaces, registers the source file as a
78/// named template, and renders. We don't share the `Tracker` across
79/// renders because `add_template` requires `&mut` while `Preprocessor::
80/// expand` runs through a `&self` trait method — a per-call tracker is
81/// the simplest way to keep the pipeline's existing concurrency shape.
82pub struct TemplatePreprocessor {
83    extensions: Vec<String>,
84    dodot_ns: BTreeMap<String, String>,
85    user_vars: BTreeMap<String, String>,
86    /// SHA-256 of the deterministic projection of `dodot_ns` and
87    /// `user_vars` (sorted keys, length-prefixed). Reused as the
88    /// `context_hash` for every render this preprocessor performs.
89    ///
90    /// `env.*` references are intentionally **not** part of the
91    /// context hash and tracking them is out of scope by design — see
92    /// `preprocessing-pipeline.lex` §6.4. The cache contract is
93    /// "same source bytes + same `dodot.*` namespace + same
94    /// `user_vars` → same output." The `env.*` namespace is the
95    /// explicitly live-read zone; rotating a referenced env var does
96    /// not invalidate the cache, and users pick up the new value via
97    /// `dodot up --force`. Stable values that should participate in
98    /// invalidation belong in `[preprocessor.template.vars]`
99    /// (`user_vars`), not `env.*`.
100    context_hash: [u8; 32],
101}
102
103impl std::fmt::Debug for TemplatePreprocessor {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        f.debug_struct("TemplatePreprocessor")
106            .field("extensions", &self.extensions)
107            .finish_non_exhaustive()
108    }
109}
110
111impl TemplatePreprocessor {
112    /// Construct a new template preprocessor.
113    ///
114    /// Validates that no user-defined variable uses a reserved name
115    /// (`dodot` or `env`). Resolves the `dodot.*` builtins from
116    /// `pather` + system info and computes the context hash now so
117    /// every subsequent `expand` reuses the same value.
118    ///
119    /// Extensions are normalized at construction: a leading dot (e.g.
120    /// `".tmpl"`) is stripped so both `"tmpl"` and `".tmpl"` work.
121    pub fn new(
122        extensions: Vec<String>,
123        user_vars: HashMap<String, String>,
124        pather: &dyn Pather,
125    ) -> Result<Self> {
126        for name in user_vars.keys() {
127            if RESERVED_VARS.contains(&name.as_str()) {
128                return Err(DodotError::TemplateReservedVar { name: name.clone() });
129            }
130        }
131
132        let extensions: Vec<String> = extensions
133            .into_iter()
134            .map(|e| e.trim_start_matches('.').to_string())
135            .collect();
136
137        let dodot_ns = build_dodot_context(pather);
138        let user_vars: BTreeMap<String, String> = user_vars.into_iter().collect();
139        let context_hash = compute_context_hash(&dodot_ns, &user_vars);
140
141        Ok(Self {
142            extensions,
143            dodot_ns,
144            user_vars,
145            context_hash,
146        })
147    }
148
149    /// Build a fresh tracker with this preprocessor's namespaces
150    /// installed and `UndefinedBehavior::Strict` set. Called per render
151    /// because `Tracker::add_template` requires `&mut self`.
152    fn make_tracker(&self) -> Tracker {
153        let mut tracker = Tracker::new();
154        let env = tracker.env_mut();
155        env.set_undefined_behavior(UndefinedBehavior::Strict);
156        env.add_global("dodot", Value::from(self.dodot_ns.clone()));
157        env.add_global("env", Value::from_object(EnvLookup));
158        for (name, val) in &self.user_vars {
159            env.add_global(name.clone(), Value::from(val.clone()));
160        }
161        tracker
162    }
163}
164
165impl Preprocessor for TemplatePreprocessor {
166    fn name(&self) -> &str {
167        "template"
168    }
169
170    fn transform_type(&self) -> TransformType {
171        TransformType::Generative
172    }
173
174    fn supports_reverse_merge(&self) -> bool {
175        // Templates emit a tracked_render and produce baselines; the
176        // reverse-merge pipeline (transform check, clean filter) reads
177        // those baselines to write template-space diffs back to source.
178        true
179    }
180
181    fn matches_extension(&self, filename: &str) -> bool {
182        // Extensions are normalized (no leading dot) at construction.
183        // We require a literal "." before the extension to avoid e.g.
184        // "mpl" matching "foo.tmpl". No per-call allocation.
185        self.extensions.iter().any(|ext| {
186            filename
187                .strip_suffix(ext.as_str())
188                .is_some_and(|prefix| prefix.ends_with('.'))
189        })
190    }
191
192    fn stripped_name(&self, filename: &str) -> String {
193        // If multiple configured extensions match (e.g. "tmpl" and
194        // "j2.tmpl" both suffixes of the same filename), prefer the
195        // longest so behaviour is deterministic and independent of
196        // config ordering.
197        self.extensions
198            .iter()
199            .filter_map(|ext| {
200                filename
201                    .strip_suffix(ext.as_str())
202                    .and_then(|prefix| prefix.strip_suffix('.'))
203                    .map(|stripped| (ext.len(), stripped))
204            })
205            .max_by_key(|(len, _)| *len)
206            .map(|(_, stripped)| stripped.to_string())
207            .unwrap_or_else(|| filename.to_string())
208    }
209
210    fn expand(&self, source: &Path, fs: &dyn Fs) -> Result<Vec<ExpandedFile>> {
211        let template_str = fs.read_to_string(source)?;
212
213        // Use the source file's path as the template name. Tracker
214        // requires named templates; the path is unique per file and
215        // surfaces sensibly in any error MiniJinja produces.
216        let template_name = source.to_string_lossy().into_owned();
217
218        let mut tracker = self.make_tracker();
219        tracker
220            .add_template(&template_name, &template_str)
221            .map_err(|e| DodotError::TemplateRender {
222                source_file: source.to_path_buf(),
223                message: format_minijinja_error(&e),
224            })?;
225
226        let tracked =
227            tracker
228                .render(&template_name, ())
229                .map_err(|e| DodotError::TemplateRender {
230                    source_file: source.to_path_buf(),
231                    message: format_minijinja_error(&e),
232                })?;
233
234        let filename = source
235            .file_name()
236            .unwrap_or_default()
237            .to_string_lossy()
238            .into_owned();
239        let stripped = self.stripped_name(&filename);
240
241        let (rendered, tracked_str) = tracked.into_parts();
242
243        Ok(vec![ExpandedFile {
244            relative_path: PathBuf::from(stripped),
245            content: rendered.into_bytes(),
246            is_dir: false,
247            tracked_render: Some(tracked_str),
248            context_hash: Some(self.context_hash),
249        }])
250    }
251}
252
253/// Produce a deterministic SHA-256 over the rendering context.
254///
255/// The hash is order-independent (BTreeMap iteration is sorted) and
256/// includes both the `dodot.*` namespace and the user-defined variables.
257/// Layout: each entry is encoded as `<ns>\x1F<key>\x1F<value>\x1E` so
258/// rearranging the boundaries between any two adjacent fields cannot
259/// produce a collision (`\x1E` and `\x1F` are the same control bytes
260/// burgertocow uses internally; they don't appear in normal
261/// configuration content).
262fn compute_context_hash(
263    dodot_ns: &BTreeMap<String, String>,
264    user_vars: &BTreeMap<String, String>,
265) -> [u8; 32] {
266    let mut hasher = Sha256::new();
267    for (k, v) in dodot_ns {
268        hasher.update(b"dodot");
269        hasher.update([0x1f]);
270        hasher.update(k.as_bytes());
271        hasher.update([0x1f]);
272        hasher.update(v.as_bytes());
273        hasher.update([0x1e]);
274    }
275    for (k, v) in user_vars {
276        hasher.update(b"vars");
277        hasher.update([0x1f]);
278        hasher.update(k.as_bytes());
279        hasher.update([0x1f]);
280        hasher.update(v.as_bytes());
281        hasher.update([0x1e]);
282    }
283    hasher.finalize().into()
284}
285
286/// Build the `dodot.*` namespace map.
287///
288/// Keys we can always resolve (os, arch, home, dotfiles_root) are
289/// always inserted. Keys that depend on environment detection
290/// (hostname, username) are inserted only when a non-empty value is
291/// found — otherwise they are omitted so that template access via
292/// `{{ dodot.hostname }}` triggers a strict-undefined render error,
293/// rather than silently injecting an empty string. Users who want a
294/// fallback can write `{{ dodot.hostname | default("unknown") }}`.
295///
296/// Hostname and username detection is cached process-wide via
297/// [`OnceLock`] so that building a template preprocessor for each pack
298/// does not respawn `hostname(1)` every time.
299fn build_dodot_context(pather: &dyn Pather) -> BTreeMap<String, String> {
300    let mut ctx = BTreeMap::new();
301    ctx.insert("os".into(), std::env::consts::OS.into());
302    ctx.insert("arch".into(), std::env::consts::ARCH.into());
303    if let Some(h) = cached_hostname() {
304        ctx.insert("hostname".into(), h.clone());
305    }
306    if let Some(u) = cached_username() {
307        ctx.insert("username".into(), u.clone());
308    }
309    ctx.insert("home".into(), pather.home_dir().display().to_string());
310    ctx.insert(
311        "dotfiles_root".into(),
312        pather.dotfiles_root().display().to_string(),
313    );
314    ctx
315}
316
317/// Process-wide cached hostname. First call resolves and pins the
318/// result for the lifetime of the process.
319fn cached_hostname() -> Option<&'static String> {
320    static CACHE: OnceLock<Option<String>> = OnceLock::new();
321    CACHE.get_or_init(detect_hostname).as_ref()
322}
323
324/// Process-wide cached username. Same caching semantics as
325/// [`cached_hostname`].
326fn cached_username() -> Option<&'static String> {
327    static CACHE: OnceLock<Option<String>> = OnceLock::new();
328    CACHE.get_or_init(detect_username).as_ref()
329}
330
331fn detect_hostname() -> Option<String> {
332    if let Ok(h) = std::env::var("HOSTNAME") {
333        if !h.is_empty() {
334            return Some(h);
335        }
336    }
337    // Fallback: shell out. Ignore errors.
338    let output = std::process::Command::new("hostname").output().ok()?;
339    if !output.status.success() {
340        return None;
341    }
342    let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
343    if name.is_empty() {
344        None
345    } else {
346        Some(name)
347    }
348}
349
350fn detect_username() -> Option<String> {
351    for var in ["USER", "USERNAME", "LOGNAME"] {
352        if let Ok(v) = std::env::var(var) {
353            if !v.is_empty() {
354                return Some(v);
355            }
356        }
357    }
358    None
359}
360
361/// Compact a MiniJinja error into a single human-readable string with
362/// a suggestion for the common "undefined variable" case.
363fn format_minijinja_error(err: &minijinja::Error) -> String {
364    use minijinja::ErrorKind;
365
366    let base = match err.kind() {
367        ErrorKind::UndefinedError => {
368            // Best-effort: MiniJinja's error message already says
369            // "undefined value" but doesn't always name the variable.
370            // The Display impl includes line info.
371            let mut msg = err.to_string();
372            msg.push_str(
373                "\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)",
374            );
375            msg
376        }
377        ErrorKind::SyntaxError => err.to_string(),
378        _ => err.to_string(),
379    };
380
381    // MiniJinja sometimes appends "referenced from" traces; strip them
382    // to keep the error message compact.
383    base.lines().take(10).collect::<Vec<_>>().join("\n  ")
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::paths::XdgPather;
390
391    fn make_pather() -> XdgPather {
392        XdgPather::builder()
393            .home("/home/alice")
394            .dotfiles_root("/home/alice/dotfiles")
395            .xdg_config_home("/home/alice/.config")
396            .data_dir("/home/alice/.local/share/dodot")
397            .build()
398            .unwrap()
399    }
400
401    fn new_pp(vars: HashMap<String, String>) -> TemplatePreprocessor {
402        TemplatePreprocessor::new(vec!["tmpl".into(), "template".into()], vars, &make_pather())
403            .unwrap()
404    }
405
406    // ── Trait basics ────────────────────────────────────────────
407
408    #[test]
409    fn trait_properties() {
410        let pp = new_pp(HashMap::new());
411        assert_eq!(pp.name(), "template");
412        assert_eq!(pp.transform_type(), TransformType::Generative);
413    }
414
415    #[test]
416    fn matches_default_extensions() {
417        let pp = new_pp(HashMap::new());
418        assert!(pp.matches_extension("config.toml.tmpl"));
419        assert!(pp.matches_extension("config.toml.template"));
420        assert!(!pp.matches_extension("config.toml"));
421        assert!(!pp.matches_extension("config.tmpl.bak"));
422    }
423
424    #[test]
425    fn matches_custom_extension() {
426        let pp =
427            TemplatePreprocessor::new(vec!["j2".into()], HashMap::new(), &make_pather()).unwrap();
428        assert!(pp.matches_extension("nginx.conf.j2"));
429        assert!(!pp.matches_extension("nginx.conf.tmpl"));
430    }
431
432    #[test]
433    fn stripped_name_removes_either_extension() {
434        let pp = new_pp(HashMap::new());
435        assert_eq!(pp.stripped_name("config.toml.tmpl"), "config.toml");
436        assert_eq!(pp.stripped_name("config.toml.template"), "config.toml");
437        assert_eq!(pp.stripped_name("already-stripped"), "already-stripped");
438    }
439
440    // ── Reserved variable names ─────────────────────────────────
441
442    #[test]
443    fn reserved_dodot_var_rejected() {
444        let mut vars = HashMap::new();
445        vars.insert("dodot".into(), "x".into());
446        let err = TemplatePreprocessor::new(vec!["tmpl".into()], vars, &make_pather()).unwrap_err();
447        assert!(
448            matches!(err, DodotError::TemplateReservedVar { ref name } if name == "dodot"),
449            "got: {err}"
450        );
451    }
452
453    #[test]
454    fn reserved_env_var_rejected() {
455        let mut vars = HashMap::new();
456        vars.insert("env".into(), "x".into());
457        let err = TemplatePreprocessor::new(vec!["tmpl".into()], vars, &make_pather()).unwrap_err();
458        assert!(matches!(err, DodotError::TemplateReservedVar { .. }));
459    }
460
461    // ── Rendering ───────────────────────────────────────────────
462
463    #[test]
464    fn renders_user_var() {
465        let env = crate::testing::TempEnvironment::builder()
466            .pack("app")
467            .file("greeting.tmpl", "hello {{ name }}")
468            .done()
469            .build();
470
471        let mut vars = HashMap::new();
472        vars.insert("name".into(), "Alice".into());
473        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
474
475        let source = env.dotfiles_root.join("app/greeting.tmpl");
476        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
477
478        assert_eq!(result.len(), 1);
479        assert_eq!(result[0].relative_path, PathBuf::from("greeting"));
480        assert_eq!(String::from_utf8_lossy(&result[0].content), "hello Alice");
481    }
482
483    #[test]
484    fn renders_dodot_builtins() {
485        let env = crate::testing::TempEnvironment::builder()
486            .pack("app")
487            .file(
488                "info.tmpl",
489                "home={{ dodot.home }} root={{ dodot.dotfiles_root }} os={{ dodot.os }}",
490            )
491            .done()
492            .build();
493
494        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
495            .unwrap();
496
497        let source = env.dotfiles_root.join("app/info.tmpl");
498        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
499
500        let rendered = String::from_utf8_lossy(&result[0].content);
501        let home = env.paths.home_dir().display().to_string();
502        let root = env.paths.dotfiles_root().display().to_string();
503        assert!(
504            rendered.contains(&format!("home={home}")),
505            "rendered: {rendered}"
506        );
507        assert!(
508            rendered.contains(&format!("root={root}")),
509            "rendered: {rendered}"
510        );
511        assert!(rendered.contains(&format!("os={}", std::env::consts::OS)));
512    }
513
514    #[test]
515    fn renders_env_var() {
516        // Use a likely-present env var with a fallback for determinism.
517        // PATH should always be set during tests.
518        let env = crate::testing::TempEnvironment::builder()
519            .pack("app")
520            .file("has_path.tmpl", "path={{ env.PATH }}")
521            .done()
522            .build();
523
524        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
525            .unwrap();
526
527        let source = env.dotfiles_root.join("app/has_path.tmpl");
528        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
529        let rendered = String::from_utf8_lossy(&result[0].content).into_owned();
530
531        assert!(rendered.starts_with("path="));
532        assert!(
533            rendered.len() > "path=".len(),
534            "env.PATH should have some value"
535        );
536    }
537
538    #[test]
539    fn missing_env_var_errors() {
540        let env = crate::testing::TempEnvironment::builder()
541            .pack("app")
542            .file("bad.tmpl", "value={{ env.DEFINITELY_UNSET_VAR_ZZZ_12345 }}")
543            .done()
544            .build();
545
546        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
547            .unwrap();
548
549        let source = env.dotfiles_root.join("app/bad.tmpl");
550        // Ensure the env var is genuinely unset
551        std::env::remove_var("DEFINITELY_UNSET_VAR_ZZZ_12345");
552        let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
553        assert!(
554            matches!(err, DodotError::TemplateRender { ref source_file, .. } if source_file == &source),
555            "got: {err}"
556        );
557    }
558
559    #[test]
560    fn undefined_user_var_errors() {
561        let env = crate::testing::TempEnvironment::builder()
562            .pack("app")
563            .file("bad.tmpl", "value={{ not_defined }}")
564            .done()
565            .build();
566
567        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
568            .unwrap();
569
570        let source = env.dotfiles_root.join("app/bad.tmpl");
571        let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
572        assert!(
573            matches!(err, DodotError::TemplateRender { ref message, .. } if message.contains("not_defined") || message.contains("undefined")),
574            "got: {err}"
575        );
576    }
577
578    #[test]
579    fn syntax_error_reports_source_file() {
580        let env = crate::testing::TempEnvironment::builder()
581            .pack("app")
582            .file("broken.tmpl", "{% if %}unterminated")
583            .done()
584            .build();
585
586        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
587            .unwrap();
588
589        let source = env.dotfiles_root.join("app/broken.tmpl");
590        let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
591        assert!(
592            matches!(err, DodotError::TemplateRender { ref source_file, .. } if source_file == &source),
593            "got: {err}"
594        );
595    }
596
597    #[test]
598    fn renders_filters_and_conditionals() {
599        let env = crate::testing::TempEnvironment::builder()
600            .pack("app")
601            .file(
602                "multi.tmpl",
603                "NAME={{ name | upper }}\n{% if show %}shown{% else %}hidden{% endif %}",
604            )
605            .done()
606            .build();
607
608        let mut vars = HashMap::new();
609        vars.insert("name".into(), "alice".into());
610        vars.insert("show".into(), "true".into());
611        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
612
613        let source = env.dotfiles_root.join("app/multi.tmpl");
614        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
615        let rendered = String::from_utf8_lossy(&result[0].content);
616        assert!(rendered.contains("NAME=ALICE"), "rendered: {rendered}");
617        assert!(rendered.contains("shown"), "rendered: {rendered}");
618    }
619
620    #[test]
621    fn renders_empty_template() {
622        let env = crate::testing::TempEnvironment::builder()
623            .pack("app")
624            .file("empty.tmpl", "")
625            .done()
626            .build();
627
628        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
629            .unwrap();
630
631        let source = env.dotfiles_root.join("app/empty.tmpl");
632        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
633        assert_eq!(result.len(), 1);
634        assert!(result[0].content.is_empty());
635    }
636
637    #[test]
638    fn renders_template_without_substitutions() {
639        let env = crate::testing::TempEnvironment::builder()
640            .pack("app")
641            .file("plain.tmpl", "just plain text\nno vars here")
642            .done()
643            .build();
644
645        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
646            .unwrap();
647
648        let source = env.dotfiles_root.join("app/plain.tmpl");
649        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
650        assert_eq!(
651            String::from_utf8_lossy(&result[0].content),
652            "just plain text\nno vars here"
653        );
654    }
655
656    #[test]
657    fn extension_with_leading_dot_still_matches() {
658        // Tolerate config that writes extensions as `.tmpl` instead of
659        // `tmpl`. Without the leading-dot trim, `.ends_with("..tmpl")`
660        // would silently never match and templates would not be processed.
661        let pp = TemplatePreprocessor::new(
662            vec![".tmpl".into(), ".template".into()],
663            HashMap::new(),
664            &make_pather(),
665        )
666        .unwrap();
667        assert!(pp.matches_extension("config.toml.tmpl"));
668        assert!(pp.matches_extension("app.template"));
669        assert_eq!(pp.stripped_name("config.toml.tmpl"), "config.toml");
670    }
671
672    #[test]
673    fn overlapping_suffix_does_not_false_match() {
674        // If a user configures an extension that is a suffix of another
675        // legitimate filename part (e.g. "mpl" as a suffix of "tmpl"),
676        // the matcher must require the literal "." boundary before the
677        // extension — otherwise "foo.tmpl" would be wrongly recognised
678        // as a "mpl" template and stripped to "foo.t".
679        let pp =
680            TemplatePreprocessor::new(vec!["mpl".into()], HashMap::new(), &make_pather()).unwrap();
681        assert!(!pp.matches_extension("foo.tmpl"));
682        assert_eq!(pp.stripped_name("foo.tmpl"), "foo.tmpl");
683
684        // Files that legitimately end with `.mpl` still match.
685        assert!(pp.matches_extension("song.mpl"));
686        assert_eq!(pp.stripped_name("song.mpl"), "song");
687    }
688
689    #[test]
690    fn overlapping_extensions_prefer_longest_match() {
691        // If a filename ends with both configured extensions (e.g.
692        // "foo.j2.tmpl" matches both "tmpl" and "j2.tmpl"), prefer the
693        // longest match so behaviour is deterministic regardless of
694        // config ordering.
695        let pp = TemplatePreprocessor::new(
696            vec!["tmpl".into(), "j2.tmpl".into()],
697            HashMap::new(),
698            &make_pather(),
699        )
700        .unwrap();
701        assert_eq!(pp.stripped_name("config.j2.tmpl"), "config");
702
703        // Opposite config order yields the same result.
704        let pp_reversed = TemplatePreprocessor::new(
705            vec!["j2.tmpl".into(), "tmpl".into()],
706            HashMap::new(),
707            &make_pather(),
708        )
709        .unwrap();
710        assert_eq!(pp_reversed.stripped_name("config.j2.tmpl"), "config");
711    }
712
713    #[test]
714    fn missing_dodot_key_raises_strict_error() {
715        // The `build_dodot_context` fix omits `hostname`/`username` from
716        // the map when they cannot be detected (rather than injecting
717        // empty strings, which would silently deploy broken configs).
718        //
719        // We avoid manipulating `std::env` here (not thread-safe; other
720        // tests read USER) and instead verify the underlying invariant:
721        // any missing key on the `dodot` object triggers the
722        // strict-undefined error. Under this invariant, an undetected
723        // username/hostname behaves the same as any other missing key.
724        let env = crate::testing::TempEnvironment::builder()
725            .pack("app")
726            .file("uses_missing.tmpl", "value={{ dodot.nonexistent_key_zzz }}")
727            .done()
728            .build();
729
730        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
731            .unwrap();
732
733        let source = env.dotfiles_root.join("app/uses_missing.tmpl");
734        let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
735        assert!(
736            matches!(err, DodotError::TemplateRender { .. }),
737            "accessing a missing dodot.* key must error, got: {err}"
738        );
739    }
740
741    #[test]
742    fn missing_dodot_key_can_be_defaulted() {
743        // Ergonomic escape hatch: Jinja's `default` filter lets users
744        // tolerate potentially-missing fields without raising.
745        let env = crate::testing::TempEnvironment::builder()
746            .pack("app")
747            .file(
748                "defaulted.tmpl",
749                "value={{ dodot.nonexistent_key_zzz | default(\"unknown\") }}",
750            )
751            .done()
752            .build();
753
754        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
755            .unwrap();
756
757        let source = env.dotfiles_root.join("app/defaulted.tmpl");
758        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
759        assert_eq!(String::from_utf8_lossy(&result[0].content), "value=unknown");
760    }
761
762    #[test]
763    fn env_var_default_filter_bridges_missing_vars() {
764        // The documented escape hatch for optional env vars is
765        // `{{ env.NAME | default("...") }}`. If `default` doesn't work,
766        // users have no way to reference env vars that might not be set —
767        // so this specific pattern must stay functional.
768        let env = crate::testing::TempEnvironment::builder()
769            .pack("app")
770            .file(
771                "cfg.tmpl",
772                "editor={{ env.DODOT_MISSING_VAR_ZZZ | default(\"vim\") }}",
773            )
774            .done()
775            .build();
776
777        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
778            .unwrap();
779
780        let source = env.dotfiles_root.join("app/cfg.tmpl");
781        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
782        assert_eq!(String::from_utf8_lossy(&result[0].content), "editor=vim");
783    }
784
785    #[test]
786    fn renders_for_loop_over_user_var() {
787        // Regression guard: MiniJinja supports loops, but we want to
788        // confirm that user-defined vars (which are plain strings) still
789        // work inside a minimal control-flow structure. Strings are
790        // iterable as sequences of characters — confirm our value-layer
791        // doesn't silently block that.
792        let env = crate::testing::TempEnvironment::builder()
793            .pack("app")
794            .file(
795                "loop.tmpl",
796                "{% for c in word %}{{ c | upper }}{% endfor %}",
797            )
798            .done()
799            .build();
800
801        let mut vars = HashMap::new();
802        vars.insert("word".into(), "hi".into());
803        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
804
805        let source = env.dotfiles_root.join("app/loop.tmpl");
806        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
807        assert_eq!(String::from_utf8_lossy(&result[0].content), "HI");
808    }
809
810    #[test]
811    fn renders_unicode_content_and_vars() {
812        // Template content and user vars may contain non-ASCII. Confirm
813        // both pass through without mangling.
814        let env = crate::testing::TempEnvironment::builder()
815            .pack("app")
816            .file("greet.tmpl", "こんにちは {{ name }}! 🎉")
817            .done()
818            .build();
819
820        let mut vars = HashMap::new();
821        vars.insert("name".into(), "世界".into());
822        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
823
824        let source = env.dotfiles_root.join("app/greet.tmpl");
825        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
826        assert_eq!(
827            String::from_utf8_lossy(&result[0].content),
828            "こんにちは 世界! 🎉"
829        );
830    }
831
832    #[test]
833    fn rendering_is_deterministic_across_calls() {
834        // Calling `expand` multiple times with the same inputs must
835        // produce byte-identical output. This guards against any
836        // hidden state leaking between renders (e.g. a stale globals
837        // cache, a reseeded RNG, or a leaked side-effect into the
838        // Environment).
839        let env = crate::testing::TempEnvironment::builder()
840            .pack("app")
841            .file(
842                "cfg.tmpl",
843                "name={{ name }} os={{ dodot.os }} home={{ dodot.home }}",
844            )
845            .done()
846            .build();
847
848        let mut vars = HashMap::new();
849        vars.insert("name".into(), "Alice".into());
850        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
851
852        let source = env.dotfiles_root.join("app/cfg.tmpl");
853        let first = pp.expand(&source, env.fs.as_ref()).unwrap();
854        let second = pp.expand(&source, env.fs.as_ref()).unwrap();
855        let third = pp.expand(&source, env.fs.as_ref()).unwrap();
856
857        assert_eq!(first[0].content, second[0].content);
858        assert_eq!(second[0].content, third[0].content);
859    }
860
861    #[test]
862    fn stripped_name_of_literal_extension_returns_empty() {
863        // Edge case recording the current (defensive) behavior: a file
864        // named exactly `.tmpl` (extension and nothing else) strips to
865        // the empty string. In normal packs the scanner filters dotfiles
866        // out before they reach the preprocessor, so this won't happen
867        // via user flows. But a misconfigured preprocessor extension or
868        // an archive entry with no stem could still produce an empty
869        // path downstream, and the pipeline is expected to reject that
870        // with a useful error — see
871        // `pipeline::rejects_empty_path_from_preprocessor`.
872        let pp = new_pp(HashMap::new());
873        assert_eq!(pp.stripped_name(".tmpl"), "");
874        assert!(pp.matches_extension(".tmpl"));
875    }
876
877    #[test]
878    fn build_dodot_context_omits_undetected_optional_keys() {
879        // Directly exercise the map-building helper: given a Pather but
880        // the detection helpers return None (simulated via testing the
881        // helper return invariants), verify the map structure.
882        //
883        // Since `detect_username`/`detect_hostname` read real env/system
884        // state, we can only assert: if they return Some, the key is
885        // present; if they return None, the key is absent.
886        let ctx = build_dodot_context(&make_pather());
887
888        // These are always present:
889        assert!(ctx.contains_key("os"));
890        assert!(ctx.contains_key("arch"));
891        assert!(ctx.contains_key("home"));
892        assert!(ctx.contains_key("dotfiles_root"));
893
894        // Optional keys: present iff the detection helper returned Some.
895        assert_eq!(ctx.contains_key("username"), detect_username().is_some());
896        assert_eq!(ctx.contains_key("hostname"), detect_hostname().is_some());
897    }
898
899    // ── Tracked render + context hash ────────────────────────────
900
901    #[test]
902    fn expand_emits_tracked_render_with_markers_around_each_variable() {
903        // The cache layer needs the marker-annotated render to drive
904        // burgertocow's reverse-diff. Confirm that each `{{ var }}`
905        // emission produces exactly one VAR_START / VAR_END pair in
906        // the tracked string.
907        let env = crate::testing::TempEnvironment::builder()
908            .pack("app")
909            .file("cfg.tmpl", "name={{ name }} count={{ count }}")
910            .done()
911            .build();
912
913        let mut vars = HashMap::new();
914        vars.insert("name".into(), "Alice".into());
915        vars.insert("count".into(), "3".into());
916        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
917
918        let source = env.dotfiles_root.join("app/cfg.tmpl");
919        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
920        let tracked = result[0]
921            .tracked_render
922            .as_ref()
923            .expect("tracked render must be present for a generative preprocessor");
924        assert_eq!(
925            tracked.matches(burgertocow::VAR_START).count(),
926            2,
927            "two variable emissions should produce two start markers, got: {tracked:?}"
928        );
929        assert_eq!(
930            tracked.matches(burgertocow::VAR_END).count(),
931            2,
932            "two variable emissions should produce two end markers, got: {tracked:?}"
933        );
934    }
935
936    #[test]
937    fn expand_visible_output_matches_tracked_with_markers_stripped() {
938        // The visible content (what the symlink target sees) must equal
939        // the tracked string with marker bytes removed. Otherwise the
940        // baseline cache's `rendered_content` and the deployed file
941        // would diverge by exactly the marker characters.
942        let env = crate::testing::TempEnvironment::builder()
943            .pack("app")
944            .file("cfg.tmpl", "user={{ name }} home={{ dodot.home }}")
945            .done()
946            .build();
947
948        let mut vars = HashMap::new();
949        vars.insert("name".into(), "Alice".into());
950        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
951
952        let source = env.dotfiles_root.join("app/cfg.tmpl");
953        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
954        let visible = String::from_utf8(result[0].content.clone()).unwrap();
955        let tracked = result[0].tracked_render.as_ref().unwrap();
956
957        let stripped: String = tracked
958            .chars()
959            .filter(|c| *c != burgertocow::VAR_START && *c != burgertocow::VAR_END)
960            .collect();
961        assert_eq!(visible, stripped);
962    }
963
964    #[test]
965    fn context_hash_is_populated_and_stable() {
966        // Same constructor inputs should produce the same context hash
967        // across runs and across `expand` calls. This is what lets the
968        // baseline cache decide "input didn't change" without re-rendering.
969        let env = crate::testing::TempEnvironment::builder()
970            .pack("app")
971            .file("a.tmpl", "x={{ name }}")
972            .done()
973            .build();
974
975        let mut vars = HashMap::new();
976        vars.insert("name".into(), "Alice".into());
977        let pp1 = TemplatePreprocessor::new(vec!["tmpl".into()], vars.clone(), env.paths.as_ref())
978            .unwrap();
979        let pp2 = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
980
981        assert_eq!(
982            pp1.context_hash, pp2.context_hash,
983            "identical inputs must yield identical context hashes"
984        );
985
986        let source = env.dotfiles_root.join("app/a.tmpl");
987        let r1 = pp1.expand(&source, env.fs.as_ref()).unwrap();
988        let r2 = pp1.expand(&source, env.fs.as_ref()).unwrap();
989        assert_eq!(r1[0].context_hash, r2[0].context_hash);
990        assert_eq!(r1[0].context_hash, Some(pp1.context_hash));
991    }
992
993    #[test]
994    fn context_hash_changes_when_user_var_changes() {
995        // A different user-var value MUST produce a different context
996        // hash. Without this, secret rotation through user vars wouldn't
997        // re-run install/homebrew sentinels (whose freshness is keyed off
998        // the context hash via §3.5 of the secrets spec).
999        let mut vars1 = HashMap::new();
1000        vars1.insert("name".into(), "Alice".into());
1001
1002        let mut vars2 = HashMap::new();
1003        vars2.insert("name".into(), "Bob".into());
1004
1005        let pather = make_pather();
1006        let pp1 = TemplatePreprocessor::new(vec!["tmpl".into()], vars1, &pather).unwrap();
1007        let pp2 = TemplatePreprocessor::new(vec!["tmpl".into()], vars2, &pather).unwrap();
1008        assert_ne!(pp1.context_hash, pp2.context_hash);
1009    }
1010
1011    #[test]
1012    fn context_hash_is_order_independent_for_user_vars() {
1013        // Hash inputs are gathered from a HashMap, so iteration order
1014        // is non-deterministic. Sorting via BTreeMap before hashing must
1015        // produce a stable hash regardless of insertion order.
1016        let pather = make_pather();
1017
1018        let mut a = HashMap::new();
1019        a.insert("alpha".into(), "1".into());
1020        a.insert("zeta".into(), "26".into());
1021
1022        let mut b = HashMap::new();
1023        b.insert("zeta".into(), "26".into());
1024        b.insert("alpha".into(), "1".into());
1025
1026        let pp_a = TemplatePreprocessor::new(vec!["tmpl".into()], a, &pather).unwrap();
1027        let pp_b = TemplatePreprocessor::new(vec!["tmpl".into()], b, &pather).unwrap();
1028        assert_eq!(pp_a.context_hash, pp_b.context_hash);
1029    }
1030
1031    #[test]
1032    fn empty_template_still_emits_tracked_render() {
1033        // Edge case: a template with no `{{ ... }}` emissions. The
1034        // tracked string should be the same as the visible content
1035        // (no markers added) and still be present, not None.
1036        let env = crate::testing::TempEnvironment::builder()
1037            .pack("app")
1038            .file("plain.tmpl", "no vars at all")
1039            .done()
1040            .build();
1041
1042        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
1043            .unwrap();
1044
1045        let source = env.dotfiles_root.join("app/plain.tmpl");
1046        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
1047        let tracked = result[0].tracked_render.as_ref().unwrap();
1048        assert!(
1049            !tracked.contains(burgertocow::VAR_START) && !tracked.contains(burgertocow::VAR_END),
1050            "no variables → no markers, got: {tracked:?}"
1051        );
1052        // And still equal to the visible content.
1053        assert_eq!(
1054            String::from_utf8(result[0].content.clone()).unwrap(),
1055            *tracked
1056        );
1057    }
1058}