1use std::path::{Path, PathBuf};
7
8use crate::error::SsgError;
9use staticdatagen::compile;
10
11use crate::cmd::SsgConfig;
12#[cfg(feature = "i18n")]
13use crate::i18n;
14use crate::{
15 accessibility, ai, assets, content, csp, deploy, drafts, highlight,
16 islands, livereload, pagination, plugin, plugins as plugins_mod,
17 postprocess, search, seo, shortcodes, streaming, taxonomy, walk,
18};
19
20#[derive(Debug, Clone, serde::Serialize)]
26#[allow(dead_code)]
27pub struct BuildError {
28 pub file: Option<String>,
30 pub line: Option<usize>,
32 pub message: String,
34}
35
36impl BuildError {
37 #[must_use]
51 #[allow(dead_code)]
52 pub fn from_error(err: &SsgError) -> Self {
53 let message = format!("{err:#}");
54 let file = extract_file_from_error(&message);
55 Self {
56 file,
57 line: None,
58 message,
59 }
60 }
61
62 #[must_use]
75 #[allow(dead_code)]
76 pub fn to_ws_message(&self) -> String {
77 serde_json::json!({
78 "type": "error",
79 "file": self.file,
80 "line": self.line,
81 "message": self.message,
82 })
83 .to_string()
84 }
85}
86
87#[must_use]
97#[allow(dead_code)]
98pub fn clear_error_message() -> String {
99 r#"{"type":"clear-error"}"#.to_string()
100}
101
102#[allow(dead_code)]
105fn extract_file_from_error(msg: &str) -> Option<String> {
106 for word in msg.split_whitespace() {
107 let trimmed = word.trim_matches(|c: char| {
108 !c.is_alphanumeric() && c != '/' && c != '.' && c != '_' && c != '-'
109 });
110 if trimmed.contains('/')
111 && (trimmed.ends_with(".md")
112 || trimmed.ends_with(".html")
113 || trimmed.ends_with(".toml")
114 || trimmed.ends_with(".yml")
115 || trimmed.ends_with(".yaml"))
116 {
117 return Some(trimmed.to_string());
118 }
119 }
120 None
121}
122
123#[derive(Debug, Clone, Default)]
134#[allow(clippy::struct_excessive_bools)]
135pub struct RunOptions {
136 pub quiet: bool,
138 pub include_drafts: bool,
140 pub deploy_target: Option<String>,
142 pub validate_only: bool,
144 pub jobs: Option<usize>,
147 pub max_memory_mb: Option<usize>,
150 #[allow(dead_code)]
152 pub ai_fix: bool,
153 #[allow(dead_code)]
155 pub ai_fix_dry_run: bool,
156 pub incremental: bool,
159 pub no_llm_cache: bool,
166 pub isr: bool,
170}
171
172impl RunOptions {
173 pub fn from_matches(matches: &clap::ArgMatches) -> Self {
186 Self {
187 quiet: matches.get_flag("quiet"),
188 include_drafts: matches.get_flag("drafts"),
189 deploy_target: matches.get_one::<String>("deploy").cloned(),
190 validate_only: matches.get_flag("validate"),
191 jobs: matches.get_one::<usize>("jobs").copied(),
192 max_memory_mb: matches.get_one::<usize>("max-memory").copied(),
193 ai_fix: matches.get_flag("ai-fix"),
194 ai_fix_dry_run: matches.get_flag("ai-fix-dry-run"),
195 incremental: matches
196 .try_contains_id("incremental")
197 .unwrap_or(false)
198 && matches.get_flag("incremental"),
199 no_llm_cache: matches
200 .try_contains_id("no-llm-cache")
201 .unwrap_or(false)
202 && matches.get_flag("no-llm-cache"),
203 isr: matches.try_contains_id("isr").unwrap_or(false)
204 && matches.get_flag("isr"),
205 }
206 }
207
208 pub fn from_subcommand_matches(sub_m: &clap::ArgMatches) -> Self {
227 let opt_flag = |name: &str| -> bool {
228 sub_m.try_contains_id(name).unwrap_or(false) && sub_m.get_flag(name)
229 };
230 let opt_one = |name: &str| -> Option<usize> {
231 if sub_m.try_contains_id(name).unwrap_or(false) {
232 sub_m.get_one::<usize>(name).copied()
233 } else {
234 None
235 }
236 };
237 let opt_str = |name: &str| -> Option<String> {
238 if sub_m.try_contains_id(name).unwrap_or(false) {
239 sub_m.get_one::<String>(name).cloned()
240 } else {
241 None
242 }
243 };
244 Self {
245 quiet: opt_flag("quiet"),
246 include_drafts: opt_flag("drafts"),
247 deploy_target: opt_str("target"),
252 validate_only: false,
253 jobs: opt_one("jobs"),
254 max_memory_mb: opt_one("max-memory"),
255 ai_fix: false,
256 ai_fix_dry_run: false,
257 incremental: opt_flag("incremental"),
258 no_llm_cache: opt_flag("no-llm-cache"),
259 isr: opt_flag("isr"),
260 }
261 }
262}
263
264pub fn resolve_build_and_site_dirs(config: &SsgConfig) -> (PathBuf, PathBuf) {
282 let site_dir = config
283 .serve_dir
284 .clone()
285 .unwrap_or_else(|| config.output_dir.clone());
286
287 let build_dir = if site_dir == config.output_dir {
288 config.output_dir.with_extension("build-tmp")
289 } else {
290 config.output_dir.clone()
291 };
292
293 (build_dir, site_dir)
294}
295
296pub fn build_pipeline(
313 config: &SsgConfig,
314 opts: &RunOptions,
315) -> (
316 plugin::PluginManager,
317 plugin::PluginContext,
318 PathBuf,
319 PathBuf,
320) {
321 let (build_dir, site_dir) = resolve_build_and_site_dirs(config);
322
323 if opts.no_llm_cache {
331 std::env::set_var("SSG_NO_LLM_CACHE", "1");
332 }
333
334 let mut ctx = plugin::PluginContext::with_config(
335 &config.content_dir,
336 &build_dir,
337 &site_dir,
338 &config.template_dir,
339 config.clone(),
340 );
341
342 if let Some(mb) = opts.max_memory_mb {
344 ctx.memory_budget = Some(streaming::MemoryBudget::from_mb(mb));
345 }
346
347 let mut plugins = plugin::PluginManager::new();
348 register_default_plugins(
349 &mut plugins,
350 config,
351 opts.include_drafts,
352 opts.deploy_target.as_deref(),
353 );
354 if opts.isr {
355 register_isr_plugins(&mut plugins);
356 }
357
358 (plugins, ctx, build_dir, site_dir)
359}
360
361pub fn register_isr_plugins(plugins: &mut plugin::PluginManager) {
380 plugins.register(crate::isr_manifest::IsrManifestPlugin::new());
381 plugins.register(crate::rpc_schema::RpcSchemaPlugin::new());
387}
388
389#[cfg_attr(
395 feature = "otel",
396 tracing::instrument(skip(plugins, ctx), fields(
397 content_dir = %content_dir.display(),
398 site_dir = %site_dir.display(),
399 quiet,
400 ))
401)]
402pub fn execute_build_pipeline(
416 plugins: &plugin::PluginManager,
417 ctx: &plugin::PluginContext,
418 build_dir: &Path,
419 content_dir: &Path,
420 site_dir: &Path,
421 template_dir: &Path,
422 quiet: bool,
423) -> Result<(), SsgError> {
424 execute_build_pipeline_with(
425 plugins,
426 ctx,
427 build_dir,
428 content_dir,
429 site_dir,
430 template_dir,
431 quiet,
432 false,
433 )
434}
435
436#[cfg_attr(
451 feature = "otel",
452 tracing::instrument(skip(plugins, ctx), fields(
453 content_dir = %content_dir.display(),
454 site_dir = %site_dir.display(),
455 quiet,
456 incremental,
457 ))
458)]
459pub fn execute_build_pipeline_with(
475 plugins: &plugin::PluginManager,
476 ctx: &plugin::PluginContext,
477 build_dir: &Path,
478 content_dir: &Path,
479 site_dir: &Path,
480 template_dir: &Path,
481 quiet: bool,
482 incremental: bool,
483) -> Result<(), SsgError> {
484 let start = std::time::Instant::now();
485
486 let cache_root = depgraph_cache_root(site_dir);
487
488 let plugin_cache = plugin::PluginCache::load(site_dir);
490 let prev_graph = crate::depgraph::DepGraph::load(&cache_root);
491
492 let mut ctx = ctx.clone();
493 ctx.cache = Some(plugin_cache);
494 ctx.dep_graph = Some(prev_graph.clone());
495
496 if incremental {
502 let current =
503 crate::depgraph::current_hashes(content_dir, template_dir)?;
504 let diff = prev_graph.diff(¤t);
505 if diff.is_empty() && prev_graph.page_count() > 0 && site_dir.exists() {
506 let elapsed = start.elapsed();
507 if !quiet {
508 println!(
509 "Site cached ({} pages, no changes) in {:.2}ms",
510 prev_graph.page_count(),
511 elapsed.as_secs_f64() * 1000.0,
512 );
513 }
514 return Ok(());
515 }
516
517 if !diff.deleted.is_empty() {
520 let stale_outputs = prev_graph.invalidated_outputs(&diff.deleted);
521 for out in &stale_outputs {
522 let _ = std::fs::remove_file(out);
523 }
524 }
525 }
526
527 plugins.run_before_compile(&ctx)?;
528
529 let budget = ctx
532 .memory_budget
533 .unwrap_or_else(streaming::MemoryBudget::default_budget);
534 let explicitly_set = ctx.memory_budget.is_some();
535
536 if streaming::should_stream(content_dir, &budget, explicitly_set) {
537 let batches = streaming::batched_content_files(content_dir, &budget)?;
538 for (i, batch) in batches.iter().enumerate() {
539 streaming::compile_batch(
540 batch,
541 content_dir,
542 build_dir,
543 site_dir,
544 template_dir,
545 i,
546 )?;
547 }
548 } else {
549 let base_url = ctx.config.as_ref().map(|c| c.base_url.clone());
555 let locales = ctx
556 .config
557 .as_ref()
558 .map(SsgConfig::i18n_locales)
559 .unwrap_or_default();
560 compile_site_with_locales(
561 build_dir,
562 content_dir,
563 site_dir,
564 template_dir,
565 base_url.as_deref(),
566 &locales,
567 )?;
568 }
569
570 ctx.cache_html_files();
573
574 plugins.run_after_compile(&ctx)?;
575
576 plugins.run_fused_transforms(&ctx)?;
579
580 let audit_report =
582 crate::plugins_group::audit::AuditPlugin::audit_directory(site_dir);
583 let audit_path = site_dir.join("quality-gate-report.json");
584 if let Ok(json_str) = serde_json::to_string_pretty(&audit_report) {
585 let _ = std::fs::write(&audit_path, json_str);
586 }
587 if audit_report.passed_pillars == audit_report.total_pillars {
588 log::info!(
589 "[audit] Quality Gate: {}/{} pillars passed across {} pages (0 issues)",
590 audit_report.passed_pillars,
591 audit_report.total_pillars,
592 audit_report.pages_scanned
593 );
594 } else {
595 log::warn!(
596 "[audit] Quality Gate: {}/{} pillars passed across {} pages ({} issues)",
597 audit_report.passed_pillars,
598 audit_report.total_pillars,
599 audit_report.pages_scanned,
600 audit_report.total_issues
601 );
602 }
603
604 let mut new_graph = crate::depgraph::DepGraph::new();
607 if let Err(e) = crate::depgraph::populate(
608 &mut new_graph,
609 content_dir,
610 template_dir,
611 site_dir,
612 ) {
613 log::warn!("Failed to populate dependency graph: {e}");
614 }
615
616 if let Err(e) = new_graph.save(&cache_root) {
617 log::warn!("Failed to save dependency graph: {e}");
618 }
619
620 if let Some(ref mut cache) = ctx.cache {
622 if let Ok(files) = walk::walk_files(site_dir, "html") {
623 for file in &files {
624 cache.update(file);
625 }
626 }
627 if let Err(e) = cache.save(site_dir) {
628 log::warn!("Failed to save plugin cache: {e}");
629 }
630 }
631
632 let elapsed = start.elapsed();
633 if !quiet {
634 println!(
635 "Site built in {:.2}s ({} plugin(s))",
636 elapsed.as_secs_f64(),
637 plugins.len()
638 );
639 }
640 Ok(())
641}
642
643#[must_use]
659pub fn depgraph_cache_root(site_dir: &Path) -> PathBuf {
660 let target = Path::new("target");
661 if target.is_dir() {
662 target.join(crate::depgraph::CACHE_DIRNAME)
663 } else {
664 site_dir.join(".ssg-cache")
665 }
666}
667
668pub fn compile_site(
689 build_dir: &Path,
690 content_dir: &Path,
691 site_dir: &Path,
692 template_dir: &Path,
693) -> Result<(), SsgError> {
694 compile_site_with_base_url(
695 build_dir,
696 content_dir,
697 site_dir,
698 template_dir,
699 None,
700 )
701}
702
703pub fn compile_site_with_base_url(
734 build_dir: &Path,
735 content_dir: &Path,
736 site_dir: &Path,
737 template_dir: &Path,
738 base_url: Option<&str>,
739) -> Result<(), SsgError> {
740 compile_site_with_locales(
741 build_dir,
742 content_dir,
743 site_dir,
744 template_dir,
745 base_url,
746 &[],
747 )
748}
749
750pub fn compile_site_with_locales(
756 build_dir: &Path,
757 content_dir: &Path,
758 site_dir: &Path,
759 template_dir: &Path,
760 base_url: Option<&str>,
761 locales: &[String],
762) -> Result<(), SsgError> {
763 let template_vars =
791 crate::content_stager::collect_template_vars(template_dir)
792 .map_err(|e| SsgError::io(e, template_dir))?;
793
794 let staged_content =
795 crate::content_stager::stage_content_with_site_defaults(
796 content_dir,
797 build_dir,
798 &template_vars,
799 base_url,
800 locales,
801 )
802 .map_err(|e| SsgError::io(e, content_dir))?;
803
804 let compiled = compile(build_dir, &staged_content, site_dir, template_dir);
822 let _ = std::fs::remove_dir_all(&staged_content);
827 compiled.map_err(|e| compile_error(&e, build_dir, template_dir))?;
828
829 copy_template_assets(template_dir, site_dir)
830}
831
832fn copy_template_assets(
834 template_dir: &Path,
835 site_dir: &Path,
836) -> Result<(), SsgError> {
837 copy_static_template_assets(template_dir, site_dir)?;
840 if let Some(parent) = template_dir.parent() {
841 let assets_dir = parent.join("assets");
842 if assets_dir.is_dir() {
843 let site_assets = site_dir.join("assets");
844 let _ = std::fs::create_dir_all(&site_assets);
845 copy_static_template_assets(&assets_dir, &site_assets)?;
846 }
847 }
848 Ok(())
849}
850
851fn compile_error(
853 e: &anyhow::Error,
854 build_dir: &Path,
855 template_dir: &Path,
856) -> SsgError {
857 eprintln!(" Error compiling site: {e:?}");
858 let enoent = std::io::Error::from_raw_os_error(2).to_string();
871 let enoent_prose = enoent.split(" (os error").next().unwrap_or(&enoent);
876 let not_found = e.chain().any(|cause| {
877 cause
878 .downcast_ref::<std::io::Error>()
879 .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
880 }) || format!("{e:?}").contains(enoent_prose);
881 if not_found {
882 const USUAL_ROOT_TEMPLATES: [&str; 4] =
883 ["template.html", "index.html", "page.html", "post.html"];
884 let absent: Vec<&str> = USUAL_ROOT_TEMPLATES
885 .iter()
886 .copied()
887 .filter(|name| !template_dir.join(name).is_file())
888 .collect();
889 if !absent.is_empty() {
890 return SsgError::Validation {
891 field: "templates".to_string(),
892 message: format!(
893 "the compile step could not read a template. \
894 {} does not contain {}. A site needs the \
895 templates its content asks for at the template \
896 directory root, plus the MiniJinja set under \
897 `{}`. Run `ssg --new <name>` to scaffold a \
898 project with both, or copy them from \
899 `examples/basic/templates/`.",
900 template_dir.display(),
901 absent.join(", "),
902 template_dir.join("tera").display(),
903 ),
904 };
905 }
906 }
907 SsgError::io(
908 std::io::Error::other(format!("Failed to compile site: {e:?}")),
909 build_dir,
910 )
911}
912
913fn copy_static_template_assets(src: &Path, dst: &Path) -> Result<(), SsgError> {
914 if !src.is_dir() {
915 return Ok(());
916 }
917 let entries = std::fs::read_dir(src).map_err(|e| SsgError::io(e, src))?;
918 for entry in entries.flatten() {
919 let path = entry.path();
920 let name = entry.file_name();
921 let name_str = name.to_string_lossy();
922 if path.is_file() {
923 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
924 if matches!(
925 ext,
926 "css"
927 | "js"
928 | "ico"
929 | "svg"
930 | "png"
931 | "jpg"
932 | "jpeg"
933 | "webp"
934 | "avif"
935 | "woff"
936 | "woff2"
937 | "ttf"
938 | "json"
939 | "map"
940 ) && !name_str.ends_with(".tera.html")
941 {
942 let target = dst.join(name);
943 let _ = std::fs::copy(&path, &target);
944 }
945 } else if path.is_dir()
946 && name_str != "tera"
947 && !name_str.starts_with('.')
948 {
949 let target_dir = dst.join(name);
950 let _ = std::fs::create_dir_all(&target_dir);
951 let _ = copy_static_template_assets(&path, &target_dir);
952 }
953 }
954 Ok(())
955}
956
957pub fn register_default_plugins(
981 plugins: &mut plugin::PluginManager,
982 config: &SsgConfig,
983 include_drafts: bool,
984 deploy_target: Option<&str>,
985) {
986 let base_url = config.base_url.clone();
987
988 plugins.register(content::ContentValidationPlugin);
990 plugins.register(drafts::DraftPlugin::new(include_drafts));
991 plugins.register(shortcodes::ShortcodePlugin);
992
993 #[cfg(feature = "templates")]
995 plugins.register(
996 crate::template_plugin::TemplatePlugin::from_template_dir(
997 &config.template_dir,
998 ),
999 );
1000
1001 plugins.register(postprocess::SitemapFixPlugin);
1004 plugins.register(postprocess::NewsSitemapFixPlugin);
1005 plugins.register(postprocess::RssAggregatePlugin);
1006 plugins.register(postprocess::AtomFeedPlugin);
1007 plugins.register(postprocess::JsonFeedPlugin);
1008 plugins.register(postprocess::ManifestFixPlugin);
1009 plugins.register(postprocess::HtmlFixPlugin);
1010 plugins.register(postprocess::AgenticDiscoveryPlugin);
1022
1023 plugins.register(highlight::HighlightPlugin::default());
1026
1027 plugins.register(seo::SeoPlugin);
1029 plugins
1030 .register(seo::JsonLdPlugin::from_site(&base_url, &config.site_name));
1031 plugins.register(seo::CanonicalPlugin::new(base_url.clone()));
1032 plugins.register(seo::RobotsPlugin::new(base_url));
1033
1034 plugins.register(ai::AiPlugin);
1036
1037 plugins.register(crate::agent_api::AgentApiPlugin::default());
1042
1043 plugins.register(taxonomy::TaxonomyPlugin);
1045 plugins.register(pagination::PaginationPlugin::default());
1046
1047 plugins.register(search::SearchPlugin);
1049
1050 plugins.register(accessibility::AccessibilityPlugin);
1052
1053 plugins.register(crate::plugins_group::audit::AuditPlugin);
1055
1056 #[cfg(feature = "image-optimization")]
1058 plugins.register(crate::image_plugin::ImageOptimizationPlugin::default());
1059
1060 #[cfg(feature = "i18n")]
1062 if let Some(ref i18n_cfg) = config.i18n {
1063 if i18n_cfg.locales.len() > 1 {
1064 plugins.register(i18n::I18nPlugin::new(i18n_cfg.clone()));
1065 }
1066 }
1067
1068 plugins.register(islands::IslandPlugin);
1070
1071 if crate::view_transitions::ViewTransitionsPlugin::enabled(config) {
1075 plugins.register(crate::view_transitions::ViewTransitionsPlugin::new());
1076 }
1077
1078 plugins.register(csp::CspPlugin);
1080
1081 plugins.register(crate::sbom::SbomPlugin);
1085
1086 plugins.register(assets::FingerprintPlugin);
1088
1089 plugins.register(plugins_mod::MinifyPlugin);
1111
1112 plugins.register(postprocess::EdgeHeadersPlugin);
1117
1118 if let Some(target) = deploy_target {
1120 let dt = match target {
1121 "netlify" => Some(deploy::DeployTarget::Netlify),
1122 "vercel" => Some(deploy::DeployTarget::Vercel),
1123 "cloudflare" => Some(deploy::DeployTarget::CloudflarePages),
1124 "github" => Some(deploy::DeployTarget::GithubPages),
1125 _ => {
1126 log::warn!("Unknown deploy target: {target}");
1127 None
1128 }
1129 };
1130 if let Some(dt) = dt {
1131 plugins.register(deploy::DeployPlugin::new(dt));
1132 }
1133 }
1134
1135 plugins.register(livereload::LiveReloadPlugin::default());
1137}
1138
1139#[cfg(test)]
1140mod tests {
1141
1142 #[test]
1147 fn default_plugins_have_no_duplicate_names() {
1148 let config = SsgConfig::default();
1149 let mut plugins = plugin::PluginManager::new();
1150 register_default_plugins(&mut plugins, &config, false, None);
1151
1152 let mut seen = std::collections::BTreeMap::new();
1153 for info in plugins.inventory() {
1154 *seen.entry(info.name).or_insert(0usize) += 1;
1155 }
1156 let dupes: Vec<_> = seen
1157 .iter()
1158 .filter(|(_, n)| **n > 1)
1159 .map(|(k, _)| *k)
1160 .collect();
1161 assert!(dupes.is_empty(), "duplicate plugin names: {dupes:?}");
1162 }
1163
1164 #[test]
1171 fn one_deploy_plugin_registers_when_a_target_is_given() {
1172 let config = SsgConfig::default();
1173
1174 let mut without = plugin::PluginManager::new();
1175 register_default_plugins(&mut without, &config, false, None);
1176 assert!(
1177 !without
1178 .inventory()
1179 .iter()
1180 .any(|p| p.name.contains("deploy")),
1181 "a plain build should register no deploy plugin"
1182 );
1183
1184 let mut with = plugin::PluginManager::new();
1185 register_default_plugins(&mut with, &config, false, Some("netlify"));
1186 let deploy: Vec<_> = with
1187 .inventory()
1188 .into_iter()
1189 .filter(|p| p.name.contains("deploy"))
1190 .collect();
1191 assert_eq!(
1192 deploy.len(),
1193 1,
1194 "expected exactly one deploy plugin, got {deploy:?}"
1195 );
1196 assert_eq!(with.len(), without.len() + 1);
1197 }
1198
1199 #[test]
1203 fn exactly_one_sbom_plugin_is_registered() {
1204 let config = SsgConfig::default();
1205 let mut plugins = plugin::PluginManager::new();
1206 register_default_plugins(&mut plugins, &config, false, None);
1207
1208 let sbom: Vec<_> = plugins
1209 .inventory()
1210 .into_iter()
1211 .filter(|p| p.name.contains("sbom"))
1212 .collect();
1213 assert_eq!(sbom.len(), 1, "expected one SBOM plugin, got {sbom:?}");
1214 assert_eq!(sbom[0].name, "sbom");
1215 }
1216
1217 #[test]
1220 fn inventory_is_in_registration_order() {
1221 let config = SsgConfig::default();
1222 let mut plugins = plugin::PluginManager::new();
1223 register_default_plugins(&mut plugins, &config, false, None);
1224
1225 let inv = plugins.inventory();
1226 assert!(!inv.is_empty(), "should not be empty");
1227 for (i, info) in inv.iter().enumerate() {
1228 assert_eq!(info.order, i);
1229 }
1230 assert_eq!(inv.len(), plugins.len());
1231 }
1232 use super::*;
1233
1234 #[test]
1235 fn test_build_error_serialization() {
1236 let err = BuildError {
1237 file: Some("content/post.md".to_string()),
1238 line: Some(42),
1239 message: "unexpected token".to_string(),
1240 };
1241 let json = err.to_ws_message();
1242 let parsed: serde_json::Value =
1243 serde_json::from_str(&json).expect("valid JSON");
1244 assert_eq!(parsed["type"], "error");
1245 assert_eq!(parsed["file"], "content/post.md");
1246 assert_eq!(parsed["line"], 42);
1247 assert_eq!(parsed["message"], "unexpected token");
1248 }
1249
1250 #[test]
1251 fn test_clear_error_message() {
1252 let msg = clear_error_message();
1253 let parsed: serde_json::Value =
1254 serde_json::from_str(&msg).expect("valid JSON");
1255 assert_eq!(parsed["type"], "clear-error");
1256 }
1257
1258 #[test]
1259 fn test_extract_file_from_error_md() {
1260 let msg = "cannot read content/posts/hello.md: permission denied";
1261 assert_eq!(
1262 extract_file_from_error(msg),
1263 Some("content/posts/hello.md".to_string())
1264 );
1265 }
1266
1267 #[test]
1268 fn test_extract_file_from_error_html() {
1269 let msg = "template error in templates/base.html";
1270 assert_eq!(
1271 extract_file_from_error(msg),
1272 Some("templates/base.html".to_string())
1273 );
1274 }
1275
1276 #[test]
1277 fn test_extract_file_from_error_toml() {
1278 let msg = "parse error in config/site.toml at line 5";
1279 assert_eq!(
1280 extract_file_from_error(msg),
1281 Some("config/site.toml".to_string())
1282 );
1283 }
1284
1285 #[test]
1286 fn test_extract_file_from_error_none() {
1287 let msg = "something went wrong with no file path";
1288 assert_eq!(extract_file_from_error(msg), None);
1289 }
1290
1291 #[test]
1292 fn test_build_error_from_error() {
1293 let err = SsgError::Io {
1294 path: PathBuf::from("output/index.html"),
1295 source: std::io::Error::other("disk full"),
1296 };
1297 let be = BuildError::from_error(&err);
1298 assert_eq!(be.file, Some("output/index.html".to_string()));
1299 assert!(be.line.is_none());
1300 assert!(be.message.contains("disk full"));
1301 }
1302
1303 #[test]
1308 fn test_build_error_no_file_no_line() {
1309 let err = BuildError {
1310 file: None,
1311 line: None,
1312 message: "something broke".to_string(),
1313 };
1314 let json = err.to_ws_message();
1315 let parsed: serde_json::Value =
1316 serde_json::from_str(&json).expect("valid JSON");
1317 assert_eq!(parsed["type"], "error");
1318 assert!(parsed["file"].is_null());
1319 assert!(parsed["line"].is_null());
1320 assert_eq!(parsed["message"], "something broke");
1321 }
1322
1323 #[test]
1324 fn test_build_error_clone() {
1325 let err = BuildError {
1326 file: Some("a/b.md".to_string()),
1327 line: Some(10),
1328 message: "oops".to_string(),
1329 };
1330 let cloned = err.clone();
1331 assert_eq!(cloned.file, err.file);
1332 assert_eq!(cloned.line, err.line);
1333 assert_eq!(cloned.message, err.message);
1334 }
1335
1336 #[test]
1337 fn test_build_error_debug() {
1338 let err = BuildError {
1339 file: None,
1340 line: None,
1341 message: "debug test".to_string(),
1342 };
1343 let debug = format!("{err:?}");
1344 assert!(debug.contains("BuildError"));
1345 assert!(debug.contains("debug test"));
1346 }
1347
1348 #[test]
1349 fn test_build_error_from_error_no_file() {
1350 let err = SsgError::Core(ssg_core::Error::FrontmatterParse {
1351 syntax: "generic error without any file path".to_string(),
1352 });
1353 let be = BuildError::from_error(&err);
1354 assert!(be.file.is_none());
1355 assert!(be.message.contains("generic error"));
1356 }
1357
1358 #[test]
1359 fn test_build_error_from_error_yml_extension() {
1360 let err = SsgError::Io {
1361 path: PathBuf::from("config/site.yml"),
1362 source: std::io::Error::other("parse error"),
1363 };
1364 let be = BuildError::from_error(&err);
1365 assert_eq!(be.file, Some("config/site.yml".to_string()));
1366 }
1367
1368 #[test]
1369 fn test_build_error_from_error_yaml_extension() {
1370 let err = SsgError::Io {
1371 path: PathBuf::from("data/settings.yaml"),
1372 source: std::io::Error::other("error at line 3"),
1373 };
1374 let be = BuildError::from_error(&err);
1375 assert_eq!(be.file, Some("data/settings.yaml".to_string()));
1376 }
1377
1378 #[test]
1383 fn test_extract_file_with_punctuation_around_path() {
1384 let msg = "error: 'templates/base.html' not found";
1385 let result = extract_file_from_error(msg);
1386 assert_eq!(result, Some("templates/base.html".to_string()));
1387 }
1388
1389 #[test]
1390 fn test_extract_file_no_slash_in_word() {
1391 let msg = "file not found: base.html";
1392 let result = extract_file_from_error(msg);
1393 assert!(result.is_none(), "no slash means no file path extraction");
1394 }
1395
1396 #[test]
1397 fn test_extract_file_multiple_paths_returns_first() {
1398 let msg = "failed to read src/a.md and src/b.html";
1399 let result = extract_file_from_error(msg);
1400 assert_eq!(result, Some("src/a.md".to_string()));
1401 }
1402
1403 #[test]
1404 fn test_extract_file_toml_with_trailing_colon() {
1405 let msg = "invalid key in config/site.toml: 'foo'";
1406 let result = extract_file_from_error(msg);
1407 assert_eq!(result, Some("config/site.toml".to_string()));
1408 }
1409
1410 #[test]
1415 fn test_clear_error_message_is_valid_json() {
1416 let msg = clear_error_message();
1417 let parsed: serde_json::Value =
1418 serde_json::from_str(&msg).expect("valid JSON");
1419 assert_eq!(parsed["type"], "clear-error");
1420 assert_eq!(parsed.as_object().unwrap().len(), 1);
1422 }
1423
1424 #[test]
1429 fn test_resolve_dirs_no_serve_dir() {
1430 use crate::cmd::SsgConfig;
1431 use std::path::PathBuf;
1432 let mut config = SsgConfig::default();
1433 config.output_dir = PathBuf::from("out");
1434 config.serve_dir = None;
1435
1436 let (build, site) = resolve_build_and_site_dirs(&config);
1437 assert_eq!(site, PathBuf::from("out"));
1438 assert_ne!(build, site);
1440 }
1441
1442 #[test]
1443 fn test_resolve_dirs_serve_differs_from_output() {
1444 use crate::cmd::SsgConfig;
1445 use std::path::PathBuf;
1446 let mut config = SsgConfig::default();
1447 config.output_dir = PathBuf::from("build");
1448 config.serve_dir = Some(PathBuf::from("public"));
1449
1450 let (build, site) = resolve_build_and_site_dirs(&config);
1451 assert_eq!(build, PathBuf::from("build"));
1452 assert_eq!(site, PathBuf::from("public"));
1453 }
1454
1455 #[test]
1456 fn test_resolve_dirs_serve_equals_output() {
1457 use crate::cmd::SsgConfig;
1458 use std::path::PathBuf;
1459 let mut config = SsgConfig::default();
1460 config.output_dir = PathBuf::from("dist");
1461 config.serve_dir = Some(PathBuf::from("dist"));
1462
1463 let (build, site) = resolve_build_and_site_dirs(&config);
1464 assert_eq!(site, PathBuf::from("dist"));
1465 assert_ne!(build, site);
1466 assert!(build.to_string_lossy().contains("build-tmp"));
1467 }
1468
1469 #[test]
1474 fn test_run_options_defaults() {
1475 use crate::cmd::Cli;
1476 let cli = Cli::build();
1477 let matches = cli.try_get_matches_from(vec!["ssg"]).unwrap();
1478 let opts = RunOptions::from_matches(&matches);
1479
1480 assert!(!opts.quiet);
1481 assert!(!opts.include_drafts);
1482 assert!(opts.deploy_target.is_none());
1483 assert!(!opts.validate_only);
1484 assert!(opts.jobs.is_none());
1485 assert!(opts.max_memory_mb.is_none());
1486 assert!(!opts.ai_fix);
1487 assert!(!opts.ai_fix_dry_run);
1488 }
1489
1490 #[test]
1491 fn test_run_options_ai_fix_flags() {
1492 use crate::cmd::Cli;
1493 let cli = Cli::build();
1494 let matches = cli
1495 .try_get_matches_from(vec!["ssg", "--ai-fix", "--ai-fix-dry-run"])
1496 .unwrap();
1497 let opts = RunOptions::from_matches(&matches);
1498 assert!(opts.ai_fix);
1499 assert!(opts.ai_fix_dry_run);
1500 }
1501
1502 #[test]
1503 fn test_run_options_from_matches_incremental_no_llm_cache_isr_flags() {
1504 use crate::cmd::Cli;
1509 let cli = Cli::build();
1510 let matches = cli
1511 .try_get_matches_from(vec![
1512 "ssg",
1513 "--incremental",
1514 "--no-llm-cache",
1515 "--isr",
1516 ])
1517 .unwrap();
1518 let opts = RunOptions::from_matches(&matches);
1519 assert!(opts.incremental);
1520 assert!(opts.no_llm_cache);
1521 assert!(opts.isr);
1522 }
1523
1524 #[test]
1525 fn test_run_options_debug() {
1526 use crate::cmd::Cli;
1527 let cli = Cli::build();
1528 let matches = cli.try_get_matches_from(vec!["ssg"]).unwrap();
1529 let opts = RunOptions::from_matches(&matches);
1530 let debug = format!("{opts:?}");
1531 assert!(debug.contains("RunOptions"));
1532 assert!(debug.contains("quiet"));
1533 }
1534
1535 #[test]
1536 fn test_run_options_clone() {
1537 use crate::cmd::Cli;
1538 let cli = Cli::build();
1539 let matches = cli
1540 .try_get_matches_from(vec!["ssg", "--quiet", "--jobs", "2"])
1541 .unwrap();
1542 let opts = RunOptions::from_matches(&matches);
1543 let cloned = opts.clone();
1544 assert_eq!(cloned.quiet, opts.quiet);
1545 assert_eq!(cloned.jobs, opts.jobs);
1546 }
1547
1548 #[test]
1553 fn test_register_default_plugins_minimum_count() {
1554 use crate::cmd::SsgConfig;
1555 use crate::plugin::PluginManager;
1556
1557 let config = SsgConfig::default();
1558 let mut pm = PluginManager::new();
1559 register_default_plugins(&mut pm, &config, false, None);
1560
1561 let count = pm.len();
1563 assert!(
1564 count >= 15,
1565 "expected at least 15 default plugins, got {count}"
1566 );
1567 }
1568
1569 #[test]
1570 fn test_register_default_plugins_includes_key_plugins() {
1571 use crate::cmd::SsgConfig;
1572 use crate::plugin::PluginManager;
1573
1574 let config = SsgConfig::default();
1575 let mut pm = PluginManager::new();
1576 register_default_plugins(&mut pm, &config, false, None);
1577
1578 let names = pm.names();
1579 assert!(names.contains(&"content-validation"));
1580 assert!(names.contains(&"drafts"));
1581 assert!(names.contains(&"shortcodes"));
1582 assert!(names.contains(&"seo"));
1583 assert!(names.contains(&"search"));
1584 assert!(names.contains(&"minify"));
1585 assert!(names.contains(&"livereload"));
1586 }
1587
1588 #[test]
1589 fn test_register_default_plugins_with_deploy_adds_deploy_plugin() {
1590 use crate::cmd::SsgConfig;
1591 use crate::plugin::PluginManager;
1592
1593 let config = SsgConfig::default();
1594 let mut pm_without = PluginManager::new();
1595 register_default_plugins(&mut pm_without, &config, false, None);
1596 let count_without = pm_without.len();
1597
1598 let mut pm_with = PluginManager::new();
1599 register_default_plugins(&mut pm_with, &config, false, Some("netlify"));
1600
1601 assert_eq!(pm_with.len(), count_without + 1);
1602 assert!(pm_with.names().contains(&"deploy"));
1603 }
1604
1605 #[test]
1606 fn test_register_default_plugins_unknown_deploy_skipped() {
1607 use crate::cmd::SsgConfig;
1608 use crate::plugin::PluginManager;
1609
1610 let config = SsgConfig::default();
1611 let mut pm = PluginManager::new();
1612 register_default_plugins(
1613 &mut pm,
1614 &config,
1615 false,
1616 Some("nonexistent-platform"),
1617 );
1618
1619 assert!(
1620 !pm.names().contains(&"deploy"),
1621 "unknown deploy target should not register a deploy plugin"
1622 );
1623 }
1624
1625 #[test]
1630 fn test_build_pipeline_returns_valid_dirs() {
1631 use crate::cmd::SsgConfig;
1632
1633 let temp = tempfile::tempdir().unwrap();
1634 let mut config = SsgConfig::default();
1635 config.content_dir = temp.path().join("content");
1636 config.output_dir = temp.path().join("public");
1637 config.template_dir = temp.path().join("templates");
1638
1639 let opts = RunOptions {
1640 quiet: true,
1641 include_drafts: false,
1642 deploy_target: None,
1643 validate_only: false,
1644 jobs: None,
1645 max_memory_mb: None,
1646 ai_fix: false,
1647 ai_fix_dry_run: false,
1648 incremental: false,
1649 no_llm_cache: false,
1650 isr: false,
1651 };
1652
1653 let (plugins, ctx, build_dir, site_dir) =
1654 build_pipeline(&config, &opts);
1655
1656 assert!(!plugins.is_empty());
1657 assert_ne!(build_dir, site_dir);
1658 assert_eq!(ctx.content_dir, temp.path().join("content"));
1659 }
1660
1661 #[test]
1666 fn test_run_options_from_subcommand_reads_max_memory() {
1667 use crate::cmd::Cli;
1668 let matches = Cli::subcommand_app().get_matches_from(vec![
1669 "ssg",
1670 "build",
1671 "--max-memory",
1672 "64",
1673 ]);
1674 let sub_m = matches.subcommand_matches("build").unwrap();
1675 let opts = RunOptions::from_subcommand_matches(sub_m);
1676 assert_eq!(opts.max_memory_mb, Some(64));
1677 }
1678
1679 #[test]
1684 fn test_build_pipeline_no_llm_cache_exports_env_flag() {
1685 use std::sync::Mutex;
1687 static ENV_LOCK: Mutex<()> = Mutex::new(());
1688 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1689 let prev = std::env::var("SSG_NO_LLM_CACHE").ok();
1690 std::env::remove_var("SSG_NO_LLM_CACHE");
1691
1692 let config = SsgConfig::default();
1693 let opts = RunOptions {
1694 no_llm_cache: true,
1695 ..RunOptions::default()
1696 };
1697 let (plugins, _ctx, _build, _site) = build_pipeline(&config, &opts);
1698 let seen = std::env::var("SSG_NO_LLM_CACHE").ok();
1699
1700 match prev {
1702 Some(v) => std::env::set_var("SSG_NO_LLM_CACHE", v),
1703 None => std::env::remove_var("SSG_NO_LLM_CACHE"),
1704 }
1705 assert_eq!(seen.as_deref(), Some("1"));
1706 assert!(!plugins.is_empty());
1707 }
1708
1709 #[test]
1710 fn test_register_isr_plugins_appends_isr_pair() {
1711 use crate::plugin::PluginManager;
1712 let mut pm = PluginManager::new();
1713 register_isr_plugins(&mut pm);
1714 assert_eq!(pm.len(), 2, "ISR manifest + RPC schema plugins");
1715 }
1716
1717 #[test]
1718 fn test_build_pipeline_isr_flag_appends_plugins() {
1719 let config = SsgConfig::default();
1720 let base = build_pipeline(&config, &RunOptions::default()).0.len();
1721 let opts = RunOptions {
1722 isr: true,
1723 ..RunOptions::default()
1724 };
1725 let with_isr = build_pipeline(&config, &opts).0.len();
1726 assert_eq!(with_isr, base + 2);
1727 }
1728
1729 #[cfg(feature = "i18n")]
1734 #[test]
1735 fn test_register_default_plugins_multi_locale_adds_i18n() {
1736 use crate::plugin::PluginManager;
1737 let mut config = SsgConfig::default();
1738 config.i18n = Some(i18n::I18nConfig {
1739 default_locale: "en".to_string(),
1740 locales: vec!["en".to_string(), "fr".to_string()],
1741 url_prefix: Default::default(),
1742 });
1743
1744 let mut pm = PluginManager::new();
1745 register_default_plugins(&mut pm, &config, false, None);
1746 assert!(
1747 pm.names().contains(&"i18n"),
1748 "two locales must register the i18n plugin: {:?}",
1749 pm.names()
1750 );
1751 }
1752
1753 #[cfg(feature = "i18n")]
1754 #[test]
1755 fn test_register_default_plugins_single_locale_skips_i18n() {
1756 use crate::plugin::PluginManager;
1757 let mut config = SsgConfig::default();
1758 config.i18n = Some(i18n::I18nConfig::default());
1759
1760 let mut pm = PluginManager::new();
1761 register_default_plugins(&mut pm, &config, false, None);
1762 assert!(!pm.names().contains(&"i18n"));
1763 }
1764
1765 #[test]
1766 fn test_register_default_plugins_transitions_opt_in() {
1767 use crate::plugin::PluginManager;
1768 let mut config = SsgConfig::default();
1769 config.transitions = true;
1770
1771 let mut pm = PluginManager::new();
1772 register_default_plugins(&mut pm, &config, false, None);
1773 assert!(pm.names().contains(&"view-transitions"));
1774 }
1775
1776 #[test]
1781 #[serial_test::serial(cwd)]
1782 fn test_depgraph_cache_root_falls_back_without_target_dir() {
1783 let tmp = tempfile::tempdir().unwrap();
1786 let prev = std::env::current_dir().expect("read current dir");
1787 std::env::set_current_dir(tmp.path()).expect("pushd");
1788
1789 let root = depgraph_cache_root(Path::new("/tmp/site"));
1790
1791 std::env::set_current_dir(&prev).expect("popd");
1792 assert_eq!(root, Path::new("/tmp/site").join(".ssg-cache"));
1793 }
1794
1795 #[test]
1800 #[cfg(unix)]
1801 fn test_compile_maps_unreadable_template_dir_to_io_error() {
1802 use std::os::unix::fs::PermissionsExt;
1803 let tmp = tempfile::tempdir().unwrap();
1804 let content = tmp.path().join("content");
1805 let build = tmp.path().join("build");
1806 let site = tmp.path().join("public");
1807 let templates = tmp.path().join("templates");
1808 std::fs::create_dir_all(&content).unwrap();
1809 std::fs::create_dir_all(&templates).unwrap();
1810 std::fs::set_permissions(
1811 &templates,
1812 std::fs::Permissions::from_mode(0o000),
1813 )
1814 .unwrap();
1815
1816 let res = compile_site_with_base_url(
1817 &build, &content, &site, &templates, None,
1818 );
1819
1820 let _ = std::fs::set_permissions(
1821 &templates,
1822 std::fs::Permissions::from_mode(0o755),
1823 );
1824 assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1826 }
1827
1828 fn build_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf, PathBuf)
1836 {
1837 crate::test_support::init_logger();
1838 let tmp = tempfile::tempdir().expect("tempdir");
1839 let content = tmp.path().join("content");
1840 let build = tmp.path().join("build");
1841 let site = tmp.path().join("public");
1842 let templates = tmp.path().join("templates");
1843 std::fs::create_dir_all(&content).expect("mkdir content");
1844 std::fs::create_dir_all(&templates).expect("mkdir templates");
1845 std::fs::create_dir_all(&build).expect("mkdir build");
1846 std::fs::write(
1847 content.join("index.md"),
1848 "---\ntitle: \"Home\"\ndescription: \"home\"\n\
1849 permalink: \"https://example.com/\"\n---\nhome body",
1850 )
1851 .expect("write index.md");
1852 std::fs::write(
1853 content.join("about.md"),
1854 "---\ntitle: \"About\"\ndescription: \"about\"\n\
1855 permalink: \"https://example.com/about/\"\n---\nabout body",
1856 )
1857 .expect("write about.md");
1858 std::fs::write(
1859 templates.join("page.html"),
1860 "<!doctype html><html><body>{{ content }}</body></html>",
1861 )
1862 .expect("write template");
1863 (tmp, content, build, site, templates)
1864 }
1865
1866 #[test]
1870 fn compile_removes_its_staged_content_after_a_build() {
1871 let (_tmp, content, build, site, templates) = build_fixture();
1872 let staging =
1873 crate::content_stager::staging_root_for("content", &build);
1874
1875 compile_site(&build, &content, &site, &templates).expect("compile");
1876
1877 assert!(site.join("index.html").is_file(), "the build ran");
1878 assert!(
1879 !staging.exists(),
1880 "staged content left behind at {}",
1881 staging.display()
1882 );
1883 }
1884
1885 #[test]
1887 fn compile_removes_its_staged_content_after_a_failed_build() {
1888 let (_tmp, content, build, site, templates) = build_fixture();
1889 let staging =
1890 crate::content_stager::staging_root_for("content", &build);
1891 std::fs::remove_file(templates.join("page.html")).expect("rm");
1892
1893 let res = compile_site(&build, &content, &site, &templates);
1894
1895 assert!(res.is_err(), "a build with no template must fail");
1896 assert!(
1897 !staging.exists(),
1898 "staged content left behind at {}",
1899 staging.display()
1900 );
1901 }
1902
1903 #[derive(Debug)]
1905 struct FailingPlugin {
1906 phase: &'static str,
1907 }
1908
1909 impl plugin::Plugin for FailingPlugin {
1910 fn name(&self) -> &'static str {
1911 "failing-test-plugin"
1912 }
1913 fn before_compile(
1914 &self,
1915 _ctx: &plugin::PluginContext,
1916 ) -> Result<(), SsgError> {
1917 if self.phase == "before" {
1918 return Err(SsgError::Validation {
1919 field: "test".to_string(),
1920 message: "injected before_compile failure".to_string(),
1921 });
1922 }
1923 Ok(())
1924 }
1925 fn after_compile(
1926 &self,
1927 _ctx: &plugin::PluginContext,
1928 ) -> Result<(), SsgError> {
1929 if self.phase == "after" {
1930 return Err(SsgError::Validation {
1931 field: "test".to_string(),
1932 message: "injected after_compile failure".to_string(),
1933 });
1934 }
1935 Ok(())
1936 }
1937 fn has_transform(&self) -> bool {
1938 self.phase == "transform"
1939 }
1940 fn transform_html(
1941 &self,
1942 _html: &str,
1943 _path: &Path,
1944 _ctx: &plugin::PluginContext,
1945 ) -> Result<String, SsgError> {
1946 Err(SsgError::Validation {
1947 field: "test".to_string(),
1948 message: "injected transform failure".to_string(),
1949 })
1950 }
1951 }
1952
1953 #[derive(Debug)]
1958 struct SabotagePlugin {
1959 mode: &'static str,
1960 }
1961
1962 impl plugin::Plugin for SabotagePlugin {
1963 fn name(&self) -> &'static str {
1964 "sabotage-test-plugin"
1965 }
1966 fn after_compile(
1967 &self,
1968 ctx: &plugin::PluginContext,
1969 ) -> Result<(), SsgError> {
1970 if self.mode == "block-plugin-cache" {
1971 let _ = std::fs::create_dir_all(
1972 ctx.site_dir.join(".ssg-plugin-cache.json"),
1973 );
1974 }
1975 #[cfg(unix)]
1976 if self.mode == "lock-subdir" {
1977 use std::os::unix::fs::PermissionsExt;
1978 let locked = ctx.site_dir.join("locked");
1979 let _ = std::fs::create_dir_all(&locked);
1980 let _ = std::fs::set_permissions(
1981 &locked,
1982 std::fs::Permissions::from_mode(0o000),
1983 );
1984 }
1985 Ok(())
1986 }
1987 }
1988
1989 fn run_fixture_with_plugins(
1990 pm: &plugin::PluginManager,
1991 incremental: bool,
1992 ) -> (tempfile::TempDir, PathBuf, Result<(), SsgError>) {
1993 let (tmp, content, build, site, templates) = build_fixture();
1994 let ctx =
1995 plugin::PluginContext::new(&content, &build, &site, &templates);
1996 let res = execute_build_pipeline_with(
1997 pm,
1998 &ctx,
1999 &build,
2000 &content,
2001 &site,
2002 &templates,
2003 true,
2004 incremental,
2005 );
2006 (tmp, site, res)
2007 }
2008
2009 #[test]
2010 fn test_pipeline_propagates_before_compile_failure() {
2011 let mut pm = plugin::PluginManager::new();
2012 pm.register(FailingPlugin { phase: "before" });
2013 let (_tmp, _site, res) = run_fixture_with_plugins(&pm, false);
2014 assert!(res.is_err());
2015 }
2016
2017 #[test]
2018 #[serial_test::parallel(stager_fp)]
2019 fn test_pipeline_propagates_after_compile_failure() {
2020 let mut pm = plugin::PluginManager::new();
2021 pm.register(FailingPlugin { phase: "after" });
2022 let (_tmp, _site, res) = run_fixture_with_plugins(&pm, false);
2023 assert!(res.is_err());
2024 }
2025
2026 #[test]
2027 #[serial_test::parallel(stager_fp)]
2028 fn test_pipeline_propagates_transform_failure() {
2029 let mut pm = plugin::PluginManager::new();
2030 pm.register(FailingPlugin { phase: "transform" });
2031 let (_tmp, _site, res) = run_fixture_with_plugins(&pm, false);
2032 assert!(res.is_err());
2033 }
2034
2035 #[test]
2036 #[serial_test::serial(ssg_cache, stager_fp)]
2037 fn test_pipeline_streams_when_budget_explicitly_set() {
2038 let (_tmp, content, build, site, templates) = build_fixture();
2039 let mut ctx =
2040 plugin::PluginContext::new(&content, &build, &site, &templates);
2041 ctx.memory_budget = Some(streaming::MemoryBudget::from_mb(1));
2043
2044 let pm = plugin::PluginManager::new();
2045 execute_build_pipeline_with(
2046 &pm, &ctx, &build, &content, &site, &templates, true, false,
2047 )
2048 .expect("streamed build should succeed");
2049
2050 assert!(
2051 site.join("about").join("index.html").exists(),
2052 "batched compile must emit the page outputs"
2053 );
2054 }
2055
2056 #[test]
2057 #[serial_test::serial(cwd, ssg_cache, stager_fp)]
2058 fn test_pipeline_incremental_fast_path_and_delete_sweep() {
2059 let (_tmp, content, build, site, templates) = build_fixture();
2060 let ctx =
2061 plugin::PluginContext::new(&content, &build, &site, &templates);
2062 let pm = plugin::PluginManager::new();
2063
2064 let cache_root = depgraph_cache_root(&site);
2066 let _ = std::fs::remove_file(
2067 cache_root.join(crate::depgraph::DEP_GRAPH_FILE),
2068 );
2069
2070 execute_build_pipeline_with(
2072 &pm, &ctx, &build, &content, &site, &templates, false, true,
2073 )
2074 .expect("cold incremental build should succeed");
2075 let about_out = site.join("about").join("index.html");
2076 assert!(about_out.exists());
2077
2078 std::fs::write(&about_out, "MARKER").unwrap();
2082 execute_build_pipeline_with(
2083 &pm, &ctx, &build, &content, &site, &templates, false, true,
2084 )
2085 .expect("warm incremental build should succeed");
2086 assert_eq!(
2087 std::fs::read_to_string(&about_out).unwrap(),
2088 "MARKER",
2089 "fast path must not recompile unchanged sources"
2090 );
2091
2092 std::fs::remove_file(content.join("about.md")).unwrap();
2095 execute_build_pipeline_with(
2096 &pm, &ctx, &build, &content, &site, &templates, false, true,
2097 )
2098 .expect("incremental rebuild after delete should succeed");
2099 assert!(!about_out.exists(), "deleted source's output must be swept");
2100 }
2101
2102 #[test]
2103 #[cfg(unix)]
2104 #[serial_test::serial(ssg_cache, stager_fp)]
2105 fn test_pipeline_warns_but_succeeds_when_populate_fails() {
2106 let (_tmp, content, build, site, templates) = build_fixture();
2110 std::os::unix::fs::symlink(
2111 content.join("nowhere.md"),
2112 content.join("ghost.md"),
2113 )
2114 .unwrap();
2115 let ctx =
2116 plugin::PluginContext::new(&content, &build, &site, &templates);
2117 let pm = plugin::PluginManager::new();
2118
2119 execute_build_pipeline_with(
2120 &pm, &ctx, &build, &content, &site, &templates, true, false,
2121 )
2122 .expect("populate failure must be non-fatal");
2123 }
2124
2125 #[test]
2126 #[serial_test::serial(cwd, ssg_cache, stager_fp)]
2127 fn test_pipeline_warns_but_succeeds_when_graph_save_fails() {
2128 let (_tmp, content, build, site, templates) = build_fixture();
2131 let ctx =
2132 plugin::PluginContext::new(&content, &build, &site, &templates);
2133 let pm = plugin::PluginManager::new();
2134
2135 let cwd_tmp = tempfile::tempdir().expect("cwd tempdir");
2148 std::fs::create_dir_all(cwd_tmp.path().join("target"))
2149 .expect("create target dir");
2150 let prev_cwd = std::env::current_dir().expect("read current dir");
2151 std::env::set_current_dir(cwd_tmp.path()).expect("pushd");
2152
2153 let cache_root = depgraph_cache_root(&site);
2154 let blocker =
2155 cache_root.join(format!("{}.tmp", crate::depgraph::DEP_GRAPH_FILE));
2156 std::fs::create_dir_all(&blocker).unwrap();
2157 std::fs::write(blocker.join("keep.txt"), "x").unwrap();
2158 assert!(
2159 !blocker.starts_with(&site),
2160 "cache root must sit outside the site dir the build clears"
2161 );
2162
2163 let res = execute_build_pipeline_with(
2164 &pm, &ctx, &build, &content, &site, &templates, true, false,
2165 );
2166
2167 let blocked = blocker.is_dir();
2168 let _ = std::fs::remove_dir_all(&blocker);
2169 std::env::set_current_dir(&prev_cwd).expect("popd");
2170 res.expect("graph-save failure must be non-fatal");
2171 assert!(blocked, "blocker must have survived the build");
2172 }
2173
2174 #[test]
2175 #[serial_test::serial(ssg_cache, stager_fp)]
2176 fn test_pipeline_warns_but_succeeds_when_plugin_cache_save_fails() {
2177 let mut pm = plugin::PluginManager::new();
2181 pm.register(SabotagePlugin {
2182 mode: "block-plugin-cache",
2183 });
2184 let (_tmp, site, res) = run_fixture_with_plugins(&pm, false);
2185 res.expect("plugin-cache save failure must be non-fatal");
2186 assert!(
2187 site.join(".ssg-plugin-cache.json").is_dir(),
2188 "blocker must be present for the warn arm to have fired"
2189 );
2190 }
2191
2192 #[test]
2193 #[serial_test::serial(ssg_cache, stager_fp)]
2194 fn test_execute_build_pipeline_with_config_derives_base_url_for_non_streaming_compile(
2195 ) {
2196 use crate::cmd::SsgConfig;
2203 let (_tmp, content, build, site, templates) = build_fixture();
2204 let config = SsgConfig {
2205 base_url: "https://example.com".to_string(),
2206 ..SsgConfig::default()
2207 };
2208 let ctx = plugin::PluginContext::with_config(
2209 &content, &build, &site, &templates, config,
2210 );
2211 let pm = plugin::PluginManager::new();
2212 execute_build_pipeline_with(
2213 &pm, &ctx, &build, &content, &site, &templates, true, false,
2214 )
2215 .expect("build with a configured base_url should succeed");
2216 assert!(
2217 site.join("about").join("index.html").exists(),
2218 "compile must still emit page outputs when config carries a base_url"
2219 );
2220 }
2221
2222 #[test]
2223 #[cfg(unix)]
2224 #[serial_test::serial(cwd, ssg_cache, stager_fp)]
2225 fn test_pipeline_incremental_propagates_current_hashes_failure() {
2226 use std::os::unix::fs::PermissionsExt;
2233 let (_tmp, content, build, site, templates) = build_fixture();
2234 let ctx =
2235 plugin::PluginContext::new(&content, &build, &site, &templates);
2236 let pm = plugin::PluginManager::new();
2237
2238 std::fs::set_permissions(
2239 &content,
2240 std::fs::Permissions::from_mode(0o000),
2241 )
2242 .unwrap();
2243
2244 let res = execute_build_pipeline_with(
2245 &pm, &ctx, &build, &content, &site, &templates, true, true,
2246 );
2247
2248 let _ = std::fs::set_permissions(
2249 &content,
2250 std::fs::Permissions::from_mode(0o755),
2251 );
2252 assert!(
2253 res.is_err(),
2254 "unreadable content_dir must fail current_hashes and propagate"
2255 );
2256 }
2257
2258 #[test]
2259 #[cfg(unix)]
2260 #[serial_test::serial(ssg_cache, stager_fp)]
2261 fn test_pipeline_tolerates_unwalkable_site_dir() {
2262 use std::os::unix::fs::PermissionsExt;
2267 let mut pm = plugin::PluginManager::new();
2268 pm.register(SabotagePlugin {
2269 mode: "lock-subdir",
2270 });
2271 let (_tmp, site, res) = run_fixture_with_plugins(&pm, false);
2272
2273 let locked = site.join("locked");
2274 let was_locked = locked.is_dir();
2275 let _ = std::fs::set_permissions(
2276 &locked,
2277 std::fs::Permissions::from_mode(0o755),
2278 );
2279 res.expect("unwalkable site dir must be non-fatal");
2280 assert!(was_locked, "sabotage dir must have survived the build");
2281 }
2282}