1use std::fs;
75use std::io;
76use std::path::{Path, PathBuf};
77
78pub fn stage_content_with_template_defaults(
99 content_dir: &Path,
100 build_dir: &Path,
101 template_var_keys: &[String],
102) -> Result<PathBuf, io::Error> {
103 stage_content_with_site_defaults(
104 content_dir,
105 build_dir,
106 template_var_keys,
107 None,
108 &[],
109 )
110}
111
112pub fn stage_content_with_site_defaults(
159 content_dir: &Path,
160 build_dir: &Path,
161 template_var_keys: &[String],
162 base_url: Option<&str>,
163 locales: &[String],
164) -> Result<PathBuf, io::Error> {
165 let base_url = base_url.map(str::trim).filter(|b| !b.is_empty());
168
169 let staging_dir = staging_root_for("content", build_dir);
170 recreate_staging_dir(&staging_dir)?;
171
172 copy_tree(content_dir, &staging_dir, base_url)?;
173
174 if !template_var_keys.is_empty() {
180 inject_template_defaults_recursive(
181 &staging_dir,
182 template_var_keys,
183 base_url,
184 locales,
185 )?;
186 }
187
188 Ok(staging_dir)
189}
190
191pub(crate) const DERIVED_PATH_KEYS: [&str; 4] =
221 ["site_path", "site_url", "locale_path", "locale_url"];
222
223fn derive_path_globals(
231 base_url: Option<&str>,
232 staged_rel: &Path,
233 locales: &[String],
234) -> Vec<(String, String)> {
235 let site_url = base_url.map_or_else(
236 || "/".to_string(),
237 |b| format!("{}/", b.trim_end_matches('/')),
238 );
239 let site_path = url_path_component(&site_url);
240
241 let locale = detect_locale(staged_rel, locales);
242 let (locale_url, locale_path) = match locale {
243 Some(l) => (format!("{site_url}{l}/"), format!("{site_path}{l}/")),
244 None => (site_url.clone(), site_path.clone()),
245 };
246
247 DERIVED_PATH_KEYS
250 .into_iter()
251 .map(str::to_string)
252 .zip([site_path, site_url, locale_path, locale_url])
253 .collect()
254}
255
256fn url_path_component(url: &str) -> String {
261 let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
262 let path = if url.starts_with('/') {
263 url
264 } else {
265 after_scheme.find('/').map_or("/", |i| &after_scheme[i..])
266 };
267 let trimmed = path.trim_matches('/');
268 if trimmed.is_empty() {
269 "/".to_string()
270 } else {
271 format!("/{trimmed}/")
272 }
273}
274
275fn detect_locale(staged_rel: &Path, locales: &[String]) -> Option<String> {
277 if locales.len() < 2 {
278 return None;
279 }
280 let mut comps = staged_rel.components();
281 let first = comps.next()?.as_os_str().to_string_lossy().into_owned();
282
283 if comps.next().is_some() && locales.contains(&first) {
285 return Some(first);
286 }
287 let stem = Path::new(&first)
289 .file_stem()
290 .map(|s| s.to_string_lossy().into_owned())?;
291 locales.contains(&stem).then_some(stem)
292}
293
294fn recreate_staging_dir(staging_dir: &Path) -> Result<(), io::Error> {
297 if staging_dir.exists() {
298 fs::remove_dir_all(staging_dir)?;
299 }
300 fs::create_dir_all(staging_dir)?;
301 Ok(())
302}
303
304fn staging_root_for(suffix: &str, build_dir: &Path) -> PathBuf {
319 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
322 for b in build_dir.as_os_str().as_encoded_bytes() {
323 hash ^= u64::from(*b);
324 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
325 }
326 std::env::temp_dir().join(format!(
327 "ssg-staging-{}-{hash:016x}-{suffix}",
328 std::process::id()
329 ))
330}
331
332fn copy_tree(
342 src: &Path,
343 dst: &Path,
344 base_url: Option<&str>,
345) -> Result<(), io::Error> {
346 use rayon::iter::IntoParallelIterator;
347 use rayon::iter::ParallelIterator;
348
349 let mut files: Vec<(PathBuf, PathBuf, bool)> = Vec::new();
352 let mut dirs: Vec<(PathBuf, PathBuf)> = Vec::new();
353 collect_entries(src, dst, &mut files, &mut dirs)?;
354 flatten_nested_index_pages(dst, &mut files);
355
356 for (_src_dir, dst_dir) in &dirs {
359 fs::create_dir_all(dst_dir)?;
360 }
361
362 let errors: Vec<io::Error> = files
363 .into_par_iter()
364 .filter_map(|(src_path, dst_path, is_md)| {
365 let r = if is_md {
366 fs::read_to_string(&src_path).and_then(|body| {
373 let mut staged = collapse_multiline_quoted_scalars(&body);
374 if let Some(base) = base_url {
381 if let Ok(rel) = src_path.strip_prefix(src) {
393 let rel = rel.to_string_lossy();
394 let permalink =
395 crate::urls::derive_permalink(base, &rel);
396 staged = inject_permalink_if_missing(
397 &staged, &permalink,
398 );
399 }
400 }
401 fs::write(&dst_path, staged)
402 })
403 } else {
404 fs::copy(&src_path, &dst_path).map(|_| ())
405 };
406 r.err()
407 })
408 .collect();
409
410 if let Some(e) = errors.into_iter().next() {
411 return Err(e);
412 }
413 Ok(())
414}
415
416fn flatten_nested_index_pages(
451 dst_root: &Path,
452 files: &mut [(PathBuf, PathBuf, bool)],
453) {
454 let occupied: std::collections::HashSet<PathBuf> =
455 files.iter().map(|(_, dst, _)| dst.clone()).collect();
456
457 for (_src_path, dst_path, is_md) in files.iter_mut() {
458 if !*is_md {
459 continue;
460 }
461 if dst_path.file_stem().and_then(|s| s.to_str()) != Some("index") {
462 continue;
463 }
464 let Some(parent) = dst_path.parent() else {
467 continue;
468 };
469 if parent == dst_root {
470 continue;
471 }
472 let (Some(grandparent), Some(dir_name)) =
473 (parent.parent(), parent.file_name())
474 else {
475 continue;
476 };
477 let ext = dst_path.extension().unwrap_or_default().to_string_lossy();
478 let renamed =
479 grandparent.join(format!("{}.{ext}", dir_name.to_string_lossy()));
480 if occupied.contains(&renamed) {
481 continue;
482 }
483 *dst_path = renamed;
484 }
485}
486
487const CONTENT_CONTROL_FILES: &[&str] = &["content.schema.toml"];
497
498fn collect_entries(
505 src: &Path,
506 dst: &Path,
507 files: &mut Vec<(PathBuf, PathBuf, bool)>,
508 dirs: &mut Vec<(PathBuf, PathBuf)>,
509) -> Result<(), io::Error> {
510 for entry in fs::read_dir(src)? {
511 let entry = entry?;
512 let file_name = entry.file_name();
513 let src_path = entry.path();
514 let dst_path = dst.join(&file_name);
515 let file_type = entry.file_type()?;
516 if file_type.is_dir() {
517 dirs.push((src_path.clone(), dst_path.clone()));
518 collect_entries(&src_path, &dst_path, files, dirs)?;
519 } else if file_type.is_file() {
520 if CONTENT_CONTROL_FILES
521 .iter()
522 .any(|name| file_name.as_encoded_bytes() == name.as_bytes())
523 {
524 continue;
525 }
526 let is_md = is_markdown(&src_path);
527 files.push((src_path, dst_path, is_md));
528 }
529 }
532 Ok(())
533}
534
535pub fn collect_template_vars(
568 template_dir: &Path,
569) -> Result<Vec<String>, io::Error> {
570 let mut out = std::collections::BTreeSet::new();
571 if !template_dir.exists() {
572 return Ok(Vec::new());
573 }
574 walk_collect_vars(template_dir, &mut out)?;
575 Ok(out.into_iter().collect())
576}
577
578fn walk_collect_vars(
579 dir: &Path,
580 out: &mut std::collections::BTreeSet<String>,
581) -> Result<(), io::Error> {
582 for entry in fs::read_dir(dir)? {
583 let entry = entry?;
584 let p = entry.path();
585 let ft = entry.file_type()?;
586 if ft.is_dir() {
587 walk_collect_vars(&p, out)?;
588 } else if ft.is_file() {
589 let is_template = matches!(
592 p.extension().and_then(|s| s.to_str()),
593 Some("html" | "htm" | "xml" | "txt" | "rss")
594 );
595 if is_template {
596 if let Ok(body) = fs::read_to_string(&p) {
597 extract_simple_vars(&body, out);
598 }
599 }
600 }
601 }
602 Ok(())
603}
604
605fn extract_simple_vars(
609 body: &str,
610 out: &mut std::collections::BTreeSet<String>,
611) {
612 let bytes = body.as_bytes();
613 let mut i = 0;
614 while i + 1 < bytes.len() {
615 if bytes[i] == b'{' && bytes[i + 1] == b'{' {
616 if let Some(end) = find_closing_braces(&body[i + 2..]) {
618 let inner = body[i + 2..i + 2 + end].trim();
619 if let Some(name) = simple_var_name(inner) {
620 let _ = out.insert(name.to_string());
621 }
622 i += 2 + end + 2;
623 continue;
624 }
625 }
626 i += 1;
627 }
628}
629
630const fn find_closing_braces(s: &str) -> Option<usize> {
631 let bytes = s.as_bytes();
632 let mut j = 0;
633 while j + 1 < bytes.len() {
634 if bytes[j] == b'}' && bytes[j + 1] == b'}' {
635 return Some(j);
636 }
637 j += 1;
638 }
639 None
640}
641
642fn simple_var_name(inner: &str) -> Option<&str> {
646 let s = inner.trim();
647 if s.is_empty() {
648 return None;
649 }
650 let first = s.as_bytes()[0];
651 if matches!(first, b'#' | b'/' | b'!' | b'>') {
652 return None;
653 }
654 if s.contains('|') || s.contains('.') {
655 return None;
656 }
657 if s.bytes().any(|b| b.is_ascii_whitespace()) {
658 return None;
659 }
660 Some(s)
661}
662
663fn inject_template_defaults_recursive(
668 dir: &Path,
669 keys: &[String],
670 base_url: Option<&str>,
671 locales: &[String],
672) -> Result<(), io::Error> {
673 use rayon::iter::IntoParallelIterator;
674 use rayon::iter::ParallelIterator;
675
676 fail_point!("content_stager::inject-defaults", |_| {
677 Err(io::Error::other(
678 "injected: content_stager::inject-defaults",
679 ))
680 });
681
682 let mut md_files: Vec<PathBuf> = Vec::new();
683 collect_markdown_files(dir, &mut md_files)?;
684
685 let errors: Vec<io::Error> = md_files
686 .into_par_iter()
687 .filter_map(|p| {
688 let body = match fs::read_to_string(&p) {
689 Ok(b) => b,
690 Err(e) => return Some(e),
691 };
692 let rel = p.strip_prefix(dir).unwrap_or(&p);
696 let derived: Vec<(String, String)> =
697 derive_path_globals(base_url, rel, locales)
698 .into_iter()
699 .filter(|(k, _)| keys.iter().any(|want| want == k))
700 .collect();
701 let staged = inject_missing_keys_with_values(&body, keys, &derived);
702 if staged == body {
703 return None;
704 }
705 fs::write(&p, staged).err()
706 })
707 .collect();
708 if let Some(e) = errors.into_iter().next() {
709 return Err(e);
710 }
711 Ok(())
712}
713
714fn collect_markdown_files(
715 dir: &Path,
716 out: &mut Vec<PathBuf>,
717) -> Result<(), io::Error> {
718 for entry in fs::read_dir(dir)? {
719 let entry = entry?;
720 let p = entry.path();
721 let ft = entry.file_type()?;
722 if ft.is_dir() {
723 collect_markdown_files(&p, out)?;
724 } else if ft.is_file() && is_markdown(&p) {
725 out.push(p);
726 }
727 }
728 Ok(())
729}
730
731#[must_use]
735pub fn inject_missing_keys(body: &str, keys: &[String]) -> String {
736 inject_missing_keys_with_values(body, keys, &[])
737}
738
739pub fn inject_missing_keys_with_values(
746 body: &str,
747 keys: &[String],
748 derived: &[(String, String)],
749) -> String {
750 let trimmed = body.trim_start_matches('\u{FEFF}');
751 let Some((_lead, after_open)) = find_opening_fence(trimmed) else {
752 return body.to_string();
753 };
754 let Some(close_rel) = find_closing_fence(after_open) else {
755 return body.to_string();
756 };
757 let block = &after_open[..close_rel];
758 let after_block = &after_open[close_rel..];
759
760 let derived_keys: Vec<String> =
761 derived.iter().map(|(k, _)| k.clone()).collect();
762 let missing: Vec<&String> = keys
763 .iter()
764 .chain(derived_keys.iter())
765 .filter(|k| !frontmatter_has_key(block, k))
766 .collect();
767 if missing.is_empty() {
768 return body.to_string();
769 }
770
771 let mut additions = String::with_capacity(missing.len() * 24);
772 let mut seen: Vec<&str> = Vec::with_capacity(missing.len());
773 for k in missing {
774 if seen.contains(&k.as_str()) {
775 continue;
776 }
777 seen.push(k.as_str());
778 let value = derived
779 .iter()
780 .find(|(dk, _)| dk == k)
781 .map_or("", |(_, v)| v.as_str());
782 additions.push_str(&format!("{k}: \"{value}\"\n"));
783 }
784
785 let mut out = String::with_capacity(body.len() + additions.len());
786 out.push_str(&trimmed[..trimmed.len() - after_open.len()]);
787 out.push_str(&additions);
788 out.push_str(block);
789 out.push_str(after_block);
790 if body.starts_with('\u{FEFF}') {
791 return format!("\u{FEFF}{out}");
792 }
793 out
794}
795
796#[must_use]
835pub fn inject_permalink_if_missing(body: &str, permalink: &str) -> String {
836 let trimmed = body.trim_start_matches('\u{FEFF}');
837 let Some((_lead, after_open)) = find_opening_fence(trimmed) else {
838 return body.to_string();
839 };
840 let Some(close_rel) = find_closing_fence(after_open) else {
841 return body.to_string();
842 };
843 let block = &after_open[..close_rel];
844 let after_block = &after_open[close_rel..];
845
846 if frontmatter_has_key(block, "permalink")
850 || frontmatter_has_key(block, "url")
851 {
852 return body.to_string();
853 }
854
855 let mut out = String::with_capacity(body.len() + permalink.len() + 16);
856 out.push_str(&trimmed[..trimmed.len() - after_open.len()]);
857 out.push_str(&format!("permalink: \"{permalink}\"\n"));
858 out.push_str(block);
859 out.push_str(after_block);
860 if body.starts_with('\u{FEFF}') {
861 return format!("\u{FEFF}{out}");
862 }
863 out
864}
865
866fn frontmatter_has_key(block: &str, key: &str) -> bool {
869 for raw in block.lines() {
870 let line = raw.trim_start();
871 if line.starts_with('#') {
872 continue;
873 }
874 let prefixes = [
875 format!("{key}:"),
876 format!("{key} :"),
877 format!("\"{key}\":"),
878 format!("'{key}':"),
879 ];
880 if prefixes.iter().any(|p| line.starts_with(p)) {
881 return true;
882 }
883 }
884 false
885}
886
887fn is_markdown(p: &Path) -> bool {
888 matches!(
889 p.extension().and_then(|s| s.to_str()),
890 Some("md" | "markdown")
891 )
892}
893
894fn collapse_multiline_quoted_scalars(block: &str) -> String {
916 let mut out = String::with_capacity(block.len());
917 let lines: Vec<&str> = block.lines().collect();
918 let mut i = 0;
919 while i < lines.len() {
920 let line = lines[i];
921 if let Some(eq_pos) = line.find(": \"") {
923 let after_quote = &line[eq_pos + 3..];
924 if after_quote.trim().is_empty() {
928 let mut joined = String::from(&line[..eq_pos + 3]);
931 let mut closed = false;
932 i += 1;
933 while i < lines.len() {
934 let next = lines[i];
935 if let Some(close) = next.find('"') {
936 joined.push_str(next[..close].trim_start());
937 joined.push_str(&next[close..]);
938 out.push_str(&joined);
939 out.push('\n');
940 i += 1;
941 closed = true;
942 break;
943 }
944 joined.push_str(next.trim_start());
945 joined.push(' ');
946 i += 1;
947 }
948 if !closed {
953 out.push_str(joined.trim_end());
954 out.push('\n');
955 }
956 continue;
957 }
958 }
959 out.push_str(line);
960 out.push('\n');
961 i += 1;
962 }
963 out
964}
965
966fn find_opening_fence(s: &str) -> Option<(&str, &str)> {
970 let mut byte_pos = 0;
974 for line in s.split_inclusive('\n') {
975 let bare = line.trim_end_matches('\n').trim_end_matches('\r');
976 if bare.trim().is_empty() {
977 byte_pos += line.len();
978 continue;
979 }
980 if bare == "---" {
981 let lead = &s[..byte_pos];
982 let after = &s[byte_pos + line.len()..];
983 return Some((lead, after));
984 }
985 return None;
986 }
987 None
988}
989
990fn find_closing_fence(after_open: &str) -> Option<usize> {
994 let mut byte_pos = 0;
995 for line in after_open.split_inclusive('\n') {
996 let bare = line.trim_end_matches('\n').trim_end_matches('\r');
997 if bare.trim() == "---" {
998 return Some(byte_pos);
999 }
1000 byte_pos += line.len();
1001 }
1002 None
1003}
1004
1005#[cfg(test)]
1010mod tests {
1011 use super::*;
1012
1013 #[test]
1014 fn inject_missing_keys_no_frontmatter_passthrough() {
1015 let body = "no frontmatter here\nplain markdown";
1017 let out = inject_missing_keys(body, &["x".to_string()]);
1018 assert_eq!(out, body);
1019 }
1020
1021 #[test]
1022 fn inject_missing_keys_unterminated_frontmatter_passthrough() {
1023 let body = "---\ntitle: T\n# never closes";
1025 let out = inject_missing_keys(body, &["x".to_string()]);
1026 assert_eq!(out, body);
1027 }
1028
1029 #[test]
1030 fn inject_missing_keys_with_bom_preserves_bom() {
1031 let body = "\u{FEFF}---\ntitle: T\n---\nbody";
1036 let out = inject_missing_keys(body, &["author".to_string()]);
1037 assert!(out.starts_with('\u{FEFF}'));
1038 assert!(out.contains("author: \"\""));
1039 }
1040
1041 #[test]
1042 fn inject_missing_keys_returns_body_unchanged_when_all_keys_present() {
1043 let body = "---\ntitle: T\nauthor: A\n---\nbody";
1047 let out = inject_missing_keys(
1048 body,
1049 &["title".to_string(), "author".to_string()],
1050 );
1051 assert_eq!(out, body);
1052 }
1053
1054 #[test]
1055 fn find_closing_braces_returns_none_when_unterminated() {
1056 let mut out = std::collections::BTreeSet::new();
1059 extract_simple_vars("{{ never_closes", &mut out);
1060 assert!(out.is_empty());
1062 }
1063
1064 #[test]
1065 fn collapse_multiline_quoted_scalar_collapses_two_line() {
1066 let input = "url: \"\nhttps://example.com/x\"\n";
1067 let out = collapse_multiline_quoted_scalars(input);
1068 assert!(out.contains("url: \"https://example.com/x\""));
1069 assert!(!out.contains("\nhttps"));
1070 }
1071
1072 #[test]
1073 fn collapse_multiline_quoted_scalar_preserves_single_line() {
1074 let input = "title: \"On one line\"\nauthor: \"Jane\"\n";
1075 let out = collapse_multiline_quoted_scalars(input);
1076 assert_eq!(out, input);
1077 }
1078
1079 #[test]
1080 fn collapse_multiline_quoted_scalar_collapses_three_line() {
1081 let input = "blurb: \"\nline one\nline two\"\n";
1082 let out = collapse_multiline_quoted_scalars(input);
1083 assert!(out.contains("blurb: \"line one line two\""));
1084 }
1085
1086 #[test]
1087 fn collapse_handles_unterminated_quote_gracefully() {
1088 let input = "x: \"\nstill open\n";
1092 let out = collapse_multiline_quoted_scalars(input);
1093 assert!(out.contains("still open"));
1094 }
1095
1096 #[test]
1097 fn user_real_world_twitter_url_multiline_collapses() {
1098 let input = "twitter_url: \"\nhttps://sebastienrousseau.com/2026-04-11-quantum-thresholds-are-moving-again\"\n";
1102 let out = collapse_multiline_quoted_scalars(input);
1103 assert!(out.contains("twitter_url: \"https://"));
1104 assert_eq!(out.lines().count(), 1);
1105 }
1106
1107 #[test]
1112 fn inject_permalink_adds_key_when_missing() {
1113 let out = inject_permalink_if_missing(
1114 "---\ntitle: T\n---\nbody",
1115 "https://example.com/t/",
1116 );
1117 assert!(out.contains("permalink: \"https://example.com/t/\""));
1118 assert!(out.contains("title: T"));
1119 assert!(out.contains("body"));
1120 }
1121
1122 #[test]
1123 fn inject_permalink_preserves_author_permalink_verbatim() {
1124 let input = "---\npermalink: /custom/place/\ntitle: T\n---\nbody";
1125 assert_eq!(
1126 inject_permalink_if_missing(input, "https://example.com/t/"),
1127 input
1128 );
1129 }
1130
1131 #[test]
1132 fn inject_permalink_treats_url_key_as_author_specified() {
1133 let input = "---\nurl: https://elsewhere.example/\ntitle: T\n---\nb";
1134 assert_eq!(
1135 inject_permalink_if_missing(input, "https://example.com/t/"),
1136 input
1137 );
1138 }
1139
1140 #[test]
1141 fn inject_permalink_no_frontmatter_passthrough() {
1142 let input = "# Heading\n\nBody.";
1143 assert_eq!(
1144 inject_permalink_if_missing(input, "https://example.com/"),
1145 input
1146 );
1147 }
1148
1149 #[test]
1150 fn inject_permalink_unterminated_fence_passthrough() {
1151 let input = "---\ntitle: T\n# never closes";
1152 assert_eq!(
1153 inject_permalink_if_missing(input, "https://example.com/"),
1154 input
1155 );
1156 }
1157
1158 #[test]
1159 fn inject_permalink_preserves_bom() {
1160 let input = "\u{FEFF}---\ntitle: T\n---\nbody";
1161 let out = inject_permalink_if_missing(input, "https://example.com/t/");
1162 assert!(out.starts_with('\u{FEFF}'));
1163 assert!(out.contains("permalink: \"https://example.com/t/\""));
1164 }
1165
1166 #[test]
1167 fn inject_permalink_is_idempotent() {
1168 let input = "---\ntitle: T\n---\nbody";
1169 let once = inject_permalink_if_missing(input, "https://example.com/t/");
1170 let twice =
1171 inject_permalink_if_missing(&once, "https://example.com/t/");
1172 assert_eq!(once, twice);
1173 }
1174
1175 #[test]
1176 #[serial_test::parallel(stager_fp)]
1177 fn stage_with_site_defaults_derives_permalinks_for_all_pages() {
1178 let tmp = tempfile::tempdir().unwrap();
1183 let src = tmp.path().join("content");
1184 let build = tmp.path().join("build");
1185 fs::create_dir_all(src.join("posts")).unwrap();
1186 fs::write(src.join("index.md"), "---\ntitle: Home\n---\nhome").unwrap();
1187 fs::write(src.join("about.md"), "---\ntitle: About\n---\nab").unwrap();
1188 fs::write(src.join("posts/first.md"), "---\ntitle: First\n---\npost")
1189 .unwrap();
1190
1191 let staged = stage_content_with_site_defaults(
1192 &src,
1193 &build,
1194 &[],
1195 Some("https://example.com"),
1196 &[],
1197 )
1198 .unwrap();
1199
1200 let home = fs::read_to_string(staged.join("index.md")).unwrap();
1201 assert!(
1202 home.contains("permalink: \"https://example.com/\""),
1203 "index.md must map to the site root URL: {home}"
1204 );
1205 let about = fs::read_to_string(staged.join("about.md")).unwrap();
1206 assert!(
1207 about.contains("permalink: \"https://example.com/about/\""),
1208 "about.md must map to a pretty directory URL: {about}"
1209 );
1210 let post = fs::read_to_string(staged.join("posts/first.md")).unwrap();
1211 assert!(
1212 post.contains("permalink: \"https://example.com/posts/first/\""),
1213 "nested page must include its directory path: {post}"
1214 );
1215 }
1216
1217 #[test]
1218 #[serial_test::parallel(stager_fp)]
1219 fn stage_with_site_defaults_keeps_author_permalink_verbatim() {
1220 let tmp = tempfile::tempdir().unwrap();
1221 let src = tmp.path().join("content");
1222 let build = tmp.path().join("build");
1223 fs::create_dir_all(&src).unwrap();
1224 fs::write(
1225 src.join("custom.md"),
1226 "---\nlayout: post\npermalink: \"https://example.com/my-spot/\"\ntitle: C\n---\nbody",
1227 )
1228 .unwrap();
1229
1230 let staged = stage_content_with_site_defaults(
1231 &src,
1232 &build,
1233 &[],
1234 Some("https://example.com"),
1235 &[],
1236 )
1237 .unwrap();
1238 let body = fs::read_to_string(staged.join("custom.md")).unwrap();
1239 assert!(body.contains("permalink: \"https://example.com/my-spot/\""));
1240 assert_eq!(body.matches("permalink:").count(), 1);
1242 }
1243
1244 #[test]
1245 #[serial_test::parallel(stager_fp)]
1246 fn stage_with_site_defaults_handles_nested_index_md() {
1247 let tmp = tempfile::tempdir().unwrap();
1255 let src = tmp.path().join("content");
1256 let build = tmp.path().join("build");
1257 fs::create_dir_all(src.join("about")).unwrap();
1258 fs::write(src.join("about/index.md"), "---\ntitle: About\n---\nbody")
1259 .unwrap();
1260
1261 let staged = stage_content_with_site_defaults(
1262 &src,
1263 &build,
1264 &[],
1265 Some("https://example.com/"),
1266 &[],
1267 )
1268 .unwrap();
1269 let body = fs::read_to_string(staged.join("about.md")).unwrap();
1270 assert!(
1271 body.contains("permalink: \"https://example.com/about/\""),
1272 "trailing-slash base + nested index.md: {body}"
1273 );
1274 }
1275
1276 #[test]
1281 #[serial_test::parallel(stager_fp)]
1282 fn stage_flattens_nested_index_md_to_parent_named_file() {
1283 let tmp = tempfile::tempdir().unwrap();
1293 let src = tmp.path().join("content");
1294 let build = tmp.path().join("build");
1295 fs::create_dir_all(src.join("fr")).unwrap();
1296 fs::create_dir_all(src.join("fr/blog")).unwrap();
1297 fs::write(src.join("index.md"), "---\ntitle: Home\n---\nen").unwrap();
1298 fs::write(src.join("fr/index.md"), "---\ntitle: Accueil\n---\nfr")
1299 .unwrap();
1300 fs::write(src.join("fr/blog/index.md"), "---\ntitle: Blog\n---\nb")
1301 .unwrap();
1302 fs::write(src.join("fr/a-propos.md"), "---\ntitle: A\n---\nap")
1303 .unwrap();
1304
1305 let staged =
1306 stage_content_with_template_defaults(&src, &build, &[]).unwrap();
1307
1308 assert!(
1309 staged.join("index.md").exists(),
1310 "root index.md is already correct and must stay put"
1311 );
1312 assert!(
1313 staged.join("fr.md").exists(),
1314 "fr/index.md must stage as fr.md so it compiles to fr/index.html"
1315 );
1316 assert!(
1317 !staged.join("fr/index.md").exists(),
1318 "the nested original must not also be staged"
1319 );
1320 assert!(
1321 staged.join("fr/blog.md").exists(),
1322 "deeper nesting flattens one level too"
1323 );
1324 assert!(
1325 !staged.join("fr/blog/index.md").exists(),
1326 "the nested original must not also be staged"
1327 );
1328 assert!(
1329 staged.join("fr/a-propos.md").exists(),
1330 "non-index siblings are untouched"
1331 );
1332 }
1333
1334 #[test]
1335 #[serial_test::parallel(stager_fp)]
1336 fn stage_keeps_nested_index_md_when_parent_named_file_exists() {
1337 let tmp = tempfile::tempdir().unwrap();
1341 let src = tmp.path().join("content");
1342 let build = tmp.path().join("build");
1343 fs::create_dir_all(src.join("fr")).unwrap();
1344 fs::write(src.join("fr.md"), "---\ntitle: FR\n---\nsection").unwrap();
1345 fs::write(src.join("fr/index.md"), "---\ntitle: Accueil\n---\nfr")
1346 .unwrap();
1347
1348 let staged =
1349 stage_content_with_template_defaults(&src, &build, &[]).unwrap();
1350
1351 assert!(staged.join("fr.md").exists());
1352 assert!(
1353 staged.join("fr/index.md").exists(),
1354 "collision must not clobber the authored fr.md"
1355 );
1356 assert!(
1357 fs::read_to_string(staged.join("fr.md"))
1358 .unwrap()
1359 .contains("title: FR"),
1360 "the authored fr.md must survive verbatim"
1361 );
1362 }
1363
1364 #[test]
1365 fn flatten_nested_index_pages_keeps_dotted_directory_names_intact() {
1366 let dst = Path::new("/staged");
1369 let mut files = vec![(
1370 PathBuf::from("/src/v1.2/index.md"),
1371 PathBuf::from("/staged/v1.2/index.md"),
1372 true,
1373 )];
1374 flatten_nested_index_pages(dst, &mut files);
1375 assert_eq!(files[0].1, PathBuf::from("/staged/v1.2.md"));
1376 }
1377
1378 #[test]
1379 fn flatten_nested_index_pages_ignores_non_markdown_and_root_files() {
1380 let dst = Path::new("/staged");
1381 let mut files = vec![
1382 (
1384 PathBuf::from("/src/fr/index.html"),
1385 PathBuf::from("/staged/fr/index.html"),
1386 false,
1387 ),
1388 (
1390 PathBuf::from("/src/index.md"),
1391 PathBuf::from("/staged/index.md"),
1392 true,
1393 ),
1394 (
1396 PathBuf::from("/src/fr/about.md"),
1397 PathBuf::from("/staged/fr/about.md"),
1398 true,
1399 ),
1400 ];
1401 let before = files.clone();
1402 flatten_nested_index_pages(dst, &mut files);
1403 assert_eq!(files, before);
1404 }
1405
1406 #[test]
1407 #[serial_test::parallel(stager_fp)]
1408 fn stage_without_base_url_injects_no_permalink() {
1409 let tmp = tempfile::tempdir().unwrap();
1410 let src = tmp.path().join("content");
1411 let build = tmp.path().join("build");
1412 fs::create_dir_all(&src).unwrap();
1413 fs::write(src.join("a.md"), "---\ntitle: A\n---\nbody").unwrap();
1414
1415 let staged =
1417 stage_content_with_template_defaults(&src, &build, &[]).unwrap();
1418 let body = fs::read_to_string(staged.join("a.md")).unwrap();
1419 assert!(!body.contains("permalink:"));
1420
1421 let staged = stage_content_with_site_defaults(
1425 &src,
1426 &build,
1427 &[],
1428 Some(" "),
1429 &[],
1430 )
1431 .unwrap();
1432 let body = fs::read_to_string(staged.join("a.md")).unwrap();
1433 assert!(!body.contains("permalink:"));
1434 }
1435
1436 #[test]
1437 #[serial_test::parallel(stager_fp)]
1438 fn stage_with_site_defaults_is_idempotent_across_runs() {
1439 let tmp = tempfile::tempdir().unwrap();
1444 let src = tmp.path().join("content");
1445 let build = tmp.path().join("build");
1446 fs::create_dir_all(&src).unwrap();
1447 fs::write(src.join("a.md"), "---\ntitle: A\n---\nbody").unwrap();
1448
1449 let _first = stage_content_with_site_defaults(
1450 &src,
1451 &build,
1452 &[],
1453 Some("https://example.com"),
1454 &[],
1455 )
1456 .unwrap();
1457 let staged = stage_content_with_site_defaults(
1458 &src,
1459 &build,
1460 &[],
1461 Some("https://example.com"),
1462 &[],
1463 )
1464 .unwrap();
1465 let body = fs::read_to_string(staged.join("a.md")).unwrap();
1466 assert_eq!(body.matches("permalink:").count(), 1);
1467 assert_eq!(body.matches("layout:").count(), 0);
1468 }
1469
1470 #[test]
1471 #[serial_test::parallel(stager_fp)]
1472 fn stage_content_with_template_defaults_injects_defaults_end_to_end() {
1473 let tmp = tempfile::tempdir().unwrap();
1481 let src = tmp.path().join("content");
1482 let build = tmp.path().join("build");
1483 fs::create_dir_all(&src).unwrap();
1484 fs::write(src.join("a.md"), "---\ntitle: A\n---\nbody").unwrap();
1485
1486 let staged = stage_content_with_template_defaults(
1487 &src,
1488 &build,
1489 &["author".to_string()],
1490 )
1491 .unwrap();
1492
1493 let body = fs::read_to_string(staged.join("a.md")).unwrap();
1494 assert!(
1495 body.contains("author: \"\""),
1496 "missing template var must be injected: {body}"
1497 );
1498 }
1499
1500 fn locales() -> Vec<String> {
1503 vec!["en".to_string(), "fr".to_string()]
1504 }
1505
1506 fn derived_for(rel: &str, base: Option<&str>) -> Vec<(String, String)> {
1507 derive_path_globals(base, Path::new(rel), &locales())
1508 }
1509
1510 fn value_of(pairs: &[(String, String)], key: &str) -> String {
1511 pairs
1512 .iter()
1513 .find(|(k, _)| k == key)
1514 .map(|(_, v)| v.clone())
1515 .unwrap_or_default()
1516 }
1517
1518 #[test]
1523 fn derived_paths_separate_site_scope_from_locale_scope() {
1524 let d =
1525 derived_for("fr/a-propos.md", Some("https://example.com/atlas"));
1526
1527 assert_eq!(value_of(&d, "site_path"), "/atlas/");
1528 assert_eq!(value_of(&d, "site_url"), "https://example.com/atlas/");
1529 assert_eq!(value_of(&d, "locale_path"), "/atlas/fr/");
1530 assert_eq!(value_of(&d, "locale_url"), "https://example.com/atlas/fr/");
1531 }
1532
1533 #[test]
1536 fn derived_paths_recognise_a_flattened_locale_home_page() {
1537 let d = derived_for("fr.md", Some("https://example.com/atlas"));
1538 assert_eq!(value_of(&d, "locale_path"), "/atlas/fr/");
1539 }
1540
1541 #[test]
1544 fn derived_paths_collapse_for_the_root_hosted_default_locale() {
1545 let d = derived_for("about.md", Some("https://example.com/atlas"));
1546 assert_eq!(value_of(&d, "locale_path"), value_of(&d, "site_path"));
1547 assert_eq!(value_of(&d, "locale_url"), value_of(&d, "site_url"));
1548 }
1549
1550 #[test]
1553 fn derived_paths_ignore_locales_when_only_one_is_configured() {
1554 let d = derive_path_globals(
1555 Some("https://example.com"),
1556 Path::new("fr/a-propos.md"),
1557 &["en".to_string()],
1558 );
1559 assert_eq!(value_of(&d, "locale_path"), "/");
1560 }
1561
1562 #[test]
1565 fn derived_paths_handle_the_domain_root_and_a_missing_base_url() {
1566 let root = derived_for("about.md", Some("https://example.com"));
1567 assert_eq!(value_of(&root, "site_path"), "/");
1568 assert_eq!(value_of(&root, "site_url"), "https://example.com/");
1569
1570 let none = derived_for("about.md", None);
1571 assert_eq!(value_of(&none, "site_path"), "/");
1572 assert_eq!(value_of(&none, "site_url"), "/");
1573 }
1574
1575 #[test]
1577 fn derived_paths_always_end_in_a_slash() {
1578 for base in [
1579 Some("https://example.com/atlas/"),
1580 Some("https://example.com/atlas"),
1581 ] {
1582 for (key, value) in derived_for("fr/x.md", base) {
1583 assert!(value.ends_with('/'), "{key} = {value:?}");
1584 }
1585 }
1586 }
1587
1588 #[test]
1591 fn author_front_matter_overrides_a_derived_value() {
1592 let body = "---\nlocale_path: \"/custom/\"\n---\nbody\n";
1593 let out = inject_missing_keys_with_values(
1594 body,
1595 &["locale_path".to_string()],
1596 &[("locale_path".to_string(), "/atlas/fr/".to_string())],
1597 );
1598 assert!(out.contains("/custom/"), "{out}");
1599 assert!(
1600 !out.contains("/atlas/fr/"),
1601 "derived value overrode the author: {out}"
1602 );
1603 }
1604
1605 #[test]
1606 fn inject_template_defaults_recursive_skips_write_when_no_keys_missing() {
1607 let tmp = tempfile::tempdir().unwrap();
1612 let dir = tmp.path().join("staged");
1613 fs::create_dir_all(&dir).unwrap();
1614 let path = dir.join("a.md");
1615 let original = "---\ntitle: T\nauthor: A\n---\nbody";
1616 fs::write(&path, original).unwrap();
1617 let before = fs::metadata(&path).unwrap().modified().unwrap();
1618
1619 inject_template_defaults_recursive(
1620 &dir,
1621 &["title".to_string(), "author".to_string()],
1622 None,
1623 &[],
1624 )
1625 .unwrap();
1626
1627 let after_body = fs::read_to_string(&path).unwrap();
1628 assert_eq!(after_body, original, "no-op write must not alter content");
1629 let after = fs::metadata(&path).unwrap().modified().unwrap();
1630 assert_eq!(before, after, "file must not be rewritten when unchanged");
1631 }
1632
1633 #[test]
1638 fn recreate_staging_dir_wipes_previous_contents() {
1639 let tmp = tempfile::tempdir().unwrap();
1640 let staging = tmp.path().join("staging");
1641 fs::create_dir_all(&staging).unwrap();
1642 fs::write(staging.join("stale.md"), "old").unwrap();
1643
1644 recreate_staging_dir(&staging).unwrap();
1645
1646 assert!(staging.is_dir());
1647 assert!(!staging.join("stale.md").exists());
1648 }
1649
1650 #[test]
1651 fn recreate_staging_dir_fails_when_path_is_a_file() {
1652 let tmp = tempfile::tempdir().unwrap();
1654 let blocked = tmp.path().join("staging");
1655 fs::write(&blocked, "not a dir").unwrap();
1656
1657 assert!(recreate_staging_dir(&blocked).is_err());
1658 }
1659
1660 #[test]
1661 #[cfg(unix)]
1662 fn recreate_staging_dir_fails_when_parent_is_read_only() {
1663 use std::os::unix::fs::PermissionsExt;
1666 let tmp = tempfile::tempdir().unwrap();
1667 let parent = tmp.path().join("ro");
1668 fs::create_dir_all(&parent).unwrap();
1669 fs::set_permissions(&parent, fs::Permissions::from_mode(0o555))
1670 .unwrap();
1671
1672 let res = recreate_staging_dir(&parent.join("staging"));
1673
1674 let _ = fs::set_permissions(&parent, fs::Permissions::from_mode(0o755));
1675 assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1677 }
1678
1679 #[test]
1680 #[serial_test::parallel(stager_fp)]
1681 fn stage_fails_when_staging_root_is_blocked_by_a_file() {
1682 let tmp = tempfile::tempdir().unwrap();
1685 let src = tmp.path().join("content");
1686 let build = tmp.path().join("build");
1687 fs::create_dir_all(&src).unwrap();
1688
1689 let staging = staging_root_for("content", &build);
1690 fs::write(&staging, "blocker").unwrap();
1691
1692 let res =
1693 stage_content_with_site_defaults(&src, &build, &[], None, &[]);
1694 let _ = fs::remove_file(&staging);
1695 assert!(res.is_err());
1696 }
1697
1698 #[test]
1703 fn copy_tree_fails_when_destination_subdir_is_blocked() {
1704 let tmp = tempfile::tempdir().unwrap();
1707 let src = tmp.path().join("src");
1708 let dst = tmp.path().join("dst");
1709 fs::create_dir_all(src.join("sub")).unwrap();
1710 fs::write(src.join("sub/a.md"), "---\nt: a\n---\nx").unwrap();
1711 fs::create_dir_all(&dst).unwrap();
1712 fs::write(dst.join("sub"), "file, not dir").unwrap();
1713
1714 assert!(copy_tree(&src, &dst, None).is_err());
1715 }
1716
1717 #[test]
1718 fn copy_tree_copies_non_markdown_files_verbatim() {
1719 let tmp = tempfile::tempdir().unwrap();
1720 let src = tmp.path().join("src");
1721 let dst = tmp.path().join("dst");
1722 fs::create_dir_all(&src).unwrap();
1723 fs::create_dir_all(&dst).unwrap();
1724 fs::write(src.join("style.css"), "body{}").unwrap();
1725
1726 copy_tree(&src, &dst, None).unwrap();
1727 assert_eq!(
1728 fs::read_to_string(dst.join("style.css")).unwrap(),
1729 "body{}"
1730 );
1731 }
1732
1733 #[test]
1734 fn copy_tree_reports_first_per_file_error() {
1735 let tmp = tempfile::tempdir().unwrap();
1738 let src = tmp.path().join("src");
1739 let dst = tmp.path().join("dst");
1740 fs::create_dir_all(&src).unwrap();
1741 fs::create_dir_all(&dst).unwrap();
1742 fs::write(src.join("bad.md"), [0xFF, 0xFE, 0x00]).unwrap();
1743
1744 assert!(copy_tree(&src, &dst, None).is_err());
1745 }
1746
1747 #[test]
1748 fn copy_tree_reports_error_copying_non_markdown_file() {
1749 let tmp = tempfile::tempdir().unwrap();
1757 let src = tmp.path().join("src");
1758 let dst = tmp.path().join("dst");
1759 fs::create_dir_all(&src).unwrap();
1760 fs::create_dir_all(&dst).unwrap();
1761 fs::write(src.join("logo.png"), b"not really a png").unwrap();
1762 fs::create_dir_all(dst.join("logo.png")).unwrap();
1764
1765 assert!(copy_tree(&src, &dst, None).is_err());
1766 }
1767
1768 #[test]
1769 #[cfg(unix)]
1770 fn copy_tree_propagates_unreadable_source_subdir() {
1771 use std::os::unix::fs::PermissionsExt;
1774 let tmp = tempfile::tempdir().unwrap();
1775 let src = tmp.path().join("src");
1776 let dst = tmp.path().join("dst");
1777 let locked = src.join("locked");
1778 fs::create_dir_all(&locked).unwrap();
1779 fs::create_dir_all(&dst).unwrap();
1780 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1781 .unwrap();
1782
1783 let res = copy_tree(&src, &dst, None);
1784
1785 let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o755));
1786 assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1788 }
1789
1790 #[test]
1791 #[cfg(unix)]
1792 fn collect_entries_skips_symlinks_and_special_files() {
1793 let tmp = tempfile::tempdir().unwrap();
1794 let src = tmp.path().join("src");
1795 let dst = tmp.path().join("dst");
1796 fs::create_dir_all(&src).unwrap();
1797 fs::create_dir_all(&dst).unwrap();
1798 fs::write(src.join("real.md"), "---\nt: a\n---\nx").unwrap();
1799 std::os::unix::fs::symlink(src.join("nowhere.md"), src.join("link.md"))
1800 .unwrap();
1801
1802 copy_tree(&src, &dst, None).unwrap();
1803 assert!(dst.join("real.md").exists());
1804 assert!(!dst.join("link.md").exists(), "symlinks must be skipped");
1805 }
1806
1807 #[test]
1813 fn collect_template_vars_recurses_into_subdirectories() {
1814 let tmp = tempfile::tempdir().unwrap();
1815 let t = tmp.path().join("templates");
1816 fs::create_dir_all(t.join("partials")).unwrap();
1817 fs::write(t.join("page.html"), "{{ title }}").unwrap();
1818 fs::write(t.join("partials/nav.html"), "{{ nav_label }}").unwrap();
1819
1820 let vars = collect_template_vars(&t).unwrap();
1821 assert!(vars.contains(&"title".to_string()));
1822 assert!(vars.contains(&"nav_label".to_string()));
1823 }
1824
1825 #[test]
1826 #[cfg(unix)]
1827 fn collect_template_vars_propagates_unreadable_subdir() {
1828 use std::os::unix::fs::PermissionsExt;
1829 let tmp = tempfile::tempdir().unwrap();
1830 let t = tmp.path().join("templates");
1831 let sub = t.join("locked");
1832 fs::create_dir_all(&sub).unwrap();
1833 fs::set_permissions(&sub, fs::Permissions::from_mode(0o000)).unwrap();
1834
1835 let res = collect_template_vars(&t);
1836
1837 let _ = fs::set_permissions(&sub, fs::Permissions::from_mode(0o755));
1838 assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1840 }
1841
1842 #[test]
1843 fn extract_simple_vars_rejects_every_non_simple_shape() {
1844 let tmp = tempfile::tempdir().unwrap();
1845 let t = tmp.path().join("templates");
1846 fs::create_dir_all(&t).unwrap();
1847 fs::write(
1848 t.join("page.html"),
1849 "{{ }}{{#each xs}}{{/each}}{{!raw}}{{>part}}\
1852 {{ a | upper }}{{ a.b }}{{ a b }}{{ good }}{{ broken",
1853 )
1854 .unwrap();
1855
1856 let vars = collect_template_vars(&t).unwrap();
1857 assert_eq!(vars, vec!["good".to_string()]);
1858 }
1859
1860 #[test]
1861 fn walk_collect_vars_skips_unreadable_and_non_template_files() {
1862 let tmp = tempfile::tempdir().unwrap();
1863 let t = tmp.path().join("templates");
1864 fs::create_dir_all(&t).unwrap();
1865 fs::write(t.join("binary.html"), [0xFF, 0xFE, 0x00]).unwrap();
1868 fs::write(t.join("style.css"), "{{ not_a_var }}").unwrap();
1870 fs::write(t.join("page.html"), "{{ real_var }}").unwrap();
1871
1872 let vars = collect_template_vars(&t).unwrap();
1873 assert_eq!(vars, vec!["real_var".to_string()]);
1874 }
1875
1876 #[test]
1881 fn inject_defaults_recurses_and_injects_in_nested_dirs() {
1882 let tmp = tempfile::tempdir().unwrap();
1883 let dir = tmp.path().join("staged");
1884 fs::create_dir_all(dir.join("blog")).unwrap();
1885 fs::write(dir.join("blog/a.md"), "---\ntitle: A\n---\nx").unwrap();
1886
1887 inject_template_defaults_recursive(
1888 &dir,
1889 &["author".to_string()],
1890 None,
1891 &[],
1892 )
1893 .unwrap();
1894
1895 let body = fs::read_to_string(dir.join("blog/a.md")).unwrap();
1896 assert!(body.contains("author:"));
1897 }
1898
1899 #[test]
1900 fn inject_defaults_reports_unreadable_markdown() {
1901 let tmp = tempfile::tempdir().unwrap();
1903 let dir = tmp.path().join("staged");
1904 fs::create_dir_all(&dir).unwrap();
1905 fs::write(dir.join("bad.md"), [0xFF, 0xFE]).unwrap();
1906
1907 let res = inject_template_defaults_recursive(
1908 &dir,
1909 &["k".to_string()],
1910 None,
1911 &[],
1912 );
1913 assert!(res.is_err());
1914 }
1915
1916 #[test]
1917 #[cfg(unix)]
1918 fn inject_defaults_propagates_unreadable_subdir() {
1919 use std::os::unix::fs::PermissionsExt;
1920 let tmp = tempfile::tempdir().unwrap();
1921 let dir = tmp.path().join("staged");
1922 let sub = dir.join("locked");
1923 fs::create_dir_all(&sub).unwrap();
1924 fs::set_permissions(&sub, fs::Permissions::from_mode(0o000)).unwrap();
1925
1926 let res = inject_template_defaults_recursive(
1927 &dir,
1928 &["k".to_string()],
1929 None,
1930 &[],
1931 );
1932
1933 let _ = fs::set_permissions(&sub, fs::Permissions::from_mode(0o755));
1934 assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1935 }
1936
1937 #[test]
1938 #[cfg(unix)]
1939 fn collect_markdown_files_skips_symlinks() {
1940 let tmp = tempfile::tempdir().unwrap();
1941 let dir = tmp.path().join("staged");
1942 fs::create_dir_all(&dir).unwrap();
1943 fs::write(dir.join("real.md"), "---\nt: a\n---\nx").unwrap();
1944 std::os::unix::fs::symlink(dir.join("nowhere.md"), dir.join("link.md"))
1945 .unwrap();
1946
1947 let mut found = Vec::new();
1948 collect_markdown_files(&dir, &mut found).unwrap();
1949 assert_eq!(found.len(), 1);
1950 }
1951
1952 #[test]
1953 fn collect_markdown_files_skips_non_markdown_regular_files() {
1954 let tmp = tempfile::tempdir().unwrap();
1960 let dir = tmp.path().join("staged");
1961 fs::create_dir_all(&dir).unwrap();
1962 fs::write(dir.join("real.md"), "---\nt: a\n---\nx").unwrap();
1963 fs::write(dir.join("notes.txt"), "not markdown").unwrap();
1964 fs::write(dir.join("style.css"), "body{}").unwrap();
1965
1966 let mut found = Vec::new();
1967 collect_markdown_files(&dir, &mut found).unwrap();
1968 assert_eq!(found, vec![dir.join("real.md")]);
1969 }
1970
1971 #[test]
1976 fn is_markdown_accepts_both_extensions() {
1977 assert!(is_markdown(Path::new("a.md")));
1978 assert!(is_markdown(Path::new("a.markdown")));
1979 assert!(!is_markdown(Path::new("a.html")));
1980 }
1981
1982 #[test]
1983 fn find_opening_fence_skips_leading_blank_lines() {
1984 let (lead, after) =
1985 find_opening_fence("\n \n---\ntitle: x\n---\nbody").unwrap();
1986 assert_eq!(lead, "\n \n");
1987 assert!(after.starts_with("title: x"));
1988 }
1989
1990 #[test]
1991 fn find_opening_fence_returns_none_for_blank_only_input() {
1992 assert!(find_opening_fence("\n\n \n").is_none());
1993 assert!(find_opening_fence("").is_none());
1994 }
1995
1996 #[cfg(feature = "test-fault-injection")]
2001 #[test]
2002 #[serial_test::serial(stager_fp)]
2003 fn stage_fault_inject_defaults_returns_err() {
2004 struct FailGuard(&'static str);
2007 impl Drop for FailGuard {
2008 fn drop(&mut self) {
2009 let _ = fail::cfg(self.0, "off");
2010 }
2011 }
2012 let _guard = FailGuard("content_stager::inject-defaults");
2013 fail::cfg("content_stager::inject-defaults", "return")
2014 .expect("activate failpoint");
2015
2016 let tmp = tempfile::tempdir().unwrap();
2017 let src = tmp.path().join("content");
2018 let build = tmp.path().join("build");
2019 fs::create_dir_all(&src).unwrap();
2020 fs::write(src.join("a.md"), "---\ntitle: A\n---\nbody").unwrap();
2021
2022 let err = stage_content_with_site_defaults(
2023 &src,
2024 &build,
2025 &["title".to_string()],
2026 None,
2027 &[],
2028 )
2029 .expect_err("failpoint must abort the staging pass");
2030 assert!(format!("{err}").contains("inject-defaults"));
2031 }
2032}