1use 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
45const RESERVED_VARS: &[&str] = &["dodot", "env"];
47
48#[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 Enumerator::NonEnumerable
70 }
71}
72
73pub struct TemplatePreprocessor {
83 extensions: Vec<String>,
84 dodot_ns: BTreeMap<String, String>,
85 user_vars: BTreeMap<String, String>,
86 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 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 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 true
179 }
180
181 fn matches_extension(&self, filename: &str) -> bool {
182 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 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 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
253fn 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
286fn 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
317fn cached_hostname() -> Option<&'static String> {
320 static CACHE: OnceLock<Option<String>> = OnceLock::new();
321 CACHE.get_or_init(detect_hostname).as_ref()
322}
323
324fn 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 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
361fn format_minijinja_error(err: &minijinja::Error) -> String {
364 use minijinja::ErrorKind;
365
366 let base = match err.kind() {
367 ErrorKind::UndefinedError => {
368 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 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 #[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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let ctx = build_dodot_context(&make_pather());
887
888 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 assert_eq!(ctx.contains_key("username"), detect_username().is_some());
896 assert_eq!(ctx.contains_key("hostname"), detect_hostname().is_some());
897 }
898
899 #[test]
902 fn expand_emits_tracked_render_with_markers_around_each_variable() {
903 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 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 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 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 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 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 assert_eq!(
1054 String::from_utf8(result[0].content.clone()).unwrap(),
1055 *tracked
1056 );
1057 }
1058}