1use 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
47const RESERVED_VARS: &[&str] = &["dodot", "env"];
49
50#[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 Enumerator::NonEnumerable
72 }
73}
74
75pub struct TemplatePreprocessor {
85 extensions: Vec<String>,
86 dodot_ns: BTreeMap<String, String>,
87 user_vars: BTreeMap<String, String>,
88 context_hash: [u8; 32],
103 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 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 pub fn with_secret_registry(mut self, registry: Arc<SecretRegistry>) -> Self {
166 self.secret_registry = Some(registry);
167 self
168 }
169
170 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 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 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 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 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 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 true
326 }
327
328 fn matches_extension(&self, filename: &str) -> bool {
329 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 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 let template_name = source.to_string_lossy().into_owned();
364
365 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
413struct SecretCallEntry {
419 sentinel: String,
420 reference: String,
421 value: String,
422}
423
424static RENDER_COUNTER: AtomicU64 = AtomicU64::new(1);
428
429fn next_render_id() -> u64 {
430 RENDER_COUNTER.fetch_add(1, Ordering::Relaxed)
431}
432
433fn 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
452fn 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
493fn 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
507fn 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
516fn 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
549fn 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
580fn cached_hostname() -> Option<&'static String> {
583 static CACHE: OnceLock<Option<String>> = OnceLock::new();
584 CACHE.get_or_init(detect_hostname).as_ref()
585}
586
587fn 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 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
624fn format_minijinja_error(err: &minijinja::Error) -> String {
627 use minijinja::ErrorKind;
628
629 let base = match err.kind() {
630 ErrorKind::UndefinedError => {
631 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 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 #[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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let ctx = build_dodot_context(&make_pather());
1150
1151 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 assert_eq!(ctx.contains_key("username"), detect_username().is_some());
1159 assert_eq!(ctx.contains_key("hostname"), detect_hostname().is_some());
1160 }
1161
1162 #[test]
1165 fn expand_emits_tracked_render_with_markers_around_each_variable() {
1166 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 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 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 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 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 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 assert_eq!(
1317 String::from_utf8(result[0].content.clone()).unwrap(),
1318 *tracked
1319 );
1320 }
1321
1322 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 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 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 assert_eq!(
1389 mock.resolve_call_count(),
1390 1,
1391 "within-run cache must collapse repeats"
1392 );
1393 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 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 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 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 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", &[]); 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 assert!(msg.contains("MockSecretProvider"));
1491 assert!(msg.contains("missing"));
1492 }
1493
1494 #[test]
1495 fn secret_function_unknown_scheme_lists_configured_schemes() {
1496 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")); }
1509
1510 #[test]
1511 fn secret_function_without_registry_errors_with_actionable_hint() {
1512 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 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 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 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 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 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 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}