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!(
1088 graph.tracked_sources().is_empty(),
1089 "{:?}",
1090 graph.tracked_sources()
1091 );
1092 }
1093
1094 #[test]
1095 fn sha256_hex_is_deterministic_64_chars() {
1096 let h = DepGraph::sha256_hex(b"hello");
1097 assert_eq!(h.len(), 64);
1098 assert_eq!(h, DepGraph::sha256_hex(b"hello"));
1099 }
1100
1101 #[test]
1102 fn sha256_hex_distinguishes_inputs() {
1103 assert_ne!(DepGraph::sha256_hex(b"a"), DepGraph::sha256_hex(b"b"));
1104 }
1105
1106 #[test]
1114 fn a_data_file_change_invalidates_every_page() {
1115 let dir = tempdir().unwrap();
1116 let content = dir.path().join("content");
1117 let template = dir.path().join("templates");
1118 let build = dir.path().join("public");
1119 let data = dir.path().join("data");
1120 for d in [&content, &template, &data] {
1121 fs::create_dir_all(d).unwrap();
1122 }
1123 write(
1124 &content.join("index.md"),
1125 "---\nlayout: \"page\"\n---\nbody",
1126 );
1127 write(
1128 &content.join("about.md"),
1129 "---\nlayout: \"page\"\n---\nbody",
1130 );
1131 write(
1132 &template.join("page.html"),
1133 "<html>{{ data.site.name }}</html>",
1134 );
1135 write(&data.join("site.toml"), "name = \"Example\"\n");
1136
1137 let mut graph = DepGraph::new();
1138 populate(&mut graph, &content, &template, &build).unwrap();
1139
1140 let site_data = data.join("site.toml");
1141 let hit = graph.invalidated(std::slice::from_ref(&site_data));
1142 for page in [content.join("index.md"), content.join("about.md")] {
1143 assert!(
1144 hit.contains(&page),
1145 "{} not invalidated by a data-file change",
1146 page.display()
1147 );
1148 }
1149 }
1150
1151 #[test]
1157 fn the_underscore_data_convention_is_tracked_too() {
1158 let dir = tempdir().unwrap();
1159 let content = dir.path().join("content");
1160 let template = dir.path().join("templates");
1161 let build = dir.path().join("public");
1162 let data = dir.path().join("_data");
1163 for d in [&content, &template, &data] {
1164 fs::create_dir_all(d).unwrap();
1165 }
1166 write(
1167 &content.join("index.md"),
1168 "---\nlayout: \"page\"\n---\nbody",
1169 );
1170 write(&template.join("page.html"), "<html></html>");
1171 write(&data.join("topics.toml"), "[rust]\ntitle = \"Rust\"\n");
1172
1173 let mut graph = DepGraph::new();
1174 populate(&mut graph, &content, &template, &build).unwrap();
1175
1176 let topics = data.join("topics.toml");
1177 assert!(
1178 graph
1179 .invalidated(std::slice::from_ref(&topics))
1180 .contains(&content.join("index.md")),
1181 "_data/topics.toml is not tracked"
1182 );
1183 }
1184
1185 #[test]
1188 fn non_data_extensions_in_the_data_dir_are_ignored() {
1189 let dir = tempdir().unwrap();
1190 let content = dir.path().join("content");
1191 let template = dir.path().join("templates");
1192 let build = dir.path().join("public");
1193 let data = dir.path().join("data");
1194 for d in [&content, &template, &data] {
1195 fs::create_dir_all(d).unwrap();
1196 }
1197 write(
1198 &content.join("index.md"),
1199 "---\nlayout: \"page\"\n---\nbody",
1200 );
1201 write(&template.join("page.html"), "<html></html>");
1202 write(&data.join("notes.md"), "not a data file");
1203 write(&data.join("real.json"), "{}");
1204
1205 let mut graph = DepGraph::new();
1206 populate(&mut graph, &content, &template, &build).unwrap();
1207
1208 let index = content.join("index.md");
1209 let deps = graph.deps_for(&index).unwrap();
1210 assert!(deps.contains(&data.join("real.json")), "json not tracked");
1211 assert!(
1212 !deps.contains(&data.join("notes.md")),
1213 "a .md in data/ is not a data file"
1214 );
1215 }
1216
1217 #[test]
1218 fn populate_walks_real_directories_and_records_edges() {
1219 let dir = tempdir().unwrap();
1220 let content = dir.path().join("content");
1221 let template = dir.path().join("templates");
1222 let build = dir.path().join("public");
1223 fs::create_dir_all(&content).unwrap();
1224 fs::create_dir_all(&template).unwrap();
1225
1226 write(
1227 &content.join("index.md"),
1228 "---\nlayout: \"page\"\n---\nbody",
1229 );
1230 write(
1231 &content.join("about.md"),
1232 "---\nlayout: \"page\"\n---\nbody",
1233 );
1234 write(&template.join("page.html"), "<html>{{title}}</html>");
1235
1236 let mut graph = DepGraph::new();
1237 populate(&mut graph, &content, &template, &build).unwrap();
1238
1239 let index = content.join("index.md");
1241 let about = content.join("about.md");
1242 let page_tpl = template.join("page.html");
1243 assert!(graph.deps_for(&index).unwrap().contains(&page_tpl));
1244 assert!(graph.deps_for(&about).unwrap().contains(&page_tpl));
1245
1246 let outs_index = graph.outputs_for(&index).unwrap();
1248 assert!(outs_index.contains(&build.join("index.html")));
1249 let outs_about = graph.outputs_for(&about).unwrap();
1250 assert!(outs_about.contains(&build.join("about").join("index.html")));
1251
1252 assert!(!graph.hashes.is_empty());
1254 }
1255
1256 #[test]
1257 fn populate_records_template_to_template_edges() {
1258 let dir = tempdir().unwrap();
1259 let content = dir.path().join("content");
1260 let template = dir.path().join("templates");
1261 let build = dir.path().join("public");
1262 fs::create_dir_all(&content).unwrap();
1263 fs::create_dir_all(&template).unwrap();
1264
1265 write(&content.join("index.md"), "---\nlayout: \"page\"\n---\n");
1266 write(
1267 &template.join("page.html"),
1268 "{{#extends \"base\"}}\n<p>x</p>",
1269 );
1270 write(&template.join("base.html"), "<html>{{body}}</html>");
1271
1272 let mut graph = DepGraph::new();
1273 populate(&mut graph, &content, &template, &build).unwrap();
1274
1275 let page_tpl = template.join("page.html");
1276 let base_tpl = template.join("base.html");
1277 assert!(
1278 graph.deps_for(&page_tpl).unwrap().contains(&base_tpl),
1279 "template→template edge must be recorded"
1280 );
1281
1282 let invalidated = graph.invalidated(&[base_tpl]);
1284 assert!(invalidated.contains(&content.join("index.md")));
1285 }
1286
1287 #[test]
1288 fn populate_records_partial_references() {
1289 let dir = tempdir().unwrap();
1290 let content = dir.path().join("content");
1291 let template = dir.path().join("templates");
1292 let build = dir.path().join("public");
1293 fs::create_dir_all(&content).unwrap();
1294 fs::create_dir_all(&template).unwrap();
1295
1296 write(&content.join("index.md"), "---\nlayout: \"page\"\n---\n");
1297 write(
1298 &template.join("page.html"),
1299 "<div>{{->header title=\"x\"}}</div>",
1300 );
1301 write(&template.join("header.html"), "<h1>{{title}}</h1>");
1302
1303 let mut graph = DepGraph::new();
1304 populate(&mut graph, &content, &template, &build).unwrap();
1305
1306 let page_tpl = template.join("page.html");
1307 let header_tpl = template.join("header.html");
1308 assert!(graph.deps_for(&page_tpl).unwrap().contains(&header_tpl));
1309 }
1310
1311 #[test]
1312 fn output_paths_for_root_index_emits_root_index_html() {
1313 let outs = output_paths_for(
1314 Path::new("/c/index.md"),
1315 Path::new("/c"),
1316 Path::new("/b"),
1317 );
1318 assert_eq!(outs, vec![PathBuf::from("/b/index.html")]);
1319 }
1320
1321 #[test]
1322 fn output_paths_for_nested_post_emits_subdir_index() {
1323 let outs = output_paths_for(
1324 Path::new("/c/blog/foo.md"),
1325 Path::new("/c"),
1326 Path::new("/b"),
1327 );
1328 assert_eq!(outs, vec![PathBuf::from("/b/blog/foo/index.html")]);
1329 }
1330
1331 #[test]
1332 fn output_paths_for_nested_index_emits_subdir_index_html() {
1333 let outs = output_paths_for(
1334 Path::new("/c/blog/index.md"),
1335 Path::new("/c"),
1336 Path::new("/b"),
1337 );
1338 assert_eq!(outs, vec![PathBuf::from("/b/blog/index.html")]);
1339 }
1340
1341 #[test]
1342 fn extract_layout_reads_yaml_frontmatter() {
1343 let text = "---\ntitle: foo\nlayout: \"post\"\n---\nbody";
1344 assert_eq!(extract_layout(text.as_bytes()), Some("post".to_string()));
1345 }
1346
1347 #[test]
1348 fn extract_layout_bareword() {
1349 let text = "---\nlayout: post\n---\nbody";
1350 assert_eq!(extract_layout(text.as_bytes()), Some("post".to_string()));
1351 }
1352
1353 #[test]
1354 fn extract_layout_missing_returns_none() {
1355 let text = "---\ntitle: foo\n---\nbody";
1356 assert!(extract_layout(text.as_bytes()).is_none());
1357 }
1358
1359 #[test]
1360 fn extract_layout_no_frontmatter_returns_none() {
1361 let text = "# just a heading";
1362 assert!(extract_layout(text.as_bytes()).is_none());
1363 }
1364
1365 #[test]
1366 fn scan_template_refs_handles_extends_and_partial() {
1367 let body =
1368 "{{#extends \"base\"}}\n{{->header title=\"x\"}}\n{{->footer}}";
1369 let refs = scan_template_refs(body);
1370 assert_eq!(
1371 refs,
1372 vec![
1373 "base".to_string(),
1374 "header".to_string(),
1375 "footer".to_string()
1376 ]
1377 );
1378 }
1379
1380 #[test]
1381 fn scan_template_refs_deduplicates() {
1382 let body = "{{->header}} {{->header}} {{->header title=\"a\"}}";
1383 let refs = scan_template_refs(body);
1384 assert_eq!(refs, vec!["header".to_string()]);
1385 }
1386
1387 #[test]
1388 fn scan_template_refs_ignores_plain_variables() {
1389 let body = "<p>{{title}}</p>{{!raw_html}}";
1390 assert!(
1391 scan_template_refs(body).is_empty(),
1392 "{:?}",
1393 scan_template_refs(body)
1394 );
1395 }
1396
1397 #[test]
1398 fn current_hashes_picks_up_md_and_html() {
1399 let dir = tempdir().unwrap();
1400 let content = dir.path().join("c");
1401 let template = dir.path().join("t");
1402 fs::create_dir_all(&content).unwrap();
1403 fs::create_dir_all(&template).unwrap();
1404 write(&content.join("a.md"), "---\nlayout: page\n---");
1405 write(&template.join("page.html"), "<h1></h1>");
1406
1407 let hashes = current_hashes(&content, &template).unwrap();
1408 assert!(hashes.contains_key(&content.join("a.md")));
1409 assert!(hashes.contains_key(&template.join("page.html")));
1410 }
1411
1412 #[test]
1413 fn record_hash_from_disk_silently_skips_missing() {
1414 let mut graph = DepGraph::new();
1415 graph.record_hash_from_disk(Path::new("/nonexistent/x.md"));
1416 assert!(graph.hashes.is_empty());
1417 }
1418
1419 #[test]
1420 fn diff_is_empty_helper_round_trips() {
1421 let d = Diff::default();
1422 assert!(d.is_empty());
1423 }
1424
1425 #[test]
1426 fn tracked_sources_returns_sorted_unique_outputs() {
1427 let mut graph = DepGraph::new();
1428 graph.add_output(Path::new("b.md"), Path::new("b.html"));
1429 graph.add_output(Path::new("a.md"), Path::new("a.html"));
1430 assert_eq!(
1431 graph.tracked_sources(),
1432 vec![PathBuf::from("a.md"), PathBuf::from("b.md")]
1433 );
1434 }
1435
1436 #[test]
1439 fn save_fails_when_cache_root_is_a_file_not_a_dir() {
1440 let dir = tempdir().unwrap();
1444 let blocker = dir.path().join("not-a-dir");
1445 fs::write(&blocker, b"i am a file").unwrap();
1446 let cache_root = blocker.join("sub");
1447 let graph = DepGraph::new();
1448 let err = graph.save(&cache_root).unwrap_err();
1449 let msg = format!("{err}");
1450 assert!(!msg.is_empty(), "should not be empty");
1451 }
1452
1453 #[test]
1454 fn save_writes_then_renames_to_final_path() {
1455 let dir = tempdir().unwrap();
1459 let cache_root = dir.path().join("cache");
1460 let mut g = DepGraph::new();
1461 g.add_dep(Path::new("a.md"), Path::new("b.html"));
1462 g.add_output(Path::new("a.md"), Path::new("out.html"));
1463 g.record_hash(Path::new("a.md"), b"contents");
1464 g.save(&cache_root).unwrap();
1465
1466 let final_path = cache_root.join(DEP_GRAPH_FILE);
1467 assert!(final_path.exists());
1468 let tmp_path = cache_root.join(format!("{DEP_GRAPH_FILE}.tmp"));
1469 assert!(
1470 !tmp_path.exists(),
1471 ".tmp file should be renamed away after save"
1472 );
1473 }
1474
1475 #[test]
1478 fn load_treats_missing_version_field_as_incompatible() {
1479 let dir = tempdir().unwrap();
1483 let cache_root = dir.path();
1484 let path = cache_root.join(DEP_GRAPH_FILE);
1485 fs::write(&path, br#"{"deps":{},"outputs":{},"hashes":{}}"#).unwrap();
1486 let loaded = DepGraph::load(cache_root);
1487 assert_eq!(loaded.page_count(), 0);
1488 assert!(
1489 loaded.tracked_sources().is_empty(),
1490 "{:?}",
1491 loaded.tracked_sources()
1492 );
1493 }
1494
1495 #[test]
1498 fn populate_propagates_unreadable_markdown_via_map_err_closure() {
1499 let dir = tempdir().unwrap();
1502 let content = dir.path().join("content");
1503 let templates = dir.path().join("templates");
1504 let build = dir.path().join("build");
1505 fs::create_dir_all(&content).unwrap();
1506 fs::create_dir_all(&templates).unwrap();
1507 let md = content.join("page.md");
1508 fs::write(&md, b"---\nlayout: post\n---\nhi").unwrap();
1509
1510 #[cfg(unix)]
1511 {
1512 use std::os::unix::fs::PermissionsExt;
1513 fs::set_permissions(&md, fs::Permissions::from_mode(0o000))
1515 .unwrap();
1516 }
1517
1518 let mut g = DepGraph::new();
1519 let res = populate(&mut g, &content, &templates, &build);
1520
1521 #[cfg(unix)]
1522 {
1523 use std::os::unix::fs::PermissionsExt;
1524 let _ = fs::set_permissions(&md, fs::Permissions::from_mode(0o644));
1526 assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1530 }
1531 #[cfg(not(unix))]
1532 {
1533 let _ = res;
1534 }
1535 }
1536
1537 #[test]
1538 fn populate_propagates_unreadable_template_via_map_err_closure() {
1539 let dir = tempdir().unwrap();
1540 let content = dir.path().join("content");
1541 let templates = dir.path().join("templates");
1542 let build = dir.path().join("build");
1543 fs::create_dir_all(&content).unwrap();
1544 fs::create_dir_all(&templates).unwrap();
1545 let tpl = templates.join("post.html");
1546 fs::write(&tpl, b"{{#extends \"base\"}}").unwrap();
1547
1548 #[cfg(unix)]
1549 {
1550 use std::os::unix::fs::PermissionsExt;
1551 fs::set_permissions(&tpl, fs::Permissions::from_mode(0o000))
1552 .unwrap();
1553 }
1554
1555 let mut g = DepGraph::new();
1556 let res = populate(&mut g, &content, &templates, &build);
1557
1558 #[cfg(unix)]
1559 {
1560 use std::os::unix::fs::PermissionsExt;
1561 let _ =
1562 fs::set_permissions(&tpl, fs::Permissions::from_mode(0o644));
1563 assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1564 }
1565 #[cfg(not(unix))]
1566 {
1567 let _ = res;
1568 }
1569 }
1570
1571 #[test]
1574 fn load_incompatible_schema_warns_and_falls_back() {
1575 crate::test_support::init_logger();
1578 let dir = tempdir().unwrap();
1579 let stale = serde_json::json!({
1580 "version": 1,
1581 "deps": {},
1582 "outputs": {},
1583 "hashes": {}
1584 });
1585 fs::write(dir.path().join(DEP_GRAPH_FILE), stale.to_string()).unwrap();
1586
1587 let g = DepGraph::load(dir.path());
1588 assert_eq!(g.page_count(), 0);
1589 }
1590
1591 #[test]
1592 fn load_corrupt_json_warns_and_falls_back() {
1593 crate::test_support::init_logger();
1595 let dir = tempdir().unwrap();
1596 fs::write(dir.path().join(DEP_GRAPH_FILE), "{ nope").unwrap();
1597
1598 let g = DepGraph::load(dir.path());
1599 assert_eq!(g.page_count(), 0);
1600 }
1601
1602 #[test]
1605 #[cfg(unix)]
1606 fn save_non_utf8_path_fails_serialization() {
1607 use std::ffi::OsStr;
1610 use std::os::unix::ffi::OsStrExt;
1611 let dir = tempdir().unwrap();
1612 let bad = PathBuf::from(OsStr::from_bytes(&[0x66, 0xFF, 0xFE]));
1613 let mut g = DepGraph::new();
1614 g.add_dep(&bad, Path::new("layout.html"));
1615
1616 assert!(g.save(dir.path()).is_err());
1617 }
1618
1619 #[test]
1620 #[cfg(unix)]
1621 fn save_unwritable_cache_root_fails_tmp_write() {
1622 use std::os::unix::fs::PermissionsExt;
1625 let dir = tempdir().unwrap();
1626 let root = dir.path().join("cache");
1627 fs::create_dir_all(&root).unwrap();
1628 fs::set_permissions(&root, fs::Permissions::from_mode(0o555)).unwrap();
1629
1630 let res = DepGraph::new().save(&root);
1631
1632 let _ = fs::set_permissions(&root, fs::Permissions::from_mode(0o755));
1633 assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1635 }
1636
1637 #[test]
1638 fn save_rename_over_directory_fails() {
1639 let dir = tempdir().unwrap();
1642 let blocker = dir.path().join(DEP_GRAPH_FILE);
1643 fs::create_dir_all(&blocker).unwrap();
1644 fs::write(blocker.join("keep.txt"), "x").unwrap();
1645
1646 assert!(DepGraph::new().save(dir.path()).is_err());
1647 }
1648
1649 #[test]
1652 fn record_hash_from_disk_reads_existing_and_skips_missing() {
1653 let dir = tempdir().unwrap();
1654 let p = dir.path().join("a.md");
1655 fs::write(&p, "hello").unwrap();
1656
1657 let mut g = DepGraph::new();
1658 g.record_hash_from_disk(&p);
1659 g.record_hash_from_disk(&dir.path().join("missing.md"));
1661
1662 let current = current_hashes(dir.path(), dir.path()).unwrap();
1663 assert!(g.diff(¤t).is_empty());
1664 }
1665
1666 #[test]
1669 fn invalidated_deduplicates_repeated_inputs() {
1670 let g = DepGraph::new();
1672 let changed =
1673 vec![PathBuf::from("content/a.md"), PathBuf::from("content/a.md")];
1674 assert_eq!(
1675 g.invalidated(&changed),
1676 vec![PathBuf::from("content/a.md")]
1677 );
1678 }
1679
1680 #[test]
1683 #[cfg(unix)]
1684 fn populate_propagates_unreadable_content_dir() {
1685 use std::os::unix::fs::PermissionsExt;
1688 let dir = tempdir().unwrap();
1689 let content = dir.path().join("content");
1690 let templates = dir.path().join("templates");
1691 fs::create_dir_all(&content).unwrap();
1692 fs::create_dir_all(&templates).unwrap();
1693 fs::set_permissions(&content, fs::Permissions::from_mode(0o000))
1694 .unwrap();
1695
1696 let mut g = DepGraph::new();
1697 let res = populate(&mut g, &content, &templates, &dir.path().join("b"));
1698
1699 let _ =
1700 fs::set_permissions(&content, fs::Permissions::from_mode(0o755));
1701 assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1702 }
1703
1704 #[test]
1705 #[cfg(unix)]
1706 fn populate_propagates_unreadable_template_dir() {
1707 use std::os::unix::fs::PermissionsExt;
1710 let dir = tempdir().unwrap();
1711 let content = dir.path().join("content");
1712 let templates = dir.path().join("templates");
1713 fs::create_dir_all(&content).unwrap();
1714 fs::create_dir_all(&templates).unwrap();
1715 fs::set_permissions(&templates, fs::Permissions::from_mode(0o000))
1716 .unwrap();
1717
1718 let mut g = DepGraph::new();
1719 let res = populate(&mut g, &content, &templates, &dir.path().join("b"));
1720
1721 let _ =
1722 fs::set_permissions(&templates, fs::Permissions::from_mode(0o755));
1723 assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1724 }
1725
1726 #[test]
1727 #[cfg(unix)]
1728 fn current_hashes_propagates_walk_failures_from_both_dirs() {
1729 use std::os::unix::fs::PermissionsExt;
1730 let dir = tempdir().unwrap();
1731 let content = dir.path().join("content");
1732 let templates = dir.path().join("templates");
1733 fs::create_dir_all(&content).unwrap();
1734 fs::create_dir_all(&templates).unwrap();
1735
1736 fs::set_permissions(&content, fs::Permissions::from_mode(0o000))
1738 .unwrap();
1739 let res_content = current_hashes(&content, &templates);
1740 let _ =
1741 fs::set_permissions(&content, fs::Permissions::from_mode(0o755));
1742
1743 fs::set_permissions(&templates, fs::Permissions::from_mode(0o000))
1745 .unwrap();
1746 let res_templates = current_hashes(&content, &templates);
1747 let _ =
1748 fs::set_permissions(&templates, fs::Permissions::from_mode(0o755));
1749
1750 assert!(res_content.err().is_none_or(|e| !format!("{e}").is_empty()));
1751 assert!(res_templates
1752 .err()
1753 .is_none_or(|e| !format!("{e}").is_empty()));
1754 }
1755
1756 #[test]
1757 #[cfg(unix)]
1758 fn current_hashes_skips_unreadable_sources() {
1759 let dir = tempdir().unwrap();
1762 let content = dir.path().join("content");
1763 let templates = dir.path().join("templates");
1764 fs::create_dir_all(&content).unwrap();
1765 fs::create_dir_all(&templates).unwrap();
1766 fs::write(content.join("real.md"), "hi").unwrap();
1767 std::os::unix::fs::symlink(
1768 content.join("ghost-target.md"),
1769 content.join("ghost.md"),
1770 )
1771 .unwrap();
1772
1773 let map = current_hashes(&content, &templates).unwrap();
1774 assert_eq!(map.len(), 1, "only the readable file is hashed");
1775 }
1776
1777 #[test]
1780 fn populate_skips_edge_when_layout_cannot_be_resolved() {
1781 let dir = tempdir().unwrap();
1785 let content = dir.path().join("content");
1786 let templates = dir.path().join("templates");
1787 let build = dir.path().join("build");
1788 fs::create_dir_all(&content).unwrap();
1789 fs::create_dir_all(&templates).unwrap();
1790 fs::write(content.join("page.md"), "---\nlayout: ghost\n---\nbody")
1791 .unwrap();
1792
1793 let mut g = DepGraph::new();
1794 populate(&mut g, &content, &templates, &build).unwrap();
1795 let deps = g
1797 .deps_for(&content.join("page.md"))
1798 .expect("page must be tracked");
1799 assert_eq!(deps.len(), 1);
1800 }
1801
1802 #[test]
1805 fn extract_layout_rejects_malformed_frontmatter() {
1806 assert_eq!(extract_layout(&[0xFF, 0xFE, 0x00]), None);
1808 assert_eq!(extract_layout(b"---\nlayout: x"), None);
1810 assert_eq!(extract_layout(b"---\nlayout:\n---\nbody"), None);
1812 }
1813
1814 #[test]
1817 fn output_paths_for_foreign_path_returns_empty() {
1818 let out = output_paths_for(
1820 Path::new("/elsewhere/post.md"),
1821 Path::new("/content"),
1822 Path::new("/build"),
1823 );
1824 assert!(out.is_empty(), "{:?}", out);
1825 }
1826
1827 #[test]
1828 fn resolve_template_prefers_locale_sibling() {
1829 let dir = tempdir().unwrap();
1830 let content = dir.path().join("content");
1831 let templates = dir.path().join("templates");
1832 fs::create_dir_all(content.join("fr")).unwrap();
1833 fs::create_dir_all(templates.join("fr")).unwrap();
1834 fs::write(templates.join("fr/post.html"), "x").unwrap();
1835 fs::write(templates.join("post.html"), "x").unwrap();
1836
1837 let got = resolve_template(
1838 &templates,
1839 &content.join("fr/a.md"),
1840 &content,
1841 "post",
1842 );
1843 assert_eq!(got, Some(templates.join("fr/post.html")));
1844 }
1845
1846 #[test]
1847 fn resolve_template_falls_back_when_locale_candidate_is_missing() {
1848 let dir = tempdir().unwrap();
1856 let content = dir.path().join("content");
1857 let templates = dir.path().join("templates");
1858 fs::create_dir_all(content.join("fr")).unwrap();
1859 fs::create_dir_all(&templates).unwrap();
1860 fs::write(templates.join("post.html"), "x").unwrap();
1862
1863 let got = resolve_template(
1864 &templates,
1865 &content.join("fr/a.md"),
1866 &content,
1867 "post",
1868 );
1869 assert_eq!(got, Some(templates.join("post.html")));
1870 }
1871
1872 #[test]
1873 fn resolve_template_falls_back_for_empty_and_foreign_paths() {
1874 let dir = tempdir().unwrap();
1875 let content = dir.path().join("content");
1876 let templates = dir.path().join("templates");
1877 fs::create_dir_all(&content).unwrap();
1878 fs::create_dir_all(&templates).unwrap();
1879 fs::write(templates.join("page.html"), "x").unwrap();
1880
1881 assert_eq!(
1883 resolve_template(&templates, &content, &content, "page"),
1884 Some(templates.join("page.html"))
1885 );
1886 assert_eq!(
1888 resolve_template(
1889 &templates,
1890 Path::new("/elsewhere/a.md"),
1891 &content,
1892 "page"
1893 ),
1894 Some(templates.join("page.html"))
1895 );
1896 assert_eq!(
1898 resolve_template(
1899 &templates,
1900 &content.join("a.md"),
1901 &content,
1902 "missing"
1903 ),
1904 None
1905 );
1906 }
1907
1908 #[test]
1911 fn scan_template_refs_handles_unclosed_and_plain_refs() {
1912 assert!(
1914 scan_template_refs("{{#extends \"base\"").is_empty(),
1915 "{:?}",
1916 scan_template_refs("{{#extends \"base\"")
1917 );
1918 let refs =
1921 scan_template_refs("{{ title }}{{#extends \"\"}}{{->p}}{{->p}}");
1922 assert_eq!(refs, vec!["p".to_string()]);
1923 }
1924}