1use crate::error::SsgError;
28use serde::{Deserialize, Serialize};
29use sha2::{Digest, Sha256};
30use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
31use std::fs;
32use std::path::{Path, PathBuf};
33
34pub const DEP_GRAPH_FILE: &str = "depgraph.json";
37
38pub const CACHE_DIRNAME: &str = "ssg-cache";
44
45const SCHEMA_VERSION: u32 = 2;
49
50#[derive(Debug, Clone, Default, Serialize, Deserialize)]
57pub struct DepGraph {
58 #[serde(default = "default_version")]
60 version: u32,
61 deps: BTreeMap<PathBuf, BTreeSet<PathBuf>>,
63 #[serde(default)]
66 outputs: BTreeMap<PathBuf, BTreeSet<PathBuf>>,
67 #[serde(default)]
69 hashes: BTreeMap<PathBuf, String>,
70}
71
72const fn default_version() -> u32 {
73 0
76}
77
78impl DepGraph {
79 #[must_use]
90 pub const fn new() -> Self {
91 Self {
92 version: SCHEMA_VERSION,
93 deps: BTreeMap::new(),
94 outputs: BTreeMap::new(),
95 hashes: BTreeMap::new(),
96 }
97 }
98
99 #[must_use]
117 pub fn load(cache_root: &Path) -> Self {
118 let path = cache_root.join(DEP_GRAPH_FILE);
119 let Ok(json) = fs::read_to_string(&path) else {
120 return Self::new();
121 };
122 match serde_json::from_str::<Self>(&json) {
123 Ok(g) if g.version == SCHEMA_VERSION => g,
124 Ok(_) => {
125 log::warn!(
126 "depgraph at {} has incompatible schema; falling back to full rebuild",
127 path.display()
128 );
129 Self::new()
130 }
131 Err(e) => {
132 log::warn!(
133 "depgraph at {} is corrupt ({e}); falling back to full rebuild",
134 path.display()
135 );
136 Self::new()
137 }
138 }
139 }
140
141 pub fn save(&self, cache_root: &Path) -> Result<(), SsgError> {
160 fs::create_dir_all(cache_root).map_err(|e| SsgError::Io {
161 path: cache_root.to_path_buf(),
162 source: e,
163 })?;
164 let final_path = cache_root.join(DEP_GRAPH_FILE);
165 let tmp_path = cache_root.join(format!("{DEP_GRAPH_FILE}.tmp"));
166 let json = serde_json::to_string(self).map_err(|e| SsgError::Io {
167 path: final_path.clone(),
168 source: std::io::Error::other(e),
169 })?;
170 fs::write(&tmp_path, json).map_err(|e| SsgError::Io {
171 path: tmp_path.clone(),
172 source: e,
173 })?;
174 fs::rename(&tmp_path, &final_path).map_err(|e| SsgError::Io {
175 path: final_path,
176 source: e,
177 })?;
178 Ok(())
179 }
180
181 pub fn add_dep(&mut self, consumer: &Path, dep: &Path) {
194 let _ = self
195 .deps
196 .entry(consumer.to_path_buf())
197 .or_default()
198 .insert(dep.to_path_buf());
199 }
200
201 pub fn add_output(&mut self, source: &Path, output: &Path) {
216 let _ = self
217 .outputs
218 .entry(source.to_path_buf())
219 .or_default()
220 .insert(output.to_path_buf());
221 }
222
223 pub fn record_hash(&mut self, path: &Path, content: &[u8]) {
240 let _ = self
241 .hashes
242 .insert(path.to_path_buf(), Self::sha256_hex(content));
243 }
244
245 pub fn record_hash_from_disk(&mut self, path: &Path) {
265 if let Ok(bytes) = fs::read(path) {
266 self.record_hash(path, &bytes);
267 }
268 }
269
270 #[must_use]
283 pub fn sha256_hex(bytes: &[u8]) -> String {
284 let mut hasher = Sha256::new();
285 hasher.update(bytes);
286 let digest = hasher.finalize();
287 let mut out = String::with_capacity(64);
288 for b in digest {
289 use std::fmt::Write as _;
290 let _ = write!(out, "{b:02x}");
291 }
292 out
293 }
294
295 #[must_use]
309 pub fn deps_for(&self, consumer: &Path) -> Option<&BTreeSet<PathBuf>> {
310 self.deps.get(consumer)
311 }
312
313 #[must_use]
326 pub fn outputs_for(&self, source: &Path) -> Option<&BTreeSet<PathBuf>> {
327 self.outputs.get(source)
328 }
329
330 #[must_use]
343 pub fn tracked_sources(&self) -> Vec<PathBuf> {
344 let mut v: Vec<PathBuf> = self.outputs.keys().cloned().collect();
345 v.sort();
346 v
347 }
348
349 #[must_use]
363 pub fn page_count(&self) -> usize {
364 self.deps.len()
365 }
366
367 pub fn forget(&mut self, path: &Path) {
383 let _ = self.deps.remove(path);
384 let _ = self.outputs.remove(path);
385 let _ = self.hashes.remove(path);
386 for set in self.deps.values_mut() {
387 let _ = set.remove(path);
388 }
389 }
390
391 pub fn clear(&mut self) {
405 self.deps.clear();
406 self.outputs.clear();
407 self.hashes.clear();
408 }
409
410 #[must_use]
427 pub fn invalidated(&self, changed: &[PathBuf]) -> Vec<PathBuf> {
428 let reverse = self.reverse_edges();
429 let mut seen: HashSet<PathBuf> = HashSet::new();
430 let mut queue: VecDeque<PathBuf> = changed.iter().cloned().collect();
431 while let Some(p) = queue.pop_front() {
432 if !seen.insert(p.clone()) {
433 continue;
434 }
435 if let Some(parents) = reverse.get(&p) {
436 for parent in parents {
437 if !seen.contains(parent) {
438 queue.push_back(parent.clone());
439 }
440 }
441 }
442 }
443 let mut result: Vec<PathBuf> = seen.into_iter().collect();
444 result.sort();
445 result
446 }
447
448 #[must_use]
465 pub fn invalidated_outputs(&self, changed: &[PathBuf]) -> Vec<PathBuf> {
466 let mut out: HashSet<PathBuf> = HashSet::new();
467 for p in self.invalidated(changed) {
468 if let Some(outs) = self.outputs.get(&p) {
469 for o in outs {
470 let _ = out.insert(o.clone());
471 }
472 }
473 }
474 let mut v: Vec<PathBuf> = out.into_iter().collect();
475 v.sort();
476 v
477 }
478
479 fn reverse_edges(&self) -> HashMap<PathBuf, HashSet<PathBuf>> {
482 let mut rev: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
483 for (consumer, deps) in &self.deps {
484 for dep in deps {
485 let _ = rev
486 .entry(dep.clone())
487 .or_default()
488 .insert(consumer.clone());
489 }
490 }
491 rev
492 }
493
494 #[must_use]
518 pub fn diff(&self, current: &HashMap<PathBuf, String>) -> Diff {
519 let mut changed = Vec::new();
520 let mut deleted = Vec::new();
521 for (path, hash) in current {
522 match self.hashes.get(path) {
523 Some(prev) if prev == hash => {}
524 _ => changed.push(path.clone()),
525 }
526 }
527 for path in self.hashes.keys() {
528 if !current.contains_key(path) {
529 deleted.push(path.clone());
530 }
531 }
532 changed.sort();
533 deleted.sort();
534 Diff { changed, deleted }
535 }
536}
537
538#[derive(Debug, Clone, Default, PartialEq, Eq)]
540pub struct Diff {
541 pub changed: Vec<PathBuf>,
544 pub deleted: Vec<PathBuf>,
547}
548
549impl Diff {
550 #[must_use]
562 pub const fn is_empty(&self) -> bool {
563 self.changed.is_empty() && self.deleted.is_empty()
564 }
565}
566
567fn data_files(content_dir: &Path) -> Vec<PathBuf> {
580 const DATA_EXTS: &[&str] = &["json", "toml", "yaml", "yml"];
583
584 let mut roots = Vec::new();
585 if let Some(parent) = content_dir.parent() {
586 roots.push(parent.join("data"));
587 roots.push(parent.join("_data"));
588 }
589 roots.push(content_dir.join("data"));
590 roots.push(content_dir.join("_data"));
591
592 let mut out = Vec::new();
593 for root in roots {
594 let Ok(entries) = fs::read_dir(&root) else {
595 continue;
596 };
597 for entry in entries.flatten() {
598 let path = entry.path();
599 if !path.is_file() {
600 continue;
601 }
602 let ext = path
603 .extension()
604 .unwrap_or_default()
605 .to_string_lossy()
606 .to_lowercase();
607 if DATA_EXTS.contains(&ext.as_str()) {
608 out.push(path);
609 }
610 }
611 }
612 out.sort();
613 out.dedup();
614 out
615}
616
617pub fn populate(
652 graph: &mut DepGraph,
653 content_dir: &Path,
654 template_dir: &Path,
655 build_dir: &Path,
656) -> Result<(), SsgError> {
657 let md_files = crate::walk::walk_files_bounded_depth(
658 content_dir,
659 "md",
660 crate::MAX_DIR_DEPTH,
661 )?;
662
663 for md in &md_files {
664 let bytes = fs::read(md).map_err(|e| SsgError::Io {
665 path: md.clone(),
666 source: e,
667 })?;
668 graph.record_hash(md, &bytes);
669
670 let layout = extract_layout(&bytes);
671 let outputs = output_paths_for(md, content_dir, build_dir);
672 for o in &outputs {
673 graph.add_output(md, o);
674 graph.add_dep(md, md);
676 if let Some(ref layout_name) = layout {
677 if let Some(tpl) =
678 resolve_template(template_dir, md, content_dir, layout_name)
679 {
680 graph.add_dep(md, &tpl);
681 }
682 }
683 }
684 }
685
686 let data = data_files(content_dir);
692 for df in &data {
693 if let Ok(bytes) = fs::read(df) {
694 graph.record_hash(df, &bytes);
695 }
696 for md in &md_files {
697 graph.add_dep(md, df);
698 }
699 }
700
701 let tpl_files = crate::walk::walk_files_bounded_depth(
702 template_dir,
703 "html",
704 crate::MAX_DIR_DEPTH,
705 )?;
706 for tpl in &tpl_files {
707 let bytes = fs::read(tpl).map_err(|e| SsgError::Io {
708 path: tpl.clone(),
709 source: e,
710 })?;
711 graph.record_hash(tpl, &bytes);
712 let text = String::from_utf8_lossy(&bytes);
713 for parent in scan_template_refs(&text) {
714 let resolved = template_dir.join(format!("{parent}.html"));
715 graph.add_dep(tpl, &resolved);
717 }
718 }
719
720 Ok(())
721}
722
723pub fn current_hashes(
743 content_dir: &Path,
744 template_dir: &Path,
745) -> Result<HashMap<PathBuf, String>, SsgError> {
746 let mut out = HashMap::new();
747 let mut push = |paths: Vec<PathBuf>| {
750 for p in paths {
751 if let Ok(bytes) = fs::read(&p) {
752 let _ = out.insert(p, DepGraph::sha256_hex(&bytes));
753 }
754 }
755 };
756 push(crate::walk::walk_files_bounded_depth(
757 content_dir,
758 "md",
759 crate::MAX_DIR_DEPTH,
760 )?);
761 push(crate::walk::walk_files_bounded_depth(
762 template_dir,
763 "html",
764 crate::MAX_DIR_DEPTH,
765 )?);
766 Ok(out)
767}
768
769fn extract_layout(bytes: &[u8]) -> Option<String> {
772 let text = std::str::from_utf8(bytes).ok()?;
773 let trimmed = text.trim_start();
774 let body = trimmed.strip_prefix("---")?;
775 let end = body.find("\n---")?;
776 let fm = &body[..end];
777 for line in fm.lines() {
778 let l = line.trim();
779 if let Some(rest) = l.strip_prefix("layout:") {
780 return Some(
781 rest.trim()
782 .trim_matches(|c| c == '"' || c == '\'')
783 .split_whitespace()
784 .next()?
785 .trim_matches(|c| c == '"' || c == '\'')
786 .to_string(),
787 );
788 }
789 }
790 None
791}
792
793fn output_paths_for(
801 md: &Path,
802 content_dir: &Path,
803 build_dir: &Path,
804) -> Vec<PathBuf> {
805 let rel = match md.strip_prefix(content_dir) {
806 Ok(r) => r.to_path_buf(),
807 Err(_) => return Vec::new(),
808 };
809 let parent = rel.parent().map(Path::to_path_buf).unwrap_or_default();
810 let stem = rel.file_stem().and_then(|s| s.to_str()).unwrap_or("");
811 let out = if stem == "index" {
812 build_dir.join(&parent).join("index.html")
813 } else {
814 build_dir.join(&parent).join(stem).join("index.html")
815 };
816 vec![out]
817}
818
819fn resolve_template(
826 template_dir: &Path,
827 md: &Path,
828 content_dir: &Path,
829 layout: &str,
830) -> Option<PathBuf> {
831 if let Ok(rel) = md.strip_prefix(content_dir) {
832 if let Some(first) = rel.components().next() {
833 let candidate = template_dir
834 .join(first.as_os_str())
835 .join(format!("{layout}.html"));
836 if candidate.exists() {
837 return Some(candidate);
838 }
839 }
840 }
841 let fallback = template_dir.join(format!("{layout}.html"));
842 if fallback.exists() {
843 Some(fallback)
844 } else {
845 None
846 }
847}
848
849fn scan_template_refs(text: &str) -> Vec<String> {
853 let mut out: Vec<String> = Vec::new();
854 let mut seen: HashSet<String> = HashSet::new();
855 let mut rest = text;
856 while let Some(start) = rest.find("{{") {
857 rest = &rest[start + 2..];
858 let Some(end) = rest.find("}}") else {
859 break;
860 };
861 let inner = rest[..end].trim();
862 rest = &rest[end + 2..];
863 let name_opt = if let Some(after) = inner.strip_prefix("#extends") {
864 Some(parse_name(after.trim()))
865 } else {
866 inner
867 .strip_prefix("->")
868 .map(|after| parse_name(after.trim()))
869 };
870 if let Some(name) = name_opt {
871 if !name.is_empty() && seen.insert(name.clone()) {
872 out.push(name);
873 }
874 }
875 }
876 out
877}
878
879fn parse_name(s: &str) -> String {
883 let s = s.trim().trim_matches(|c| c == '"' || c == '\'');
884 s.split(|c: char| c.is_whitespace() || c == '"' || c == '\'')
885 .next()
886 .unwrap_or("")
887 .to_string()
888}
889
890#[cfg(test)]
891mod tests {
892 use super::*;
893 use tempfile::tempdir;
894
895 fn write(p: &Path, body: &str) {
896 if let Some(parent) = p.parent() {
897 fs::create_dir_all(parent).unwrap();
898 }
899 fs::write(p, body).unwrap();
900 }
901
902 #[test]
903 fn empty_graph_only_reports_changed_inputs() {
904 let graph = DepGraph::new();
905 let changed = vec![PathBuf::from("content/index.md")];
906 let result = graph.invalidated(&changed);
907 assert_eq!(result, vec![PathBuf::from("content/index.md")]);
908 }
909
910 #[test]
911 fn direct_change_invalidates_only_the_page() {
912 let mut graph = DepGraph::new();
913 let page = PathBuf::from("content/about.md");
914 let tmpl = PathBuf::from("templates/base.html");
915 graph.add_dep(&page, &tmpl);
916
917 let changed = vec![page.clone()];
918 let result = graph.invalidated(&changed);
919 assert!(result.contains(&page));
920 assert_eq!(result.len(), 1, "no other consumers should fire");
921 }
922
923 #[test]
924 fn dependency_change_invalidates_all_consumers() {
925 let mut graph = DepGraph::new();
926 let a = PathBuf::from("content/index.md");
927 let b = PathBuf::from("content/about.md");
928 let tmpl = PathBuf::from("templates/base.html");
929 graph.add_dep(&a, &tmpl);
930 graph.add_dep(&b, &tmpl);
931
932 let result = graph.invalidated(std::slice::from_ref(&tmpl));
933 assert!(result.contains(&a));
934 assert!(result.contains(&b));
935 assert!(result.contains(&tmpl));
936 assert_eq!(result.len(), 3);
937 }
938
939 #[test]
940 fn transitive_edges_are_tracked_via_bfs() {
941 let mut graph = DepGraph::new();
945 let page = PathBuf::from("content/index.md");
946 let partial = PathBuf::from("templates/partial.html");
947 let base = PathBuf::from("templates/base.html");
948 graph.add_dep(&page, &partial);
949 graph.add_dep(&partial, &base);
950
951 let result = graph.invalidated(std::slice::from_ref(&base));
952 assert!(result.contains(&base));
953 assert!(result.contains(&partial));
954 assert!(
955 result.contains(&page),
956 "transitive consumer must be invalidated"
957 );
958 }
959
960 #[test]
961 fn invalidated_outputs_unions_outputs_of_every_consumer() {
962 let mut graph = DepGraph::new();
963 let page = PathBuf::from("content/about.md");
964 let out = PathBuf::from("public/about/index.html");
965 let tmpl = PathBuf::from("templates/page.html");
966 graph.add_dep(&page, &tmpl);
967 graph.add_output(&page, &out);
968
969 let result = graph.invalidated_outputs(&[tmpl]);
970 assert_eq!(result, vec![out]);
971 }
972
973 #[test]
974 fn diff_reports_changed_new_and_deleted() {
975 let mut graph = DepGraph::new();
976 graph.record_hash(Path::new("a.md"), b"alpha");
977 graph.record_hash(Path::new("b.md"), b"beta");
978 graph.record_hash(Path::new("c.md"), b"gamma");
979
980 let mut current = HashMap::new();
981 let _ = current
982 .insert(PathBuf::from("a.md"), DepGraph::sha256_hex(b"alpha"));
983 let _ = current
985 .insert(PathBuf::from("b.md"), DepGraph::sha256_hex(b"beta-prime"));
986 let _ = current
989 .insert(PathBuf::from("d.md"), DepGraph::sha256_hex(b"delta"));
990
991 let diff = graph.diff(¤t);
992 assert_eq!(
993 diff.changed,
994 vec![PathBuf::from("b.md"), PathBuf::from("d.md")]
995 );
996 assert_eq!(diff.deleted, vec![PathBuf::from("c.md")]);
997 assert!(!diff.is_empty());
998 }
999
1000 #[test]
1001 fn diff_no_changes_is_empty() {
1002 let mut graph = DepGraph::new();
1003 graph.record_hash(Path::new("a.md"), b"alpha");
1004 let mut current = HashMap::new();
1005 let _ = current
1006 .insert(PathBuf::from("a.md"), DepGraph::sha256_hex(b"alpha"));
1007 let diff = graph.diff(¤t);
1008 assert!(diff.is_empty());
1009 }
1010
1011 #[test]
1012 fn save_and_load_round_trip_preserves_edges_and_hashes() {
1013 let dir = tempdir().unwrap();
1014 let mut graph = DepGraph::new();
1015 let page = PathBuf::from("content/index.md");
1016 let tmpl = PathBuf::from("templates/base.html");
1017 let out = PathBuf::from("public/index.html");
1018 graph.add_dep(&page, &tmpl);
1019 graph.add_output(&page, &out);
1020 graph.record_hash(&page, b"hello");
1021
1022 graph.save(dir.path()).unwrap();
1023 let loaded = DepGraph::load(dir.path());
1024
1025 assert_eq!(loaded.deps_for(&page).unwrap().len(), 1);
1026 assert!(loaded.outputs_for(&page).unwrap().contains(&out));
1027 let mut current = HashMap::new();
1028 let _ = current.insert(page, DepGraph::sha256_hex(b"hello"));
1029 assert!(loaded.diff(¤t).is_empty());
1030 }
1031
1032 #[test]
1033 fn load_missing_file_yields_empty_graph() {
1034 let dir = tempdir().unwrap();
1035 let graph = DepGraph::load(dir.path());
1036 assert_eq!(graph.page_count(), 0);
1037 assert_eq!(graph.version, SCHEMA_VERSION);
1038 }
1039
1040 #[test]
1041 fn load_corrupt_json_falls_back_to_empty_ac6() {
1042 let dir = tempdir().unwrap();
1043 fs::write(dir.path().join(DEP_GRAPH_FILE), "{{ not json").unwrap();
1044 let graph = DepGraph::load(dir.path());
1045 assert_eq!(graph.page_count(), 0);
1046 }
1047
1048 #[test]
1049 fn load_wrong_schema_version_falls_back_to_empty_ac6() {
1050 let dir = tempdir().unwrap();
1051 let body =
1052 r#"{"version":0,"deps":{},"outputs":{},"hashes":{}}"#.to_string();
1053 fs::write(dir.path().join(DEP_GRAPH_FILE), body).unwrap();
1054 let graph = DepGraph::load(dir.path());
1055 assert_eq!(graph.page_count(), 0);
1056 }
1057
1058 #[test]
1059 fn forget_removes_all_traces_of_a_path() {
1060 let mut graph = DepGraph::new();
1061 let page = PathBuf::from("content/about.md");
1062 let other = PathBuf::from("content/index.md");
1063 let tmpl = PathBuf::from("templates/page.html");
1064 graph.add_dep(&page, &tmpl);
1065 graph.add_dep(&other, &tmpl);
1066 graph.add_dep(&other, &page); graph.add_output(&page, Path::new("public/about/index.html"));
1068 graph.record_hash(&page, b"x");
1069
1070 graph.forget(&page);
1071
1072 assert!(graph.deps_for(&page).is_none());
1073 assert!(graph.outputs_for(&page).is_none());
1074 let other_deps = graph.deps_for(&other).unwrap();
1075 assert!(!other_deps.contains(&page));
1076 assert!(other_deps.contains(&tmpl));
1077 }
1078
1079 #[test]
1080 fn clear_empties_everything() {
1081 let mut graph = DepGraph::new();
1082 graph.add_dep(Path::new("a"), Path::new("b"));
1083 graph.add_output(Path::new("a"), Path::new("o"));
1084 graph.record_hash(Path::new("a"), b"x");
1085 graph.clear();
1086 assert_eq!(graph.page_count(), 0);
1087 assert!(graph.tracked_sources().is_empty());
1088 }
1089
1090 #[test]
1091 fn sha256_hex_is_deterministic_64_chars() {
1092 let h = DepGraph::sha256_hex(b"hello");
1093 assert_eq!(h.len(), 64);
1094 assert_eq!(h, DepGraph::sha256_hex(b"hello"));
1095 }
1096
1097 #[test]
1098 fn sha256_hex_distinguishes_inputs() {
1099 assert_ne!(DepGraph::sha256_hex(b"a"), DepGraph::sha256_hex(b"b"));
1100 }
1101
1102 #[test]
1110 fn a_data_file_change_invalidates_every_page() {
1111 let dir = tempdir().unwrap();
1112 let content = dir.path().join("content");
1113 let template = dir.path().join("templates");
1114 let build = dir.path().join("public");
1115 let data = dir.path().join("data");
1116 for d in [&content, &template, &data] {
1117 fs::create_dir_all(d).unwrap();
1118 }
1119 write(
1120 &content.join("index.md"),
1121 "---\nlayout: \"page\"\n---\nbody",
1122 );
1123 write(
1124 &content.join("about.md"),
1125 "---\nlayout: \"page\"\n---\nbody",
1126 );
1127 write(
1128 &template.join("page.html"),
1129 "<html>{{ data.site.name }}</html>",
1130 );
1131 write(&data.join("site.toml"), "name = \"Example\"\n");
1132
1133 let mut graph = DepGraph::new();
1134 populate(&mut graph, &content, &template, &build).unwrap();
1135
1136 let site_data = data.join("site.toml");
1137 let hit = graph.invalidated(std::slice::from_ref(&site_data));
1138 for page in [content.join("index.md"), content.join("about.md")] {
1139 assert!(
1140 hit.contains(&page),
1141 "{} not invalidated by a data-file change",
1142 page.display()
1143 );
1144 }
1145 }
1146
1147 #[test]
1153 fn the_underscore_data_convention_is_tracked_too() {
1154 let dir = tempdir().unwrap();
1155 let content = dir.path().join("content");
1156 let template = dir.path().join("templates");
1157 let build = dir.path().join("public");
1158 let data = dir.path().join("_data");
1159 for d in [&content, &template, &data] {
1160 fs::create_dir_all(d).unwrap();
1161 }
1162 write(
1163 &content.join("index.md"),
1164 "---\nlayout: \"page\"\n---\nbody",
1165 );
1166 write(&template.join("page.html"), "<html></html>");
1167 write(&data.join("topics.toml"), "[rust]\ntitle = \"Rust\"\n");
1168
1169 let mut graph = DepGraph::new();
1170 populate(&mut graph, &content, &template, &build).unwrap();
1171
1172 let topics = data.join("topics.toml");
1173 assert!(
1174 graph
1175 .invalidated(std::slice::from_ref(&topics))
1176 .contains(&content.join("index.md")),
1177 "_data/topics.toml is not tracked"
1178 );
1179 }
1180
1181 #[test]
1184 fn non_data_extensions_in_the_data_dir_are_ignored() {
1185 let dir = tempdir().unwrap();
1186 let content = dir.path().join("content");
1187 let template = dir.path().join("templates");
1188 let build = dir.path().join("public");
1189 let data = dir.path().join("data");
1190 for d in [&content, &template, &data] {
1191 fs::create_dir_all(d).unwrap();
1192 }
1193 write(
1194 &content.join("index.md"),
1195 "---\nlayout: \"page\"\n---\nbody",
1196 );
1197 write(&template.join("page.html"), "<html></html>");
1198 write(&data.join("notes.md"), "not a data file");
1199 write(&data.join("real.json"), "{}");
1200
1201 let mut graph = DepGraph::new();
1202 populate(&mut graph, &content, &template, &build).unwrap();
1203
1204 let index = content.join("index.md");
1205 let deps = graph.deps_for(&index).unwrap();
1206 assert!(deps.contains(&data.join("real.json")), "json not tracked");
1207 assert!(
1208 !deps.contains(&data.join("notes.md")),
1209 "a .md in data/ is not a data file"
1210 );
1211 }
1212
1213 #[test]
1214 fn populate_walks_real_directories_and_records_edges() {
1215 let dir = tempdir().unwrap();
1216 let content = dir.path().join("content");
1217 let template = dir.path().join("templates");
1218 let build = dir.path().join("public");
1219 fs::create_dir_all(&content).unwrap();
1220 fs::create_dir_all(&template).unwrap();
1221
1222 write(
1223 &content.join("index.md"),
1224 "---\nlayout: \"page\"\n---\nbody",
1225 );
1226 write(
1227 &content.join("about.md"),
1228 "---\nlayout: \"page\"\n---\nbody",
1229 );
1230 write(&template.join("page.html"), "<html>{{title}}</html>");
1231
1232 let mut graph = DepGraph::new();
1233 populate(&mut graph, &content, &template, &build).unwrap();
1234
1235 let index = content.join("index.md");
1237 let about = content.join("about.md");
1238 let page_tpl = template.join("page.html");
1239 assert!(graph.deps_for(&index).unwrap().contains(&page_tpl));
1240 assert!(graph.deps_for(&about).unwrap().contains(&page_tpl));
1241
1242 let outs_index = graph.outputs_for(&index).unwrap();
1244 assert!(outs_index.contains(&build.join("index.html")));
1245 let outs_about = graph.outputs_for(&about).unwrap();
1246 assert!(outs_about.contains(&build.join("about").join("index.html")));
1247
1248 assert!(!graph.hashes.is_empty());
1250 }
1251
1252 #[test]
1253 fn populate_records_template_to_template_edges() {
1254 let dir = tempdir().unwrap();
1255 let content = dir.path().join("content");
1256 let template = dir.path().join("templates");
1257 let build = dir.path().join("public");
1258 fs::create_dir_all(&content).unwrap();
1259 fs::create_dir_all(&template).unwrap();
1260
1261 write(&content.join("index.md"), "---\nlayout: \"page\"\n---\n");
1262 write(
1263 &template.join("page.html"),
1264 "{{#extends \"base\"}}\n<p>x</p>",
1265 );
1266 write(&template.join("base.html"), "<html>{{body}}</html>");
1267
1268 let mut graph = DepGraph::new();
1269 populate(&mut graph, &content, &template, &build).unwrap();
1270
1271 let page_tpl = template.join("page.html");
1272 let base_tpl = template.join("base.html");
1273 assert!(
1274 graph.deps_for(&page_tpl).unwrap().contains(&base_tpl),
1275 "template→template edge must be recorded"
1276 );
1277
1278 let invalidated = graph.invalidated(&[base_tpl]);
1280 assert!(invalidated.contains(&content.join("index.md")));
1281 }
1282
1283 #[test]
1284 fn populate_records_partial_references() {
1285 let dir = tempdir().unwrap();
1286 let content = dir.path().join("content");
1287 let template = dir.path().join("templates");
1288 let build = dir.path().join("public");
1289 fs::create_dir_all(&content).unwrap();
1290 fs::create_dir_all(&template).unwrap();
1291
1292 write(&content.join("index.md"), "---\nlayout: \"page\"\n---\n");
1293 write(
1294 &template.join("page.html"),
1295 "<div>{{->header title=\"x\"}}</div>",
1296 );
1297 write(&template.join("header.html"), "<h1>{{title}}</h1>");
1298
1299 let mut graph = DepGraph::new();
1300 populate(&mut graph, &content, &template, &build).unwrap();
1301
1302 let page_tpl = template.join("page.html");
1303 let header_tpl = template.join("header.html");
1304 assert!(graph.deps_for(&page_tpl).unwrap().contains(&header_tpl));
1305 }
1306
1307 #[test]
1308 fn output_paths_for_root_index_emits_root_index_html() {
1309 let outs = output_paths_for(
1310 Path::new("/c/index.md"),
1311 Path::new("/c"),
1312 Path::new("/b"),
1313 );
1314 assert_eq!(outs, vec![PathBuf::from("/b/index.html")]);
1315 }
1316
1317 #[test]
1318 fn output_paths_for_nested_post_emits_subdir_index() {
1319 let outs = output_paths_for(
1320 Path::new("/c/blog/foo.md"),
1321 Path::new("/c"),
1322 Path::new("/b"),
1323 );
1324 assert_eq!(outs, vec![PathBuf::from("/b/blog/foo/index.html")]);
1325 }
1326
1327 #[test]
1328 fn output_paths_for_nested_index_emits_subdir_index_html() {
1329 let outs = output_paths_for(
1330 Path::new("/c/blog/index.md"),
1331 Path::new("/c"),
1332 Path::new("/b"),
1333 );
1334 assert_eq!(outs, vec![PathBuf::from("/b/blog/index.html")]);
1335 }
1336
1337 #[test]
1338 fn extract_layout_reads_yaml_frontmatter() {
1339 let text = "---\ntitle: foo\nlayout: \"post\"\n---\nbody";
1340 assert_eq!(extract_layout(text.as_bytes()), Some("post".to_string()));
1341 }
1342
1343 #[test]
1344 fn extract_layout_bareword() {
1345 let text = "---\nlayout: post\n---\nbody";
1346 assert_eq!(extract_layout(text.as_bytes()), Some("post".to_string()));
1347 }
1348
1349 #[test]
1350 fn extract_layout_missing_returns_none() {
1351 let text = "---\ntitle: foo\n---\nbody";
1352 assert!(extract_layout(text.as_bytes()).is_none());
1353 }
1354
1355 #[test]
1356 fn extract_layout_no_frontmatter_returns_none() {
1357 let text = "# just a heading";
1358 assert!(extract_layout(text.as_bytes()).is_none());
1359 }
1360
1361 #[test]
1362 fn scan_template_refs_handles_extends_and_partial() {
1363 let body =
1364 "{{#extends \"base\"}}\n{{->header title=\"x\"}}\n{{->footer}}";
1365 let refs = scan_template_refs(body);
1366 assert_eq!(
1367 refs,
1368 vec![
1369 "base".to_string(),
1370 "header".to_string(),
1371 "footer".to_string()
1372 ]
1373 );
1374 }
1375
1376 #[test]
1377 fn scan_template_refs_deduplicates() {
1378 let body = "{{->header}} {{->header}} {{->header title=\"a\"}}";
1379 let refs = scan_template_refs(body);
1380 assert_eq!(refs, vec!["header".to_string()]);
1381 }
1382
1383 #[test]
1384 fn scan_template_refs_ignores_plain_variables() {
1385 let body = "<p>{{title}}</p>{{!raw_html}}";
1386 assert!(scan_template_refs(body).is_empty());
1387 }
1388
1389 #[test]
1390 fn current_hashes_picks_up_md_and_html() {
1391 let dir = tempdir().unwrap();
1392 let content = dir.path().join("c");
1393 let template = dir.path().join("t");
1394 fs::create_dir_all(&content).unwrap();
1395 fs::create_dir_all(&template).unwrap();
1396 write(&content.join("a.md"), "---\nlayout: page\n---");
1397 write(&template.join("page.html"), "<h1></h1>");
1398
1399 let hashes = current_hashes(&content, &template).unwrap();
1400 assert!(hashes.contains_key(&content.join("a.md")));
1401 assert!(hashes.contains_key(&template.join("page.html")));
1402 }
1403
1404 #[test]
1405 fn record_hash_from_disk_silently_skips_missing() {
1406 let mut graph = DepGraph::new();
1407 graph.record_hash_from_disk(Path::new("/nonexistent/x.md"));
1408 assert!(graph.hashes.is_empty());
1409 }
1410
1411 #[test]
1412 fn diff_is_empty_helper_round_trips() {
1413 let d = Diff::default();
1414 assert!(d.is_empty());
1415 }
1416
1417 #[test]
1418 fn tracked_sources_returns_sorted_unique_outputs() {
1419 let mut graph = DepGraph::new();
1420 graph.add_output(Path::new("b.md"), Path::new("b.html"));
1421 graph.add_output(Path::new("a.md"), Path::new("a.html"));
1422 assert_eq!(
1423 graph.tracked_sources(),
1424 vec![PathBuf::from("a.md"), PathBuf::from("b.md")]
1425 );
1426 }
1427
1428 #[test]
1431 fn save_fails_when_cache_root_is_a_file_not_a_dir() {
1432 let dir = tempdir().unwrap();
1436 let blocker = dir.path().join("not-a-dir");
1437 fs::write(&blocker, b"i am a file").unwrap();
1438 let cache_root = blocker.join("sub");
1439 let graph = DepGraph::new();
1440 let err = graph.save(&cache_root).unwrap_err();
1441 let msg = format!("{err}");
1442 assert!(!msg.is_empty());
1443 }
1444
1445 #[test]
1446 fn save_writes_then_renames_to_final_path() {
1447 let dir = tempdir().unwrap();
1451 let cache_root = dir.path().join("cache");
1452 let mut g = DepGraph::new();
1453 g.add_dep(Path::new("a.md"), Path::new("b.html"));
1454 g.add_output(Path::new("a.md"), Path::new("out.html"));
1455 g.record_hash(Path::new("a.md"), b"contents");
1456 g.save(&cache_root).unwrap();
1457
1458 let final_path = cache_root.join(DEP_GRAPH_FILE);
1459 assert!(final_path.exists());
1460 let tmp_path = cache_root.join(format!("{DEP_GRAPH_FILE}.tmp"));
1461 assert!(
1462 !tmp_path.exists(),
1463 ".tmp file should be renamed away after save"
1464 );
1465 }
1466
1467 #[test]
1470 fn load_treats_missing_version_field_as_incompatible() {
1471 let dir = tempdir().unwrap();
1475 let cache_root = dir.path();
1476 let path = cache_root.join(DEP_GRAPH_FILE);
1477 fs::write(&path, br#"{"deps":{},"outputs":{},"hashes":{}}"#).unwrap();
1478 let loaded = DepGraph::load(cache_root);
1479 assert_eq!(loaded.page_count(), 0);
1480 assert!(loaded.tracked_sources().is_empty());
1481 }
1482
1483 #[test]
1486 fn populate_propagates_unreadable_markdown_via_map_err_closure() {
1487 let dir = tempdir().unwrap();
1490 let content = dir.path().join("content");
1491 let templates = dir.path().join("templates");
1492 let build = dir.path().join("build");
1493 fs::create_dir_all(&content).unwrap();
1494 fs::create_dir_all(&templates).unwrap();
1495 let md = content.join("page.md");
1496 fs::write(&md, b"---\nlayout: post\n---\nhi").unwrap();
1497
1498 #[cfg(unix)]
1499 {
1500 use std::os::unix::fs::PermissionsExt;
1501 fs::set_permissions(&md, fs::Permissions::from_mode(0o000))
1503 .unwrap();
1504 }
1505
1506 let mut g = DepGraph::new();
1507 let res = populate(&mut g, &content, &templates, &build);
1508
1509 #[cfg(unix)]
1510 {
1511 use std::os::unix::fs::PermissionsExt;
1512 let _ = fs::set_permissions(&md, fs::Permissions::from_mode(0o644));
1514 assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1518 }
1519 #[cfg(not(unix))]
1520 {
1521 let _ = res;
1522 }
1523 }
1524
1525 #[test]
1526 fn populate_propagates_unreadable_template_via_map_err_closure() {
1527 let dir = tempdir().unwrap();
1528 let content = dir.path().join("content");
1529 let templates = dir.path().join("templates");
1530 let build = dir.path().join("build");
1531 fs::create_dir_all(&content).unwrap();
1532 fs::create_dir_all(&templates).unwrap();
1533 let tpl = templates.join("post.html");
1534 fs::write(&tpl, b"{{#extends \"base\"}}").unwrap();
1535
1536 #[cfg(unix)]
1537 {
1538 use std::os::unix::fs::PermissionsExt;
1539 fs::set_permissions(&tpl, fs::Permissions::from_mode(0o000))
1540 .unwrap();
1541 }
1542
1543 let mut g = DepGraph::new();
1544 let res = populate(&mut g, &content, &templates, &build);
1545
1546 #[cfg(unix)]
1547 {
1548 use std::os::unix::fs::PermissionsExt;
1549 let _ =
1550 fs::set_permissions(&tpl, fs::Permissions::from_mode(0o644));
1551 assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1552 }
1553 #[cfg(not(unix))]
1554 {
1555 let _ = res;
1556 }
1557 }
1558
1559 #[test]
1562 fn load_incompatible_schema_warns_and_falls_back() {
1563 crate::test_support::init_logger();
1566 let dir = tempdir().unwrap();
1567 let stale = serde_json::json!({
1568 "version": 1,
1569 "deps": {},
1570 "outputs": {},
1571 "hashes": {}
1572 });
1573 fs::write(dir.path().join(DEP_GRAPH_FILE), stale.to_string()).unwrap();
1574
1575 let g = DepGraph::load(dir.path());
1576 assert_eq!(g.page_count(), 0);
1577 }
1578
1579 #[test]
1580 fn load_corrupt_json_warns_and_falls_back() {
1581 crate::test_support::init_logger();
1583 let dir = tempdir().unwrap();
1584 fs::write(dir.path().join(DEP_GRAPH_FILE), "{ nope").unwrap();
1585
1586 let g = DepGraph::load(dir.path());
1587 assert_eq!(g.page_count(), 0);
1588 }
1589
1590 #[test]
1593 #[cfg(unix)]
1594 fn save_non_utf8_path_fails_serialization() {
1595 use std::ffi::OsStr;
1598 use std::os::unix::ffi::OsStrExt;
1599 let dir = tempdir().unwrap();
1600 let bad = PathBuf::from(OsStr::from_bytes(&[0x66, 0xFF, 0xFE]));
1601 let mut g = DepGraph::new();
1602 g.add_dep(&bad, Path::new("layout.html"));
1603
1604 assert!(g.save(dir.path()).is_err());
1605 }
1606
1607 #[test]
1608 #[cfg(unix)]
1609 fn save_unwritable_cache_root_fails_tmp_write() {
1610 use std::os::unix::fs::PermissionsExt;
1613 let dir = tempdir().unwrap();
1614 let root = dir.path().join("cache");
1615 fs::create_dir_all(&root).unwrap();
1616 fs::set_permissions(&root, fs::Permissions::from_mode(0o555)).unwrap();
1617
1618 let res = DepGraph::new().save(&root);
1619
1620 let _ = fs::set_permissions(&root, fs::Permissions::from_mode(0o755));
1621 assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1623 }
1624
1625 #[test]
1626 fn save_rename_over_directory_fails() {
1627 let dir = tempdir().unwrap();
1630 let blocker = dir.path().join(DEP_GRAPH_FILE);
1631 fs::create_dir_all(&blocker).unwrap();
1632 fs::write(blocker.join("keep.txt"), "x").unwrap();
1633
1634 assert!(DepGraph::new().save(dir.path()).is_err());
1635 }
1636
1637 #[test]
1640 fn record_hash_from_disk_reads_existing_and_skips_missing() {
1641 let dir = tempdir().unwrap();
1642 let p = dir.path().join("a.md");
1643 fs::write(&p, "hello").unwrap();
1644
1645 let mut g = DepGraph::new();
1646 g.record_hash_from_disk(&p);
1647 g.record_hash_from_disk(&dir.path().join("missing.md"));
1649
1650 let current = current_hashes(dir.path(), dir.path()).unwrap();
1651 assert!(g.diff(¤t).is_empty());
1652 }
1653
1654 #[test]
1657 fn invalidated_deduplicates_repeated_inputs() {
1658 let g = DepGraph::new();
1660 let changed =
1661 vec![PathBuf::from("content/a.md"), PathBuf::from("content/a.md")];
1662 assert_eq!(
1663 g.invalidated(&changed),
1664 vec![PathBuf::from("content/a.md")]
1665 );
1666 }
1667
1668 #[test]
1671 #[cfg(unix)]
1672 fn populate_propagates_unreadable_content_dir() {
1673 use std::os::unix::fs::PermissionsExt;
1676 let dir = tempdir().unwrap();
1677 let content = dir.path().join("content");
1678 let templates = dir.path().join("templates");
1679 fs::create_dir_all(&content).unwrap();
1680 fs::create_dir_all(&templates).unwrap();
1681 fs::set_permissions(&content, fs::Permissions::from_mode(0o000))
1682 .unwrap();
1683
1684 let mut g = DepGraph::new();
1685 let res = populate(&mut g, &content, &templates, &dir.path().join("b"));
1686
1687 let _ =
1688 fs::set_permissions(&content, fs::Permissions::from_mode(0o755));
1689 assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1690 }
1691
1692 #[test]
1693 #[cfg(unix)]
1694 fn populate_propagates_unreadable_template_dir() {
1695 use std::os::unix::fs::PermissionsExt;
1698 let dir = tempdir().unwrap();
1699 let content = dir.path().join("content");
1700 let templates = dir.path().join("templates");
1701 fs::create_dir_all(&content).unwrap();
1702 fs::create_dir_all(&templates).unwrap();
1703 fs::set_permissions(&templates, fs::Permissions::from_mode(0o000))
1704 .unwrap();
1705
1706 let mut g = DepGraph::new();
1707 let res = populate(&mut g, &content, &templates, &dir.path().join("b"));
1708
1709 let _ =
1710 fs::set_permissions(&templates, fs::Permissions::from_mode(0o755));
1711 assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1712 }
1713
1714 #[test]
1715 #[cfg(unix)]
1716 fn current_hashes_propagates_walk_failures_from_both_dirs() {
1717 use std::os::unix::fs::PermissionsExt;
1718 let dir = tempdir().unwrap();
1719 let content = dir.path().join("content");
1720 let templates = dir.path().join("templates");
1721 fs::create_dir_all(&content).unwrap();
1722 fs::create_dir_all(&templates).unwrap();
1723
1724 fs::set_permissions(&content, fs::Permissions::from_mode(0o000))
1726 .unwrap();
1727 let res_content = current_hashes(&content, &templates);
1728 let _ =
1729 fs::set_permissions(&content, fs::Permissions::from_mode(0o755));
1730
1731 fs::set_permissions(&templates, fs::Permissions::from_mode(0o000))
1733 .unwrap();
1734 let res_templates = current_hashes(&content, &templates);
1735 let _ =
1736 fs::set_permissions(&templates, fs::Permissions::from_mode(0o755));
1737
1738 assert!(res_content.err().is_none_or(|e| !format!("{e}").is_empty()));
1739 assert!(res_templates
1740 .err()
1741 .is_none_or(|e| !format!("{e}").is_empty()));
1742 }
1743
1744 #[test]
1745 #[cfg(unix)]
1746 fn current_hashes_skips_unreadable_sources() {
1747 let dir = tempdir().unwrap();
1750 let content = dir.path().join("content");
1751 let templates = dir.path().join("templates");
1752 fs::create_dir_all(&content).unwrap();
1753 fs::create_dir_all(&templates).unwrap();
1754 fs::write(content.join("real.md"), "hi").unwrap();
1755 std::os::unix::fs::symlink(
1756 content.join("ghost-target.md"),
1757 content.join("ghost.md"),
1758 )
1759 .unwrap();
1760
1761 let map = current_hashes(&content, &templates).unwrap();
1762 assert_eq!(map.len(), 1, "only the readable file is hashed");
1763 }
1764
1765 #[test]
1768 fn populate_skips_edge_when_layout_cannot_be_resolved() {
1769 let dir = tempdir().unwrap();
1773 let content = dir.path().join("content");
1774 let templates = dir.path().join("templates");
1775 let build = dir.path().join("build");
1776 fs::create_dir_all(&content).unwrap();
1777 fs::create_dir_all(&templates).unwrap();
1778 fs::write(content.join("page.md"), "---\nlayout: ghost\n---\nbody")
1779 .unwrap();
1780
1781 let mut g = DepGraph::new();
1782 populate(&mut g, &content, &templates, &build).unwrap();
1783 let deps = g
1785 .deps_for(&content.join("page.md"))
1786 .expect("page must be tracked");
1787 assert_eq!(deps.len(), 1);
1788 }
1789
1790 #[test]
1793 fn extract_layout_rejects_malformed_frontmatter() {
1794 assert_eq!(extract_layout(&[0xFF, 0xFE, 0x00]), None);
1796 assert_eq!(extract_layout(b"---\nlayout: x"), None);
1798 assert_eq!(extract_layout(b"---\nlayout:\n---\nbody"), None);
1800 }
1801
1802 #[test]
1805 fn output_paths_for_foreign_path_returns_empty() {
1806 let out = output_paths_for(
1808 Path::new("/elsewhere/post.md"),
1809 Path::new("/content"),
1810 Path::new("/build"),
1811 );
1812 assert!(out.is_empty());
1813 }
1814
1815 #[test]
1816 fn resolve_template_prefers_locale_sibling() {
1817 let dir = tempdir().unwrap();
1818 let content = dir.path().join("content");
1819 let templates = dir.path().join("templates");
1820 fs::create_dir_all(content.join("fr")).unwrap();
1821 fs::create_dir_all(templates.join("fr")).unwrap();
1822 fs::write(templates.join("fr/post.html"), "x").unwrap();
1823 fs::write(templates.join("post.html"), "x").unwrap();
1824
1825 let got = resolve_template(
1826 &templates,
1827 &content.join("fr/a.md"),
1828 &content,
1829 "post",
1830 );
1831 assert_eq!(got, Some(templates.join("fr/post.html")));
1832 }
1833
1834 #[test]
1835 fn resolve_template_falls_back_when_locale_candidate_is_missing() {
1836 let dir = tempdir().unwrap();
1844 let content = dir.path().join("content");
1845 let templates = dir.path().join("templates");
1846 fs::create_dir_all(content.join("fr")).unwrap();
1847 fs::create_dir_all(&templates).unwrap();
1848 fs::write(templates.join("post.html"), "x").unwrap();
1850
1851 let got = resolve_template(
1852 &templates,
1853 &content.join("fr/a.md"),
1854 &content,
1855 "post",
1856 );
1857 assert_eq!(got, Some(templates.join("post.html")));
1858 }
1859
1860 #[test]
1861 fn resolve_template_falls_back_for_empty_and_foreign_paths() {
1862 let dir = tempdir().unwrap();
1863 let content = dir.path().join("content");
1864 let templates = dir.path().join("templates");
1865 fs::create_dir_all(&content).unwrap();
1866 fs::create_dir_all(&templates).unwrap();
1867 fs::write(templates.join("page.html"), "x").unwrap();
1868
1869 assert_eq!(
1871 resolve_template(&templates, &content, &content, "page"),
1872 Some(templates.join("page.html"))
1873 );
1874 assert_eq!(
1876 resolve_template(
1877 &templates,
1878 Path::new("/elsewhere/a.md"),
1879 &content,
1880 "page"
1881 ),
1882 Some(templates.join("page.html"))
1883 );
1884 assert_eq!(
1886 resolve_template(
1887 &templates,
1888 &content.join("a.md"),
1889 &content,
1890 "missing"
1891 ),
1892 None
1893 );
1894 }
1895
1896 #[test]
1899 fn scan_template_refs_handles_unclosed_and_plain_refs() {
1900 assert!(scan_template_refs("{{#extends \"base\"").is_empty());
1902 let refs =
1905 scan_template_refs("{{ title }}{{#extends \"\"}}{{->p}}{{->p}}");
1906 assert_eq!(refs, vec!["p".to_string()]);
1907 }
1908}