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::atomic::{AtomicU64, Ordering};
34use std::sync::{Arc, Mutex, OnceLock};
35
36use burgertocow::Tracker;
37use minijinja::value::{Enumerator, Object, ObjectRepr, Value};
38use minijinja::{Error as MjError, ErrorKind as MjErrorKind, UndefinedBehavior};
39use sha2::{Digest, Sha256};
40
41use crate::fs::Fs;
42use crate::paths::Pather;
43use crate::preprocessing::{ExpandedFile, Preprocessor, SecretLineRange, TransformType};
44use crate::secret::SecretRegistry;
45use crate::{DodotError, Result};
46
47/// Reserved top-level variable names.
48const RESERVED_VARS: &[&str] = &["dodot", "env"];
49
50/// MiniJinja object that looks up process environment variables on
51/// attribute access. `{{ env.SHELL }}` becomes `std::env::var("SHELL")`.
52/// Missing env vars return `None` from `get_value`, which MiniJinja
53/// treats as an undefined attribute (a render error under strict mode).
54/// For optional variables, use `{{ env.NAME | default("...") }}`.
55#[derive(Debug)]
56struct EnvLookup;
57
58impl Object for EnvLookup {
59    fn repr(self: &Arc<Self>) -> ObjectRepr {
60        ObjectRepr::Map
61    }
62
63    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
64        let name = key.as_str()?;
65        std::env::var(name).ok().map(Value::from)
66    }
67
68    fn enumerate(self: &Arc<Self>) -> Enumerator {
69        // Don't enumerate environment variables — printing `{{ env }}`
70        // as a whole shouldn't dump the whole process environment.
71        Enumerator::NonEnumerable
72    }
73}
74
75/// Template rendering preprocessor. Generative (one-way) transform.
76///
77/// Holds the resolved `dodot.*` map, the user-defined variables, and a
78/// pre-computed context hash. Each `expand` call constructs a fresh
79/// [`Tracker`], installs the namespaces, registers the source file as a
80/// named template, and renders. We don't share the `Tracker` across
81/// renders because `add_template` requires `&mut` while `Preprocessor::
82/// expand` runs through a `&self` trait method — a per-call tracker is
83/// the simplest way to keep the pipeline's existing concurrency shape.
84pub struct TemplatePreprocessor {
85    extensions: Vec<String>,
86    dodot_ns: BTreeMap<String, String>,
87    user_vars: BTreeMap<String, String>,
88    /// SHA-256 of the deterministic projection of `dodot_ns` and
89    /// `user_vars` (sorted keys, length-prefixed). Reused as the
90    /// `context_hash` for every render this preprocessor performs.
91    ///
92    /// `env.*` references are intentionally **not** part of the
93    /// context hash and tracking them is out of scope by design — see
94    /// `preprocessing-pipeline.lex` §6.4. The cache contract is
95    /// "same source bytes + same `dodot.*` namespace + same
96    /// `user_vars` → same output." The `env.*` namespace is the
97    /// explicitly live-read zone; rotating a referenced env var does
98    /// not invalidate the cache, and users pick up the new value via
99    /// `dodot up --force`. Stable values that should participate in
100    /// invalidation belong in `[preprocessor.template.vars]`
101    /// (`user_vars`), not `env.*`.
102    context_hash: [u8; 32],
103    /// Optional secret-resolution registry. Populated via
104    /// [`Self::with_secret_registry`] when secrets are configured.
105    /// `None` means `secret(...)` is unavailable in templates and a
106    /// `secret(...)` call surfaces as a render error pointing the
107    /// user at `[secret] enabled = true`. See `secrets.lex` §5.
108    secret_registry: Option<Arc<SecretRegistry>>,
109}
110
111impl std::fmt::Debug for TemplatePreprocessor {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        f.debug_struct("TemplatePreprocessor")
114            .field("extensions", &self.extensions)
115            .finish_non_exhaustive()
116    }
117}
118
119impl TemplatePreprocessor {
120    /// Construct a new template preprocessor.
121    ///
122    /// Validates that no user-defined variable uses a reserved name
123    /// (`dodot` or `env`). Resolves the `dodot.*` builtins from
124    /// `pather` + system info and computes the context hash now so
125    /// every subsequent `expand` reuses the same value.
126    ///
127    /// Extensions are normalized at construction: a leading dot (e.g.
128    /// `".tmpl"`) is stripped so both `"tmpl"` and `".tmpl"` work.
129    pub fn new(
130        extensions: Vec<String>,
131        user_vars: HashMap<String, String>,
132        pather: &dyn Pather,
133    ) -> Result<Self> {
134        for name in user_vars.keys() {
135            if RESERVED_VARS.contains(&name.as_str()) {
136                return Err(DodotError::TemplateReservedVar { name: name.clone() });
137            }
138        }
139
140        let extensions: Vec<String> = extensions
141            .into_iter()
142            .map(|e| e.trim_start_matches('.').to_string())
143            .collect();
144
145        let dodot_ns = build_dodot_context(pather);
146        let user_vars: BTreeMap<String, String> = user_vars.into_iter().collect();
147        let context_hash = compute_context_hash(&dodot_ns, &user_vars);
148
149        Ok(Self {
150            extensions,
151            dodot_ns,
152            user_vars,
153            context_hash,
154            secret_registry: None,
155        })
156    }
157
158    /// Wire a [`SecretRegistry`] into this preprocessor. Templates
159    /// rendered through it can call `{{ secret("op://Vault/Item/Field") }}`
160    /// (and other configured schemes); calls dispatch through the
161    /// registry, populate the per-render sidecar, and refuse
162    /// multi-line values per `secrets.lex` §3.4. Without a registry
163    /// (the default), any `secret(...)` call in a template surfaces
164    /// as a render error.
165    pub fn with_secret_registry(mut self, registry: Arc<SecretRegistry>) -> Self {
166        self.secret_registry = Some(registry);
167        self
168    }
169
170    /// Build a fresh tracker with this preprocessor's namespaces
171    /// installed and `UndefinedBehavior::Strict` set. Called per render
172    /// because `Tracker::add_template` requires `&mut self`.
173    ///
174    /// `sidecar` is the per-render secret-tracking accumulator. The
175    /// `secret(...)` MiniJinja function returns a unique private-use
176    /// sentinel (rather than the raw secret value) and pushes a
177    /// [`SecretCallEntry`] into the accumulator. The caller (the
178    /// `expand` body) walks the rendered output to find each sentinel,
179    /// records its line position, then substitutes the sentinel back
180    /// to the real value. This avoids the substring-collision failure
181    /// mode where a secret value happens to also appear elsewhere in
182    /// the rendered text.
183    ///
184    /// `render_id` is a per-render monotonic counter used in the
185    /// sentinel format so two concurrent renders can't observe each
186    /// other's sentinels.
187    fn make_tracker(&self, sidecar: Arc<Mutex<Vec<SecretCallEntry>>>, render_id: u64) -> Tracker {
188        let mut tracker = Tracker::new();
189        let env = tracker.env_mut();
190        env.set_undefined_behavior(UndefinedBehavior::Strict);
191        env.add_global("dodot", Value::from(self.dodot_ns.clone()));
192        env.add_global("env", Value::from_object(EnvLookup));
193        for (name, val) in &self.user_vars {
194            env.add_global(name.clone(), Value::from(val.clone()));
195        }
196
197        // Install the `secret(...)` function. Two cases:
198        //
199        // - Registry configured: function dispatches through the
200        //   registry. Refuses multi-line values per §3.4. Records
201        //   the (reference, value) pair into `sidecar` so the
202        //   caller can compute line ranges after rendering.
203        // - No registry: function still exists, but every call
204        //   surfaces a clean render error pointing the user at
205        //   `[secret] enabled = true`. The presence-without-function
206        //   alternative would surface as MiniJinja's generic
207        //   "undefined" error which doesn't tell the user how to
208        //   fix the config.
209        match &self.secret_registry {
210            Some(registry) => {
211                let registry = registry.clone();
212                let sidecar = sidecar.clone();
213                env.add_function(
214                    "secret",
215                    move |reference: &str| -> std::result::Result<String, MjError> {
216                        // Within-run cache: first call for a given
217                        // reference goes to the provider; subsequent
218                        // calls (in this template or any other
219                        // rendered through the same registry
220                        // instance) hit the cache and never shell
221                        // out. Multi-line / non-UTF-8 are detected
222                        // up here so the rich error messages stay
223                        // co-located with the callback that surfaces
224                        // them; only validated values reach the
225                        // cache. See `secrets.lex` §7.4 / §3.4.
226                        //
227                        // The cache holds `Arc<SecretString>` so the
228                        // resolved bytes get zeroized when the
229                        // registry's last reference drops. We expose
230                        // to `&str` only at this substitution
231                        // boundary (and the resulting String is
232                        // immediately handed to MiniJinja), keeping
233                        // the unsealed plaintext window as narrow as
234                        // the rendering pipeline allows.
235                        let secret = if let Some(cached) = registry.cache_get(reference) {
236                            cached
237                        } else {
238                            let value = registry.resolve(reference).map_err(|e| {
239                                MjError::new(MjErrorKind::InvalidOperation, e.to_string())
240                            })?;
241                            if value.contains_newline() {
242                                return Err(MjError::new(
243                                    MjErrorKind::InvalidOperation,
244                                    format!(
245                                        "secret `{reference}` resolved to a multi-line value. \
246                                     Value-injection (`{{{{ secret(...) }}}}`) is single-line only. \
247                                     For multi-line secret material (TLS / SSH keys, GPG armored \
248                                     keys, service-account JSON files), use the whole-file deploy \
249                                     path: encrypt the file, drop it in a pack, reference the \
250                                     deployed path from your config. See secrets.lex §4."
251                                    ),
252                                ));
253                            }
254                            // Validate UTF-8 before caching — a
255                            // non-UTF-8 value never reaches the
256                            // cache (the call propagates the rich
257                            // error instead).
258                            value.expose().map_err(|_| {
259                                MjError::new(
260                                    MjErrorKind::InvalidOperation,
261                                    format!(
262                                        "secret `{reference}` resolved to non-UTF-8 bytes; \
263                                     value-injection requires UTF-8 strings"
264                                    ),
265                                )
266                            })?;
267                            let arc = Arc::new(value);
268                            registry.cache_put(reference, Arc::clone(&arc));
269                            arc
270                        };
271                        // expose() can only fail on non-UTF-8, which
272                        // we excluded above for cache-miss + the
273                        // cache only holds validated UTF-8.
274                        let owned = secret.expose().unwrap_or("").to_string();
275                        let mut entries = sidecar.lock().unwrap();
276                        let sentinel = make_secret_sentinel(render_id, entries.len());
277                        entries.push(SecretCallEntry {
278                            sentinel: sentinel.clone(),
279                            reference: reference.to_string(),
280                            value: owned,
281                        });
282                        // The sentinel is what flows through MiniJinja
283                        // and into the rendered output; `expand()`
284                        // computes line ranges by locating sentinels
285                        // and then substitutes them back to the value.
286                        Ok(sentinel)
287                    },
288                );
289            }
290            None => {
291                env.add_function(
292                    "secret",
293                    |reference: &str| -> std::result::Result<String, MjError> {
294                        Err(MjError::new(
295                            MjErrorKind::InvalidOperation,
296                            format!(
297                                "secret(`{reference}`) was called but no secret providers \
298                             are configured. Either set `[secret] enabled = true` and \
299                             enable a provider via `[secret.providers.<scheme>] enabled = \
300                             true` in your .dodot.toml, or remove the `secret(...)` \
301                             reference from the template."
302                            ),
303                        ))
304                    },
305                );
306            }
307        }
308        tracker
309    }
310}
311
312impl Preprocessor for TemplatePreprocessor {
313    fn name(&self) -> &str {
314        "template"
315    }
316
317    fn transform_type(&self) -> TransformType {
318        TransformType::Generative
319    }
320
321    fn supports_reverse_merge(&self) -> bool {
322        // Templates emit a tracked_render and produce baselines; the
323        // reverse-merge pipeline (transform check, clean filter) reads
324        // those baselines to write template-space diffs back to source.
325        true
326    }
327
328    fn matches_extension(&self, filename: &str) -> bool {
329        // Extensions are normalized (no leading dot) at construction.
330        // We require a literal "." before the extension to avoid e.g.
331        // "mpl" matching "foo.tmpl". No per-call allocation.
332        self.extensions.iter().any(|ext| {
333            filename
334                .strip_suffix(ext.as_str())
335                .is_some_and(|prefix| prefix.ends_with('.'))
336        })
337    }
338
339    fn stripped_name(&self, filename: &str) -> String {
340        // If multiple configured extensions match (e.g. "tmpl" and
341        // "j2.tmpl" both suffixes of the same filename), prefer the
342        // longest so behaviour is deterministic and independent of
343        // config ordering.
344        self.extensions
345            .iter()
346            .filter_map(|ext| {
347                filename
348                    .strip_suffix(ext.as_str())
349                    .and_then(|prefix| prefix.strip_suffix('.'))
350                    .map(|stripped| (ext.len(), stripped))
351            })
352            .max_by_key(|(len, _)| *len)
353            .map(|(_, stripped)| stripped.to_string())
354            .unwrap_or_else(|| filename.to_string())
355    }
356
357    fn expand(&self, source: &Path, fs: &dyn Fs) -> Result<Vec<ExpandedFile>> {
358        let template_str = fs.read_to_string(source)?;
359
360        // Use the source file's path as the template name. Tracker
361        // requires named templates; the path is unique per file and
362        // surfaces sensibly in any error MiniJinja produces.
363        let template_name = source.to_string_lossy().into_owned();
364
365        // Per-render sidecar accumulator. Each `secret(...)` call
366        // pushes a `SecretCallEntry { sentinel, reference, value }`;
367        // the rendered output carries the sentinel (not the value),
368        // and `finalize_secrets` below turns sentinels into line
369        // ranges and then substitutes them back to the real value.
370        let sidecar: Arc<Mutex<Vec<SecretCallEntry>>> = Arc::new(Mutex::new(Vec::new()));
371        let render_id = next_render_id();
372
373        let mut tracker = self.make_tracker(sidecar.clone(), render_id);
374        tracker
375            .add_template(&template_name, &template_str)
376            .map_err(|e| DodotError::TemplateRender {
377                source_file: source.to_path_buf(),
378                message: format_minijinja_error(&e),
379            })?;
380
381        let tracked =
382            tracker
383                .render(&template_name, ())
384                .map_err(|e| DodotError::TemplateRender {
385                    source_file: source.to_path_buf(),
386                    message: format_minijinja_error(&e),
387                })?;
388
389        let filename = source
390            .file_name()
391            .unwrap_or_default()
392            .to_string_lossy()
393            .into_owned();
394        let stripped = self.stripped_name(&filename);
395
396        let (rendered, tracked_str) = tracked.into_parts();
397        let entries = std::mem::take(&mut *sidecar.lock().unwrap());
398        let (rendered, tracked_str, secret_line_ranges) =
399            finalize_secrets(rendered, tracked_str, &entries);
400
401        Ok(vec![ExpandedFile {
402            relative_path: PathBuf::from(stripped),
403            content: rendered.into_bytes(),
404            is_dir: false,
405            tracked_render: Some(tracked_str),
406            context_hash: Some(self.context_hash),
407            secret_line_ranges,
408            deploy_mode: None,
409        }])
410    }
411}
412
413/// Per-call accumulator entry for `secret(...)` resolutions. Carries
414/// both the unique private-use sentinel that the MiniJinja function
415/// emitted and the real resolved value, so `finalize_secrets` can
416/// compute line ranges from the sentinel positions and then swap
417/// sentinels for values in the rendered + tracked outputs.
418struct SecretCallEntry {
419    sentinel: String,
420    reference: String,
421    value: String,
422}
423
424/// Process-wide monotonic counter used to make sentinels unique
425/// across concurrent renders. Each `expand()` call gets a fresh id
426/// before installing the `secret()` function.
427static RENDER_COUNTER: AtomicU64 = AtomicU64::new(1);
428
429fn next_render_id() -> u64 {
430    RENDER_COUNTER.fetch_add(1, Ordering::Relaxed)
431}
432
433/// Sentinel format: `\u{E000}DSEC.<render_id>.<call_idx>\u{E001}`.
434///
435/// Both bracket characters live in the Unicode Private Use Area
436/// (U+E000–U+F8FF), which by definition has no assigned meaning and
437/// does not appear in normal dotfile content. Combined with the
438/// per-render id, the resulting string is unique within and across
439/// renders, eliminating the substring-collision failure mode of the
440/// previous "search for the resolved value" approach.
441fn make_secret_sentinel(render_id: u64, call_idx: usize) -> String {
442    let mut s = String::with_capacity(20);
443    s.push('\u{E000}');
444    s.push_str("DSEC.");
445    s.push_str(&render_id.to_string());
446    s.push('.');
447    s.push_str(&call_idx.to_string());
448    s.push('\u{E001}');
449    s
450}
451
452/// Walk `rendered` to convert each sentinel into a [`SecretLineRange`]
453/// (single-line per Phase S1 / §3.4), then substitute every sentinel
454/// back to its real value in both `rendered` and `tracked` and return
455/// all three.
456///
457/// Sentinels that don't appear in the output are dropped from the
458/// range list — the `secret()` was evaluated (for resolution side
459/// effects) but the value never reached the visible output, e.g. a
460/// call inside a false `{% if %}` branch. We still substitute (a
461/// no-op in that case) so callers can rely on the post-call output
462/// containing zero sentinel characters.
463fn finalize_secrets(
464    rendered: String,
465    tracked: String,
466    entries: &[SecretCallEntry],
467) -> (String, String, Vec<SecretLineRange>) {
468    let mut ranges = Vec::with_capacity(entries.len());
469    if !entries.is_empty() {
470        let line_starts = build_line_starts(&rendered);
471        for entry in entries {
472            if let Some(byte_off) = rendered.find(entry.sentinel.as_str()) {
473                let line = byte_offset_to_line(&line_starts, byte_off);
474                ranges.push(SecretLineRange {
475                    start: line,
476                    end: line + 1,
477                    reference: entry.reference.clone(),
478                });
479            }
480        }
481    }
482
483    let mut final_rendered = rendered;
484    let mut final_tracked = tracked;
485    for entry in entries {
486        final_rendered = final_rendered.replace(entry.sentinel.as_str(), &entry.value);
487        final_tracked = final_tracked.replace(entry.sentinel.as_str(), &entry.value);
488    }
489
490    (final_rendered, final_tracked, ranges)
491}
492
493/// Byte offsets where each line begins in `s`. `line_starts[0] == 0`;
494/// `line_starts[i]` for i > 0 is the byte index just past the i-1th
495/// `\n`. Used by [`byte_offset_to_line`] for the sentinel→line lookup.
496fn build_line_starts(s: &str) -> Vec<usize> {
497    let mut v = Vec::with_capacity(s.len() / 32 + 1);
498    v.push(0);
499    for (i, b) in s.bytes().enumerate() {
500        if b == b'\n' {
501            v.push(i + 1);
502        }
503    }
504    v
505}
506
507/// Map a byte offset within the source string to its 0-indexed line
508/// number. Binary search over `line_starts`.
509fn byte_offset_to_line(line_starts: &[usize], offset: usize) -> usize {
510    match line_starts.binary_search(&offset) {
511        Ok(line) => line,
512        Err(insert_pos) => insert_pos.saturating_sub(1),
513    }
514}
515
516/// Produce a deterministic SHA-256 over the rendering context.
517///
518/// The hash is order-independent (BTreeMap iteration is sorted) and
519/// includes both the `dodot.*` namespace and the user-defined variables.
520/// Layout: each entry is encoded as `<ns>\x1F<key>\x1F<value>\x1E` so
521/// rearranging the boundaries between any two adjacent fields cannot
522/// produce a collision (`\x1E` and `\x1F` are the same control bytes
523/// burgertocow uses internally; they don't appear in normal
524/// configuration content).
525fn compute_context_hash(
526    dodot_ns: &BTreeMap<String, String>,
527    user_vars: &BTreeMap<String, String>,
528) -> [u8; 32] {
529    let mut hasher = Sha256::new();
530    for (k, v) in dodot_ns {
531        hasher.update(b"dodot");
532        hasher.update([0x1f]);
533        hasher.update(k.as_bytes());
534        hasher.update([0x1f]);
535        hasher.update(v.as_bytes());
536        hasher.update([0x1e]);
537    }
538    for (k, v) in user_vars {
539        hasher.update(b"vars");
540        hasher.update([0x1f]);
541        hasher.update(k.as_bytes());
542        hasher.update([0x1f]);
543        hasher.update(v.as_bytes());
544        hasher.update([0x1e]);
545    }
546    hasher.finalize().into()
547}
548
549/// Build the `dodot.*` namespace map.
550///
551/// Keys we can always resolve (os, arch, home, dotfiles_root) are
552/// always inserted. Keys that depend on environment detection
553/// (hostname, username) are inserted only when a non-empty value is
554/// found — otherwise they are omitted so that template access via
555/// `{{ dodot.hostname }}` triggers a strict-undefined render error,
556/// rather than silently injecting an empty string. Users who want a
557/// fallback can write `{{ dodot.hostname | default("unknown") }}`.
558///
559/// Hostname and username detection is cached process-wide via
560/// [`OnceLock`] so that building a template preprocessor for each pack
561/// does not respawn `hostname(1)` every time.
562fn build_dodot_context(pather: &dyn Pather) -> BTreeMap<String, String> {
563    let mut ctx = BTreeMap::new();
564    ctx.insert("os".into(), std::env::consts::OS.into());
565    ctx.insert("arch".into(), std::env::consts::ARCH.into());
566    if let Some(h) = cached_hostname() {
567        ctx.insert("hostname".into(), h.clone());
568    }
569    if let Some(u) = cached_username() {
570        ctx.insert("username".into(), u.clone());
571    }
572    ctx.insert("home".into(), pather.home_dir().display().to_string());
573    ctx.insert(
574        "dotfiles_root".into(),
575        pather.dotfiles_root().display().to_string(),
576    );
577    ctx
578}
579
580/// Process-wide cached hostname. First call resolves and pins the
581/// result for the lifetime of the process.
582fn cached_hostname() -> Option<&'static String> {
583    static CACHE: OnceLock<Option<String>> = OnceLock::new();
584    CACHE.get_or_init(detect_hostname).as_ref()
585}
586
587/// Process-wide cached username. Same caching semantics as
588/// [`cached_hostname`].
589fn cached_username() -> Option<&'static String> {
590    static CACHE: OnceLock<Option<String>> = OnceLock::new();
591    CACHE.get_or_init(detect_username).as_ref()
592}
593
594fn detect_hostname() -> Option<String> {
595    if let Ok(h) = std::env::var("HOSTNAME") {
596        if !h.is_empty() {
597            return Some(h);
598        }
599    }
600    // Fallback: shell out. Ignore errors.
601    let output = std::process::Command::new("hostname").output().ok()?;
602    if !output.status.success() {
603        return None;
604    }
605    let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
606    if name.is_empty() {
607        None
608    } else {
609        Some(name)
610    }
611}
612
613fn detect_username() -> Option<String> {
614    for var in ["USER", "USERNAME", "LOGNAME"] {
615        if let Ok(v) = std::env::var(var) {
616            if !v.is_empty() {
617                return Some(v);
618            }
619        }
620    }
621    None
622}
623
624/// Compact a MiniJinja error into a single human-readable string with
625/// a suggestion for the common "undefined variable" case.
626fn format_minijinja_error(err: &minijinja::Error) -> String {
627    use minijinja::ErrorKind;
628
629    let base = match err.kind() {
630        ErrorKind::UndefinedError => {
631            // Best-effort: MiniJinja's error message already says
632            // "undefined value" but doesn't always name the variable.
633            // The Display impl includes line info.
634            let mut msg = err.to_string();
635            msg.push_str(
636                "\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)",
637            );
638            msg
639        }
640        ErrorKind::SyntaxError => err.to_string(),
641        _ => err.to_string(),
642    };
643
644    // MiniJinja sometimes appends "referenced from" traces; strip them
645    // to keep the error message compact.
646    base.lines().take(10).collect::<Vec<_>>().join("\n  ")
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652    use crate::paths::XdgPather;
653
654    fn make_pather() -> XdgPather {
655        XdgPather::builder()
656            .home("/home/alice")
657            .dotfiles_root("/home/alice/dotfiles")
658            .xdg_config_home("/home/alice/.config")
659            .data_dir("/home/alice/.local/share/dodot")
660            .build()
661            .unwrap()
662    }
663
664    fn new_pp(vars: HashMap<String, String>) -> TemplatePreprocessor {
665        TemplatePreprocessor::new(vec!["tmpl".into(), "template".into()], vars, &make_pather())
666            .unwrap()
667    }
668
669    // ── Trait basics ────────────────────────────────────────────
670
671    #[test]
672    fn trait_properties() {
673        let pp = new_pp(HashMap::new());
674        assert_eq!(pp.name(), "template");
675        assert_eq!(pp.transform_type(), TransformType::Generative);
676    }
677
678    #[test]
679    fn matches_default_extensions() {
680        let pp = new_pp(HashMap::new());
681        assert!(pp.matches_extension("config.toml.tmpl"));
682        assert!(pp.matches_extension("config.toml.template"));
683        assert!(!pp.matches_extension("config.toml"));
684        assert!(!pp.matches_extension("config.tmpl.bak"));
685    }
686
687    #[test]
688    fn matches_custom_extension() {
689        let pp =
690            TemplatePreprocessor::new(vec!["j2".into()], HashMap::new(), &make_pather()).unwrap();
691        assert!(pp.matches_extension("nginx.conf.j2"));
692        assert!(!pp.matches_extension("nginx.conf.tmpl"));
693    }
694
695    #[test]
696    fn stripped_name_removes_either_extension() {
697        let pp = new_pp(HashMap::new());
698        assert_eq!(pp.stripped_name("config.toml.tmpl"), "config.toml");
699        assert_eq!(pp.stripped_name("config.toml.template"), "config.toml");
700        assert_eq!(pp.stripped_name("already-stripped"), "already-stripped");
701    }
702
703    // ── Reserved variable names ─────────────────────────────────
704
705    #[test]
706    fn reserved_dodot_var_rejected() {
707        let mut vars = HashMap::new();
708        vars.insert("dodot".into(), "x".into());
709        let err = TemplatePreprocessor::new(vec!["tmpl".into()], vars, &make_pather()).unwrap_err();
710        assert!(
711            matches!(err, DodotError::TemplateReservedVar { ref name } if name == "dodot"),
712            "got: {err}"
713        );
714    }
715
716    #[test]
717    fn reserved_env_var_rejected() {
718        let mut vars = HashMap::new();
719        vars.insert("env".into(), "x".into());
720        let err = TemplatePreprocessor::new(vec!["tmpl".into()], vars, &make_pather()).unwrap_err();
721        assert!(matches!(err, DodotError::TemplateReservedVar { .. }));
722    }
723
724    // ── Rendering ───────────────────────────────────────────────
725
726    #[test]
727    fn renders_user_var() {
728        let env = crate::testing::TempEnvironment::builder()
729            .pack("app")
730            .file("greeting.tmpl", "hello {{ name }}")
731            .done()
732            .build();
733
734        let mut vars = HashMap::new();
735        vars.insert("name".into(), "Alice".into());
736        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
737
738        let source = env.dotfiles_root.join("app/greeting.tmpl");
739        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
740
741        assert_eq!(result.len(), 1);
742        assert_eq!(result[0].relative_path, PathBuf::from("greeting"));
743        assert_eq!(String::from_utf8_lossy(&result[0].content), "hello Alice");
744    }
745
746    #[test]
747    fn renders_dodot_builtins() {
748        let env = crate::testing::TempEnvironment::builder()
749            .pack("app")
750            .file(
751                "info.tmpl",
752                "home={{ dodot.home }} root={{ dodot.dotfiles_root }} os={{ dodot.os }}",
753            )
754            .done()
755            .build();
756
757        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
758            .unwrap();
759
760        let source = env.dotfiles_root.join("app/info.tmpl");
761        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
762
763        let rendered = String::from_utf8_lossy(&result[0].content);
764        let home = env.paths.home_dir().display().to_string();
765        let root = env.paths.dotfiles_root().display().to_string();
766        assert!(
767            rendered.contains(&format!("home={home}")),
768            "rendered: {rendered}"
769        );
770        assert!(
771            rendered.contains(&format!("root={root}")),
772            "rendered: {rendered}"
773        );
774        assert!(rendered.contains(&format!("os={}", std::env::consts::OS)));
775    }
776
777    #[test]
778    fn renders_env_var() {
779        // Use a likely-present env var with a fallback for determinism.
780        // PATH should always be set during tests.
781        let env = crate::testing::TempEnvironment::builder()
782            .pack("app")
783            .file("has_path.tmpl", "path={{ env.PATH }}")
784            .done()
785            .build();
786
787        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
788            .unwrap();
789
790        let source = env.dotfiles_root.join("app/has_path.tmpl");
791        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
792        let rendered = String::from_utf8_lossy(&result[0].content).into_owned();
793
794        assert!(rendered.starts_with("path="));
795        assert!(
796            rendered.len() > "path=".len(),
797            "env.PATH should have some value"
798        );
799    }
800
801    #[test]
802    fn missing_env_var_errors() {
803        let env = crate::testing::TempEnvironment::builder()
804            .pack("app")
805            .file("bad.tmpl", "value={{ env.DEFINITELY_UNSET_VAR_ZZZ_12345 }}")
806            .done()
807            .build();
808
809        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
810            .unwrap();
811
812        let source = env.dotfiles_root.join("app/bad.tmpl");
813        // Ensure the env var is genuinely unset
814        std::env::remove_var("DEFINITELY_UNSET_VAR_ZZZ_12345");
815        let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
816        assert!(
817            matches!(err, DodotError::TemplateRender { ref source_file, .. } if source_file == &source),
818            "got: {err}"
819        );
820    }
821
822    #[test]
823    fn undefined_user_var_errors() {
824        let env = crate::testing::TempEnvironment::builder()
825            .pack("app")
826            .file("bad.tmpl", "value={{ not_defined }}")
827            .done()
828            .build();
829
830        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
831            .unwrap();
832
833        let source = env.dotfiles_root.join("app/bad.tmpl");
834        let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
835        assert!(
836            matches!(err, DodotError::TemplateRender { ref message, .. } if message.contains("not_defined") || message.contains("undefined")),
837            "got: {err}"
838        );
839    }
840
841    #[test]
842    fn syntax_error_reports_source_file() {
843        let env = crate::testing::TempEnvironment::builder()
844            .pack("app")
845            .file("broken.tmpl", "{% if %}unterminated")
846            .done()
847            .build();
848
849        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
850            .unwrap();
851
852        let source = env.dotfiles_root.join("app/broken.tmpl");
853        let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
854        assert!(
855            matches!(err, DodotError::TemplateRender { ref source_file, .. } if source_file == &source),
856            "got: {err}"
857        );
858    }
859
860    #[test]
861    fn renders_filters_and_conditionals() {
862        let env = crate::testing::TempEnvironment::builder()
863            .pack("app")
864            .file(
865                "multi.tmpl",
866                "NAME={{ name | upper }}\n{% if show %}shown{% else %}hidden{% endif %}",
867            )
868            .done()
869            .build();
870
871        let mut vars = HashMap::new();
872        vars.insert("name".into(), "alice".into());
873        vars.insert("show".into(), "true".into());
874        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
875
876        let source = env.dotfiles_root.join("app/multi.tmpl");
877        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
878        let rendered = String::from_utf8_lossy(&result[0].content);
879        assert!(rendered.contains("NAME=ALICE"), "rendered: {rendered}");
880        assert!(rendered.contains("shown"), "rendered: {rendered}");
881    }
882
883    #[test]
884    fn renders_empty_template() {
885        let env = crate::testing::TempEnvironment::builder()
886            .pack("app")
887            .file("empty.tmpl", "")
888            .done()
889            .build();
890
891        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
892            .unwrap();
893
894        let source = env.dotfiles_root.join("app/empty.tmpl");
895        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
896        assert_eq!(result.len(), 1);
897        assert!(result[0].content.is_empty());
898    }
899
900    #[test]
901    fn renders_template_without_substitutions() {
902        let env = crate::testing::TempEnvironment::builder()
903            .pack("app")
904            .file("plain.tmpl", "just plain text\nno vars here")
905            .done()
906            .build();
907
908        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
909            .unwrap();
910
911        let source = env.dotfiles_root.join("app/plain.tmpl");
912        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
913        assert_eq!(
914            String::from_utf8_lossy(&result[0].content),
915            "just plain text\nno vars here"
916        );
917    }
918
919    #[test]
920    fn extension_with_leading_dot_still_matches() {
921        // Tolerate config that writes extensions as `.tmpl` instead of
922        // `tmpl`. Without the leading-dot trim, `.ends_with("..tmpl")`
923        // would silently never match and templates would not be processed.
924        let pp = TemplatePreprocessor::new(
925            vec![".tmpl".into(), ".template".into()],
926            HashMap::new(),
927            &make_pather(),
928        )
929        .unwrap();
930        assert!(pp.matches_extension("config.toml.tmpl"));
931        assert!(pp.matches_extension("app.template"));
932        assert_eq!(pp.stripped_name("config.toml.tmpl"), "config.toml");
933    }
934
935    #[test]
936    fn overlapping_suffix_does_not_false_match() {
937        // If a user configures an extension that is a suffix of another
938        // legitimate filename part (e.g. "mpl" as a suffix of "tmpl"),
939        // the matcher must require the literal "." boundary before the
940        // extension — otherwise "foo.tmpl" would be wrongly recognised
941        // as a "mpl" template and stripped to "foo.t".
942        let pp =
943            TemplatePreprocessor::new(vec!["mpl".into()], HashMap::new(), &make_pather()).unwrap();
944        assert!(!pp.matches_extension("foo.tmpl"));
945        assert_eq!(pp.stripped_name("foo.tmpl"), "foo.tmpl");
946
947        // Files that legitimately end with `.mpl` still match.
948        assert!(pp.matches_extension("song.mpl"));
949        assert_eq!(pp.stripped_name("song.mpl"), "song");
950    }
951
952    #[test]
953    fn overlapping_extensions_prefer_longest_match() {
954        // If a filename ends with both configured extensions (e.g.
955        // "foo.j2.tmpl" matches both "tmpl" and "j2.tmpl"), prefer the
956        // longest match so behaviour is deterministic regardless of
957        // config ordering.
958        let pp = TemplatePreprocessor::new(
959            vec!["tmpl".into(), "j2.tmpl".into()],
960            HashMap::new(),
961            &make_pather(),
962        )
963        .unwrap();
964        assert_eq!(pp.stripped_name("config.j2.tmpl"), "config");
965
966        // Opposite config order yields the same result.
967        let pp_reversed = TemplatePreprocessor::new(
968            vec!["j2.tmpl".into(), "tmpl".into()],
969            HashMap::new(),
970            &make_pather(),
971        )
972        .unwrap();
973        assert_eq!(pp_reversed.stripped_name("config.j2.tmpl"), "config");
974    }
975
976    #[test]
977    fn missing_dodot_key_raises_strict_error() {
978        // The `build_dodot_context` fix omits `hostname`/`username` from
979        // the map when they cannot be detected (rather than injecting
980        // empty strings, which would silently deploy broken configs).
981        //
982        // We avoid manipulating `std::env` here (not thread-safe; other
983        // tests read USER) and instead verify the underlying invariant:
984        // any missing key on the `dodot` object triggers the
985        // strict-undefined error. Under this invariant, an undetected
986        // username/hostname behaves the same as any other missing key.
987        let env = crate::testing::TempEnvironment::builder()
988            .pack("app")
989            .file("uses_missing.tmpl", "value={{ dodot.nonexistent_key_zzz }}")
990            .done()
991            .build();
992
993        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
994            .unwrap();
995
996        let source = env.dotfiles_root.join("app/uses_missing.tmpl");
997        let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
998        assert!(
999            matches!(err, DodotError::TemplateRender { .. }),
1000            "accessing a missing dodot.* key must error, got: {err}"
1001        );
1002    }
1003
1004    #[test]
1005    fn missing_dodot_key_can_be_defaulted() {
1006        // Ergonomic escape hatch: Jinja's `default` filter lets users
1007        // tolerate potentially-missing fields without raising.
1008        let env = crate::testing::TempEnvironment::builder()
1009            .pack("app")
1010            .file(
1011                "defaulted.tmpl",
1012                "value={{ dodot.nonexistent_key_zzz | default(\"unknown\") }}",
1013            )
1014            .done()
1015            .build();
1016
1017        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
1018            .unwrap();
1019
1020        let source = env.dotfiles_root.join("app/defaulted.tmpl");
1021        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
1022        assert_eq!(String::from_utf8_lossy(&result[0].content), "value=unknown");
1023    }
1024
1025    #[test]
1026    fn env_var_default_filter_bridges_missing_vars() {
1027        // The documented escape hatch for optional env vars is
1028        // `{{ env.NAME | default("...") }}`. If `default` doesn't work,
1029        // users have no way to reference env vars that might not be set —
1030        // so this specific pattern must stay functional.
1031        let env = crate::testing::TempEnvironment::builder()
1032            .pack("app")
1033            .file(
1034                "cfg.tmpl",
1035                "editor={{ env.DODOT_MISSING_VAR_ZZZ | default(\"vim\") }}",
1036            )
1037            .done()
1038            .build();
1039
1040        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
1041            .unwrap();
1042
1043        let source = env.dotfiles_root.join("app/cfg.tmpl");
1044        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
1045        assert_eq!(String::from_utf8_lossy(&result[0].content), "editor=vim");
1046    }
1047
1048    #[test]
1049    fn renders_for_loop_over_user_var() {
1050        // Regression guard: MiniJinja supports loops, but we want to
1051        // confirm that user-defined vars (which are plain strings) still
1052        // work inside a minimal control-flow structure. Strings are
1053        // iterable as sequences of characters — confirm our value-layer
1054        // doesn't silently block that.
1055        let env = crate::testing::TempEnvironment::builder()
1056            .pack("app")
1057            .file(
1058                "loop.tmpl",
1059                "{% for c in word %}{{ c | upper }}{% endfor %}",
1060            )
1061            .done()
1062            .build();
1063
1064        let mut vars = HashMap::new();
1065        vars.insert("word".into(), "hi".into());
1066        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
1067
1068        let source = env.dotfiles_root.join("app/loop.tmpl");
1069        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
1070        assert_eq!(String::from_utf8_lossy(&result[0].content), "HI");
1071    }
1072
1073    #[test]
1074    fn renders_unicode_content_and_vars() {
1075        // Template content and user vars may contain non-ASCII. Confirm
1076        // both pass through without mangling.
1077        let env = crate::testing::TempEnvironment::builder()
1078            .pack("app")
1079            .file("greet.tmpl", "こんにちは {{ name }}! 🎉")
1080            .done()
1081            .build();
1082
1083        let mut vars = HashMap::new();
1084        vars.insert("name".into(), "世界".into());
1085        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
1086
1087        let source = env.dotfiles_root.join("app/greet.tmpl");
1088        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
1089        assert_eq!(
1090            String::from_utf8_lossy(&result[0].content),
1091            "こんにちは 世界! 🎉"
1092        );
1093    }
1094
1095    #[test]
1096    fn rendering_is_deterministic_across_calls() {
1097        // Calling `expand` multiple times with the same inputs must
1098        // produce byte-identical output. This guards against any
1099        // hidden state leaking between renders (e.g. a stale globals
1100        // cache, a reseeded RNG, or a leaked side-effect into the
1101        // Environment).
1102        let env = crate::testing::TempEnvironment::builder()
1103            .pack("app")
1104            .file(
1105                "cfg.tmpl",
1106                "name={{ name }} os={{ dodot.os }} home={{ dodot.home }}",
1107            )
1108            .done()
1109            .build();
1110
1111        let mut vars = HashMap::new();
1112        vars.insert("name".into(), "Alice".into());
1113        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
1114
1115        let source = env.dotfiles_root.join("app/cfg.tmpl");
1116        let first = pp.expand(&source, env.fs.as_ref()).unwrap();
1117        let second = pp.expand(&source, env.fs.as_ref()).unwrap();
1118        let third = pp.expand(&source, env.fs.as_ref()).unwrap();
1119
1120        assert_eq!(first[0].content, second[0].content);
1121        assert_eq!(second[0].content, third[0].content);
1122    }
1123
1124    #[test]
1125    fn stripped_name_of_literal_extension_returns_empty() {
1126        // Edge case recording the current (defensive) behavior: a file
1127        // named exactly `.tmpl` (extension and nothing else) strips to
1128        // the empty string. In normal packs the scanner filters dotfiles
1129        // out before they reach the preprocessor, so this won't happen
1130        // via user flows. But a misconfigured preprocessor extension or
1131        // an archive entry with no stem could still produce an empty
1132        // path downstream, and the pipeline is expected to reject that
1133        // with a useful error — see
1134        // `pipeline::rejects_empty_path_from_preprocessor`.
1135        let pp = new_pp(HashMap::new());
1136        assert_eq!(pp.stripped_name(".tmpl"), "");
1137        assert!(pp.matches_extension(".tmpl"));
1138    }
1139
1140    #[test]
1141    fn build_dodot_context_omits_undetected_optional_keys() {
1142        // Directly exercise the map-building helper: given a Pather but
1143        // the detection helpers return None (simulated via testing the
1144        // helper return invariants), verify the map structure.
1145        //
1146        // Since `detect_username`/`detect_hostname` read real env/system
1147        // state, we can only assert: if they return Some, the key is
1148        // present; if they return None, the key is absent.
1149        let ctx = build_dodot_context(&make_pather());
1150
1151        // These are always present:
1152        assert!(ctx.contains_key("os"));
1153        assert!(ctx.contains_key("arch"));
1154        assert!(ctx.contains_key("home"));
1155        assert!(ctx.contains_key("dotfiles_root"));
1156
1157        // Optional keys: present iff the detection helper returned Some.
1158        assert_eq!(ctx.contains_key("username"), detect_username().is_some());
1159        assert_eq!(ctx.contains_key("hostname"), detect_hostname().is_some());
1160    }
1161
1162    // ── Tracked render + context hash ────────────────────────────
1163
1164    #[test]
1165    fn expand_emits_tracked_render_with_markers_around_each_variable() {
1166        // The cache layer needs the marker-annotated render to drive
1167        // burgertocow's reverse-diff. Confirm that each `{{ var }}`
1168        // emission produces exactly one VAR_START / VAR_END pair in
1169        // the tracked string.
1170        let env = crate::testing::TempEnvironment::builder()
1171            .pack("app")
1172            .file("cfg.tmpl", "name={{ name }} count={{ count }}")
1173            .done()
1174            .build();
1175
1176        let mut vars = HashMap::new();
1177        vars.insert("name".into(), "Alice".into());
1178        vars.insert("count".into(), "3".into());
1179        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
1180
1181        let source = env.dotfiles_root.join("app/cfg.tmpl");
1182        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
1183        let tracked = result[0]
1184            .tracked_render
1185            .as_ref()
1186            .expect("tracked render must be present for a generative preprocessor");
1187        assert_eq!(
1188            tracked.matches(burgertocow::VAR_START).count(),
1189            2,
1190            "two variable emissions should produce two start markers, got: {tracked:?}"
1191        );
1192        assert_eq!(
1193            tracked.matches(burgertocow::VAR_END).count(),
1194            2,
1195            "two variable emissions should produce two end markers, got: {tracked:?}"
1196        );
1197    }
1198
1199    #[test]
1200    fn expand_visible_output_matches_tracked_with_markers_stripped() {
1201        // The visible content (what the symlink target sees) must equal
1202        // the tracked string with marker bytes removed. Otherwise the
1203        // baseline cache's `rendered_content` and the deployed file
1204        // would diverge by exactly the marker characters.
1205        let env = crate::testing::TempEnvironment::builder()
1206            .pack("app")
1207            .file("cfg.tmpl", "user={{ name }} home={{ dodot.home }}")
1208            .done()
1209            .build();
1210
1211        let mut vars = HashMap::new();
1212        vars.insert("name".into(), "Alice".into());
1213        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
1214
1215        let source = env.dotfiles_root.join("app/cfg.tmpl");
1216        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
1217        let visible = String::from_utf8(result[0].content.clone()).unwrap();
1218        let tracked = result[0].tracked_render.as_ref().unwrap();
1219
1220        let stripped: String = tracked
1221            .chars()
1222            .filter(|c| *c != burgertocow::VAR_START && *c != burgertocow::VAR_END)
1223            .collect();
1224        assert_eq!(visible, stripped);
1225    }
1226
1227    #[test]
1228    fn context_hash_is_populated_and_stable() {
1229        // Same constructor inputs should produce the same context hash
1230        // across runs and across `expand` calls. This is what lets the
1231        // baseline cache decide "input didn't change" without re-rendering.
1232        let env = crate::testing::TempEnvironment::builder()
1233            .pack("app")
1234            .file("a.tmpl", "x={{ name }}")
1235            .done()
1236            .build();
1237
1238        let mut vars = HashMap::new();
1239        vars.insert("name".into(), "Alice".into());
1240        let pp1 = TemplatePreprocessor::new(vec!["tmpl".into()], vars.clone(), env.paths.as_ref())
1241            .unwrap();
1242        let pp2 = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
1243
1244        assert_eq!(
1245            pp1.context_hash, pp2.context_hash,
1246            "identical inputs must yield identical context hashes"
1247        );
1248
1249        let source = env.dotfiles_root.join("app/a.tmpl");
1250        let r1 = pp1.expand(&source, env.fs.as_ref()).unwrap();
1251        let r2 = pp1.expand(&source, env.fs.as_ref()).unwrap();
1252        assert_eq!(r1[0].context_hash, r2[0].context_hash);
1253        assert_eq!(r1[0].context_hash, Some(pp1.context_hash));
1254    }
1255
1256    #[test]
1257    fn context_hash_changes_when_user_var_changes() {
1258        // A different user-var value MUST produce a different context
1259        // hash. Without this, secret rotation through user vars wouldn't
1260        // re-run install/homebrew sentinels (whose freshness is keyed off
1261        // the context hash via §3.5 of the secrets spec).
1262        let mut vars1 = HashMap::new();
1263        vars1.insert("name".into(), "Alice".into());
1264
1265        let mut vars2 = HashMap::new();
1266        vars2.insert("name".into(), "Bob".into());
1267
1268        let pather = make_pather();
1269        let pp1 = TemplatePreprocessor::new(vec!["tmpl".into()], vars1, &pather).unwrap();
1270        let pp2 = TemplatePreprocessor::new(vec!["tmpl".into()], vars2, &pather).unwrap();
1271        assert_ne!(pp1.context_hash, pp2.context_hash);
1272    }
1273
1274    #[test]
1275    fn context_hash_is_order_independent_for_user_vars() {
1276        // Hash inputs are gathered from a HashMap, so iteration order
1277        // is non-deterministic. Sorting via BTreeMap before hashing must
1278        // produce a stable hash regardless of insertion order.
1279        let pather = make_pather();
1280
1281        let mut a = HashMap::new();
1282        a.insert("alpha".into(), "1".into());
1283        a.insert("zeta".into(), "26".into());
1284
1285        let mut b = HashMap::new();
1286        b.insert("zeta".into(), "26".into());
1287        b.insert("alpha".into(), "1".into());
1288
1289        let pp_a = TemplatePreprocessor::new(vec!["tmpl".into()], a, &pather).unwrap();
1290        let pp_b = TemplatePreprocessor::new(vec!["tmpl".into()], b, &pather).unwrap();
1291        assert_eq!(pp_a.context_hash, pp_b.context_hash);
1292    }
1293
1294    #[test]
1295    fn empty_template_still_emits_tracked_render() {
1296        // Edge case: a template with no `{{ ... }}` emissions. The
1297        // tracked string should be the same as the visible content
1298        // (no markers added) and still be present, not None.
1299        let env = crate::testing::TempEnvironment::builder()
1300            .pack("app")
1301            .file("plain.tmpl", "no vars at all")
1302            .done()
1303            .build();
1304
1305        let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
1306            .unwrap();
1307
1308        let source = env.dotfiles_root.join("app/plain.tmpl");
1309        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
1310        let tracked = result[0].tracked_render.as_ref().unwrap();
1311        assert!(
1312            !tracked.contains(burgertocow::VAR_START) && !tracked.contains(burgertocow::VAR_END),
1313            "no variables → no markers, got: {tracked:?}"
1314        );
1315        // And still equal to the visible content.
1316        assert_eq!(
1317            String::from_utf8(result[0].content.clone()).unwrap(),
1318            *tracked
1319        );
1320    }
1321
1322    // ── secret() integration (Phase S1) ────────────────────────
1323
1324    /// Build a TemplatePreprocessor wired with a registry containing
1325    /// the given canned `(reference, value)` pairs under one scheme.
1326    /// The scheme is used as both the URI prefix and the
1327    /// MockSecretProvider's `scheme()` return.
1328    fn pp_with_secrets(scheme: &str, pairs: &[(&str, &str)]) -> TemplatePreprocessor {
1329        use crate::secret::test_support::MockSecretProvider;
1330        use crate::secret::SecretRegistry;
1331        use std::sync::Arc;
1332
1333        let mut mock = MockSecretProvider::new(scheme);
1334        for (k, v) in pairs {
1335            mock = mock.with(k.to_string(), v.to_string());
1336        }
1337        let mut registry = SecretRegistry::new();
1338        registry.register(Arc::new(mock));
1339        new_pp(HashMap::new()).with_secret_registry(Arc::new(registry))
1340    }
1341
1342    #[test]
1343    fn secret_function_resolves_via_registry() {
1344        // pass:path/to/db -> "hunter2"; the registry strips the scheme
1345        // and hands "path/to/db" to the provider, which returns the
1346        // canned value.
1347        let pp = pp_with_secrets("pass", &[("path/to/db", "hunter2")]);
1348        let env = crate::testing::TempEnvironment::builder()
1349            .pack("app")
1350            .file(
1351                "config.toml.tmpl",
1352                "password = \"{{ secret('pass:path/to/db') }}\"\n",
1353            )
1354            .done()
1355            .build();
1356        let source = env.dotfiles_root.join("app/config.toml.tmpl");
1357        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
1358        let rendered = String::from_utf8_lossy(&result[0].content);
1359        assert_eq!(rendered, "password = \"hunter2\"\n");
1360    }
1361
1362    #[test]
1363    fn secret_function_caches_repeated_references_within_a_render() {
1364        // Same `{{ secret('pass:k') }}` used three times — the
1365        // provider should only be invoked once. Pin the within-run
1366        // cache contract from `secrets.lex` §7.4 / Phase S2.
1367        use crate::secret::test_support::MockSecretProvider;
1368        use crate::secret::SecretRegistry;
1369
1370        let mock = Arc::new(MockSecretProvider::new("pass").with("k", "v"));
1371        let mut registry = SecretRegistry::new();
1372        registry.register(mock.clone());
1373        let pp = new_pp(HashMap::new()).with_secret_registry(Arc::new(registry));
1374
1375        let env = crate::testing::TempEnvironment::builder()
1376            .pack("app")
1377            .file(
1378                "c.tmpl",
1379                "a = {{ secret('pass:k') }}\nb = {{ secret('pass:k') }}\nc = {{ secret('pass:k') }}\n",
1380            )
1381            .done()
1382            .build();
1383        let source = env.dotfiles_root.join("app/c.tmpl");
1384        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
1385        let rendered = String::from_utf8_lossy(&result[0].content);
1386        assert_eq!(rendered, "a = v\nb = v\nc = v\n");
1387        // Cache hit on calls 2 and 3.
1388        assert_eq!(
1389            mock.resolve_call_count(),
1390            1,
1391            "within-run cache must collapse repeats"
1392        );
1393        // Each call still gets its own sidecar entry — sentinels
1394        // are per-call, line ranges cover all three lines.
1395        assert_eq!(result[0].secret_line_ranges.len(), 3);
1396    }
1397
1398    #[test]
1399    fn secret_function_caches_across_multiple_expands_on_one_registry() {
1400        // Building the registry once and rendering N templates
1401        // through it = one provider call per unique reference,
1402        // not per template. This pins the `commands::up` flow
1403        // where one preflighted registry threads through every
1404        // pack rendered in the run.
1405        use crate::secret::test_support::MockSecretProvider;
1406        use crate::secret::SecretRegistry;
1407
1408        let mock = Arc::new(MockSecretProvider::new("pass").with("k", "v"));
1409        let mut registry = SecretRegistry::new();
1410        registry.register(mock.clone());
1411        let registry = Arc::new(registry);
1412
1413        // Two independent TemplatePreprocessor instances both wired
1414        // to the same Arc<SecretRegistry>.
1415        let pp_a = new_pp(HashMap::new()).with_secret_registry(registry.clone());
1416        let pp_b = new_pp(HashMap::new()).with_secret_registry(registry.clone());
1417
1418        let env = crate::testing::TempEnvironment::builder()
1419            .pack("app")
1420            .file("a.tmpl", "{{ secret('pass:k') }}\n")
1421            .file("b.tmpl", "{{ secret('pass:k') }}\n")
1422            .done()
1423            .build();
1424        let _ = pp_a
1425            .expand(&env.dotfiles_root.join("app/a.tmpl"), env.fs.as_ref())
1426            .unwrap();
1427        let _ = pp_b
1428            .expand(&env.dotfiles_root.join("app/b.tmpl"), env.fs.as_ref())
1429            .unwrap();
1430        assert_eq!(
1431            mock.resolve_call_count(),
1432            1,
1433            "shared registry should serve the second expand from cache"
1434        );
1435    }
1436
1437    #[test]
1438    fn secret_function_records_sidecar_entry_with_correct_line_range() {
1439        let pp = pp_with_secrets("pass", &[("k1", "v1"), ("k2", "v2")]);
1440        let env = crate::testing::TempEnvironment::builder()
1441            .pack("app")
1442            .file(
1443                "c.tmpl",
1444                "first\nsecond = {{ secret('pass:k1') }}\nthird\nfourth = {{ secret('pass:k2') }}\n",
1445            )
1446            .done()
1447            .build();
1448        let source = env.dotfiles_root.join("app/c.tmpl");
1449        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
1450        // k1's value lands on line 1 (0-indexed), k2's on line 3.
1451        let ranges = &result[0].secret_line_ranges;
1452        assert_eq!(ranges.len(), 2);
1453        assert_eq!(ranges[0].reference, "pass:k1");
1454        assert_eq!(ranges[0].start, 1);
1455        assert_eq!(ranges[0].end, 2);
1456        assert_eq!(ranges[1].reference, "pass:k2");
1457        assert_eq!(ranges[1].start, 3);
1458        assert_eq!(ranges[1].end, 4);
1459    }
1460
1461    #[test]
1462    fn secret_function_refuses_multiline_value_per_section_3_4() {
1463        let pp = pp_with_secrets("pass", &[("multi", "line1\nline2")]);
1464        let env = crate::testing::TempEnvironment::builder()
1465            .pack("app")
1466            .file("c.tmpl", "x = {{ secret('pass:multi') }}\n")
1467            .done()
1468            .build();
1469        let source = env.dotfiles_root.join("app/c.tmpl");
1470        let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
1471        let msg = err.to_string();
1472        assert!(msg.contains("multi-line value"));
1473        assert!(msg.contains("single-line only"));
1474        // Points the user at the whole-file path, not just rejecting.
1475        assert!(msg.contains("whole-file deploy"));
1476    }
1477
1478    #[test]
1479    fn secret_function_propagates_provider_resolve_failure() {
1480        let pp = pp_with_secrets("pass", &[]); // no canned values
1481        let env = crate::testing::TempEnvironment::builder()
1482            .pack("app")
1483            .file("c.tmpl", "x = {{ secret('pass:missing') }}\n")
1484            .done()
1485            .build();
1486        let source = env.dotfiles_root.join("app/c.tmpl");
1487        let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
1488        let msg = err.to_string();
1489        // The mock's "no canned value" message comes through.
1490        assert!(msg.contains("MockSecretProvider"));
1491        assert!(msg.contains("missing"));
1492    }
1493
1494    #[test]
1495    fn secret_function_unknown_scheme_lists_configured_schemes() {
1496        // Registry has `pass` only; template references `op://...`.
1497        let pp = pp_with_secrets("pass", &[("k", "v")]);
1498        let env = crate::testing::TempEnvironment::builder()
1499            .pack("app")
1500            .file("c.tmpl", "x = {{ secret('op://V/I/F') }}\n")
1501            .done()
1502            .build();
1503        let source = env.dotfiles_root.join("app/c.tmpl");
1504        let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
1505        let msg = err.to_string();
1506        assert!(msg.contains("no secret provider registered for scheme `op`"));
1507        assert!(msg.contains("pass")); // configured-schemes listing
1508    }
1509
1510    #[test]
1511    fn secret_function_without_registry_errors_with_actionable_hint() {
1512        // No `with_secret_registry` call → secret() exists but every
1513        // call surfaces a config-pointing error rather than
1514        // MiniJinja's generic "undefined" diagnostic.
1515        let pp = new_pp(HashMap::new());
1516        let env = crate::testing::TempEnvironment::builder()
1517            .pack("app")
1518            .file("c.tmpl", "x = {{ secret('pass:k') }}\n")
1519            .done()
1520            .build();
1521        let source = env.dotfiles_root.join("app/c.tmpl");
1522        let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
1523        let msg = err.to_string();
1524        assert!(msg.contains("no secret providers are configured"));
1525        assert!(msg.contains("[secret.providers."));
1526        assert!(msg.contains("pass:k"));
1527    }
1528
1529    #[test]
1530    fn secret_function_supports_multiple_schemes_in_one_template() {
1531        use crate::secret::test_support::MockSecretProvider;
1532        use crate::secret::SecretRegistry;
1533        use std::sync::Arc;
1534
1535        let mut registry = SecretRegistry::new();
1536        registry.register(Arc::new(
1537            MockSecretProvider::new("pass").with("db", "from-pass"),
1538        ));
1539        registry.register(Arc::new(
1540            MockSecretProvider::new("op").with("//V/I/password", "from-op"),
1541        ));
1542        let pp = new_pp(HashMap::new()).with_secret_registry(Arc::new(registry));
1543
1544        let env = crate::testing::TempEnvironment::builder()
1545            .pack("app")
1546            .file(
1547                "c.tmpl",
1548                "a={{ secret('pass:db') }}\nb={{ secret('op://V/I/password') }}\n",
1549            )
1550            .done()
1551            .build();
1552        let source = env.dotfiles_root.join("app/c.tmpl");
1553        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
1554        let rendered = String::from_utf8_lossy(&result[0].content);
1555        assert_eq!(rendered, "a=from-pass\nb=from-op\n");
1556        assert_eq!(result[0].secret_line_ranges.len(), 2);
1557    }
1558
1559    #[test]
1560    fn secret_function_tracks_render_into_baseline() {
1561        // The secret value must appear in the visible content AND
1562        // (because templates are reverse-merge-capable) in the
1563        // tracked_render. Burgertocow markers wrap the variable
1564        // emission; the secret value appears between markers in the
1565        // tracked stream.
1566        let pp = pp_with_secrets("pass", &[("k", "topsecret")]);
1567        let env = crate::testing::TempEnvironment::builder()
1568            .pack("app")
1569            .file("c.tmpl", "x = {{ secret('pass:k') }}\n")
1570            .done()
1571            .build();
1572        let source = env.dotfiles_root.join("app/c.tmpl");
1573        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
1574        let rendered = String::from_utf8_lossy(&result[0].content);
1575        assert_eq!(rendered, "x = topsecret\n");
1576
1577        let tracked = result[0]
1578            .tracked_render
1579            .as_ref()
1580            .expect("template render produces tracked stream");
1581        assert!(
1582            tracked.contains("topsecret"),
1583            "tracked render should contain the resolved value, got: {tracked:?}"
1584        );
1585    }
1586
1587    /// Build a `SecretCallEntry` and the rendered text it would
1588    /// produce when MiniJinja substitutes the sentinel for the value.
1589    /// Tests construct the rendered text with the sentinel in place
1590    /// (mimicking `secret()`'s return value) so `finalize_secrets`
1591    /// has something to find.
1592    fn entry(idx: usize, reference: &str, value: &str) -> (SecretCallEntry, String) {
1593        let sentinel = make_secret_sentinel(0, idx);
1594        let entry = SecretCallEntry {
1595            sentinel: sentinel.clone(),
1596            reference: reference.to_string(),
1597            value: value.to_string(),
1598        };
1599        (entry, sentinel)
1600    }
1601
1602    #[test]
1603    fn finalize_secrets_substitutes_sentinels_and_records_line_ranges() {
1604        let (e, sentinel) = entry(0, "pass:k", "hunter2");
1605        let rendered = format!("header\nuser = alice\npassword = {sentinel}\nfooter\n");
1606        let (final_rendered, _, ranges) = finalize_secrets(rendered, String::new(), &[e]);
1607        assert_eq!(ranges.len(), 1);
1608        assert_eq!((ranges[0].start, ranges[0].end), (2, 3));
1609        assert_eq!(ranges[0].reference, "pass:k");
1610        assert_eq!(
1611            final_rendered,
1612            "header\nuser = alice\npassword = hunter2\nfooter\n"
1613        );
1614        assert!(!final_rendered.contains('\u{E000}'));
1615    }
1616
1617    #[test]
1618    fn finalize_secrets_does_not_match_value_substring_outside_sentinel() {
1619        // The substring-based predecessor would mark line 0 (the
1620        // greeting also contains "hunter2"); the sentinel approach
1621        // only matches the exact secret slot.
1622        let (e, sentinel) = entry(0, "pass:k", "hunter2");
1623        let rendered = format!("greeting = hunter2 hi\npassword = {sentinel}\n");
1624        let (final_rendered, _, ranges) = finalize_secrets(rendered, String::new(), &[e]);
1625        assert_eq!(ranges.len(), 1);
1626        assert_eq!((ranges[0].start, ranges[0].end), (1, 2));
1627        assert_eq!(
1628            final_rendered,
1629            "greeting = hunter2 hi\npassword = hunter2\n"
1630        );
1631    }
1632
1633    #[test]
1634    fn finalize_secrets_handles_two_calls_resolving_to_same_value() {
1635        // Two distinct sentinels even when the values are identical;
1636        // both lines are masked.
1637        let (e1, s1) = entry(0, "pass:a", "shared");
1638        let (e2, s2) = entry(1, "pass:b", "shared");
1639        let rendered = format!("a = {s1}\nb = {s2}\n");
1640        let (final_rendered, _, ranges) = finalize_secrets(rendered, String::new(), &[e1, e2]);
1641        assert_eq!(ranges.len(), 2);
1642        assert_eq!((ranges[0].start, ranges[0].end), (0, 1));
1643        assert_eq!((ranges[1].start, ranges[1].end), (1, 2));
1644        assert_eq!(final_rendered, "a = shared\nb = shared\n");
1645    }
1646
1647    #[test]
1648    fn finalize_secrets_drops_entries_whose_sentinel_was_not_emitted() {
1649        // `secret()` was evaluated (e.g. inside a false `{% if %}`)
1650        // but the sentinel never reached the visible output. We
1651        // don't synthesise a fake range; we still substitute (a
1652        // no-op here) so callers can rely on the post-call output
1653        // being sentinel-free.
1654        let (e, _sentinel) = entry(0, "pass:hidden", "never-emitted");
1655        let rendered = "clean output\n".to_string();
1656        let (final_rendered, _, ranges) = finalize_secrets(rendered, String::new(), &[e]);
1657        assert!(ranges.is_empty());
1658        assert_eq!(final_rendered, "clean output\n");
1659    }
1660
1661    #[test]
1662    fn finalize_secrets_substitutes_sentinels_in_tracked_render_too() {
1663        // Sentinels must not leak into the baseline cache via the
1664        // tracked stream.
1665        let (e, sentinel) = entry(0, "pass:k", "hunter2");
1666        let tracked = format!("preamble {sentinel} epilogue");
1667        let (_, final_tracked, _) = finalize_secrets(String::new(), tracked, &[e]);
1668        assert_eq!(final_tracked, "preamble hunter2 epilogue");
1669    }
1670}