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 compile(build_dir, &staged_content, site_dir, template_dir).map_err(
822 |e| {
823 eprintln!(" Error compiling site: {e:?}");
824 let enoent = std::io::Error::from_raw_os_error(2).to_string();
837 let enoent_prose =
842 enoent.split(" (os error").next().unwrap_or(&enoent);
843 let not_found = e.chain().any(|cause| {
844 cause
845 .downcast_ref::<std::io::Error>()
846 .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
847 }) || format!("{e:?}").contains(enoent_prose);
848 if not_found {
849 const USUAL_ROOT_TEMPLATES: [&str; 4] =
850 ["template.html", "index.html", "page.html", "post.html"];
851 let absent: Vec<&str> = USUAL_ROOT_TEMPLATES
852 .iter()
853 .copied()
854 .filter(|name| !template_dir.join(name).is_file())
855 .collect();
856 if !absent.is_empty() {
857 return SsgError::Validation {
858 field: "templates".to_string(),
859 message: format!(
860 "the compile step could not read a template. \
861 {} does not contain {}. A site needs the \
862 templates its content asks for at the template \
863 directory root, plus the MiniJinja set under \
864 `{}`. Run `ssg --new <name>` to scaffold a \
865 project with both, or copy them from \
866 `examples/basic/templates/`.",
867 template_dir.display(),
868 absent.join(", "),
869 template_dir.join("tera").display(),
870 ),
871 };
872 }
873 }
874 SsgError::io(
875 std::io::Error::other(format!("Failed to compile site: {e:?}")),
876 build_dir,
877 )
878 },
879 )?;
880
881 copy_static_template_assets(template_dir, site_dir)?;
884 if let Some(parent) = template_dir.parent() {
885 let assets_dir = parent.join("assets");
886 if assets_dir.is_dir() {
887 let site_assets = site_dir.join("assets");
888 let _ = std::fs::create_dir_all(&site_assets);
889 copy_static_template_assets(&assets_dir, &site_assets)?;
890 }
891 }
892 Ok(())
893}
894
895fn copy_static_template_assets(src: &Path, dst: &Path) -> Result<(), SsgError> {
896 if !src.is_dir() {
897 return Ok(());
898 }
899 let entries = std::fs::read_dir(src).map_err(|e| SsgError::io(e, src))?;
900 for entry in entries.flatten() {
901 let path = entry.path();
902 let name = entry.file_name();
903 let name_str = name.to_string_lossy();
904 if path.is_file() {
905 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
906 if matches!(
907 ext,
908 "css"
909 | "js"
910 | "ico"
911 | "svg"
912 | "png"
913 | "jpg"
914 | "jpeg"
915 | "webp"
916 | "avif"
917 | "woff"
918 | "woff2"
919 | "ttf"
920 | "json"
921 | "map"
922 ) && !name_str.ends_with(".tera.html")
923 {
924 let target = dst.join(name);
925 let _ = std::fs::copy(&path, &target);
926 }
927 } else if path.is_dir()
928 && name_str != "tera"
929 && !name_str.starts_with('.')
930 {
931 let target_dir = dst.join(name);
932 let _ = std::fs::create_dir_all(&target_dir);
933 let _ = copy_static_template_assets(&path, &target_dir);
934 }
935 }
936 Ok(())
937}
938
939pub fn register_default_plugins(
963 plugins: &mut plugin::PluginManager,
964 config: &SsgConfig,
965 include_drafts: bool,
966 deploy_target: Option<&str>,
967) {
968 let base_url = config.base_url.clone();
969
970 plugins.register(content::ContentValidationPlugin);
972 plugins.register(drafts::DraftPlugin::new(include_drafts));
973 plugins.register(shortcodes::ShortcodePlugin);
974
975 #[cfg(feature = "templates")]
977 plugins.register(
978 crate::template_plugin::TemplatePlugin::from_template_dir(
979 &config.template_dir,
980 ),
981 );
982
983 plugins.register(postprocess::SitemapFixPlugin);
986 plugins.register(postprocess::NewsSitemapFixPlugin);
987 plugins.register(postprocess::RssAggregatePlugin);
988 plugins.register(postprocess::AtomFeedPlugin);
989 plugins.register(postprocess::JsonFeedPlugin);
990 plugins.register(postprocess::ManifestFixPlugin);
991 plugins.register(postprocess::HtmlFixPlugin);
992 plugins.register(postprocess::AgenticDiscoveryPlugin);
1003
1004 plugins.register(highlight::HighlightPlugin::default());
1006
1007 plugins.register(seo::SeoPlugin);
1009 plugins
1010 .register(seo::JsonLdPlugin::from_site(&base_url, &config.site_name));
1011 plugins.register(seo::CanonicalPlugin::new(base_url.clone()));
1012 plugins.register(seo::RobotsPlugin::new(base_url));
1013
1014 plugins.register(ai::AiPlugin);
1016
1017 plugins.register(crate::agent_api::AgentApiPlugin::default());
1022
1023 plugins.register(taxonomy::TaxonomyPlugin);
1025 plugins.register(pagination::PaginationPlugin::default());
1026
1027 plugins.register(search::SearchPlugin);
1029
1030 plugins.register(accessibility::AccessibilityPlugin);
1032
1033 plugins.register(crate::plugins_group::audit::AuditPlugin);
1035
1036 #[cfg(feature = "image-optimization")]
1038 plugins.register(crate::image_plugin::ImageOptimizationPlugin::default());
1039
1040 #[cfg(feature = "i18n")]
1042 if let Some(ref i18n_cfg) = config.i18n {
1043 if i18n_cfg.locales.len() > 1 {
1044 plugins.register(i18n::I18nPlugin::new(i18n_cfg.clone()));
1045 }
1046 }
1047
1048 plugins.register(islands::IslandPlugin);
1050
1051 if crate::view_transitions::ViewTransitionsPlugin::enabled(config) {
1055 plugins.register(crate::view_transitions::ViewTransitionsPlugin::new());
1056 }
1057
1058 plugins.register(csp::CspPlugin);
1060
1061 plugins.register(crate::sbom::SbomPlugin);
1065
1066 plugins.register(assets::FingerprintPlugin);
1068
1069 plugins.register(plugins_mod::MinifyPlugin);
1091
1092 plugins.register(postprocess::EdgeHeadersPlugin);
1097
1098 if let Some(target) = deploy_target {
1100 let dt = match target {
1101 "netlify" => Some(deploy::DeployTarget::Netlify),
1102 "vercel" => Some(deploy::DeployTarget::Vercel),
1103 "cloudflare" => Some(deploy::DeployTarget::CloudflarePages),
1104 "github" => Some(deploy::DeployTarget::GithubPages),
1105 _ => {
1106 log::warn!("Unknown deploy target: {target}");
1107 None
1108 }
1109 };
1110 if let Some(dt) = dt {
1111 plugins.register(deploy::DeployPlugin::new(dt));
1112 }
1113 }
1114
1115 plugins.register(livereload::LiveReloadPlugin::default());
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121
1122 #[test]
1127 fn default_plugins_have_no_duplicate_names() {
1128 let config = SsgConfig::default();
1129 let mut plugins = plugin::PluginManager::new();
1130 register_default_plugins(&mut plugins, &config, false, None);
1131
1132 let mut seen = std::collections::BTreeMap::new();
1133 for info in plugins.inventory() {
1134 *seen.entry(info.name).or_insert(0usize) += 1;
1135 }
1136 let dupes: Vec<_> = seen
1137 .iter()
1138 .filter(|(_, n)| **n > 1)
1139 .map(|(k, _)| *k)
1140 .collect();
1141 assert!(dupes.is_empty(), "duplicate plugin names: {dupes:?}");
1142 }
1143
1144 #[test]
1151 fn one_deploy_plugin_registers_when_a_target_is_given() {
1152 let config = SsgConfig::default();
1153
1154 let mut without = plugin::PluginManager::new();
1155 register_default_plugins(&mut without, &config, false, None);
1156 assert!(
1157 !without
1158 .inventory()
1159 .iter()
1160 .any(|p| p.name.contains("deploy")),
1161 "a plain build should register no deploy plugin"
1162 );
1163
1164 let mut with = plugin::PluginManager::new();
1165 register_default_plugins(&mut with, &config, false, Some("netlify"));
1166 let deploy: Vec<_> = with
1167 .inventory()
1168 .into_iter()
1169 .filter(|p| p.name.contains("deploy"))
1170 .collect();
1171 assert_eq!(
1172 deploy.len(),
1173 1,
1174 "expected exactly one deploy plugin, got {deploy:?}"
1175 );
1176 assert_eq!(with.len(), without.len() + 1);
1177 }
1178
1179 #[test]
1182 fn exactly_one_sbom_plugin_is_registered() {
1183 let config = SsgConfig::default();
1184 let mut plugins = plugin::PluginManager::new();
1185 register_default_plugins(&mut plugins, &config, false, None);
1186
1187 let sbom: Vec<_> = plugins
1188 .inventory()
1189 .into_iter()
1190 .filter(|p| p.name.contains("sbom"))
1191 .collect();
1192 assert_eq!(sbom.len(), 1, "expected one SBOM plugin, got {sbom:?}");
1193 assert_eq!(sbom[0].name, "sbom");
1194 }
1195
1196 #[test]
1199 fn inventory_is_in_registration_order() {
1200 let config = SsgConfig::default();
1201 let mut plugins = plugin::PluginManager::new();
1202 register_default_plugins(&mut plugins, &config, false, None);
1203
1204 let inv = plugins.inventory();
1205 assert!(!inv.is_empty());
1206 for (i, info) in inv.iter().enumerate() {
1207 assert_eq!(info.order, i);
1208 }
1209 assert_eq!(inv.len(), plugins.len());
1210 }
1211 use super::*;
1212
1213 #[test]
1214 fn test_build_error_serialization() {
1215 let err = BuildError {
1216 file: Some("content/post.md".to_string()),
1217 line: Some(42),
1218 message: "unexpected token".to_string(),
1219 };
1220 let json = err.to_ws_message();
1221 let parsed: serde_json::Value =
1222 serde_json::from_str(&json).expect("valid JSON");
1223 assert_eq!(parsed["type"], "error");
1224 assert_eq!(parsed["file"], "content/post.md");
1225 assert_eq!(parsed["line"], 42);
1226 assert_eq!(parsed["message"], "unexpected token");
1227 }
1228
1229 #[test]
1230 fn test_clear_error_message() {
1231 let msg = clear_error_message();
1232 let parsed: serde_json::Value =
1233 serde_json::from_str(&msg).expect("valid JSON");
1234 assert_eq!(parsed["type"], "clear-error");
1235 }
1236
1237 #[test]
1238 fn test_extract_file_from_error_md() {
1239 let msg = "cannot read content/posts/hello.md: permission denied";
1240 assert_eq!(
1241 extract_file_from_error(msg),
1242 Some("content/posts/hello.md".to_string())
1243 );
1244 }
1245
1246 #[test]
1247 fn test_extract_file_from_error_html() {
1248 let msg = "template error in templates/base.html";
1249 assert_eq!(
1250 extract_file_from_error(msg),
1251 Some("templates/base.html".to_string())
1252 );
1253 }
1254
1255 #[test]
1256 fn test_extract_file_from_error_toml() {
1257 let msg = "parse error in config/site.toml at line 5";
1258 assert_eq!(
1259 extract_file_from_error(msg),
1260 Some("config/site.toml".to_string())
1261 );
1262 }
1263
1264 #[test]
1265 fn test_extract_file_from_error_none() {
1266 let msg = "something went wrong with no file path";
1267 assert_eq!(extract_file_from_error(msg), None);
1268 }
1269
1270 #[test]
1271 fn test_build_error_from_error() {
1272 let err = SsgError::Io {
1273 path: PathBuf::from("output/index.html"),
1274 source: std::io::Error::other("disk full"),
1275 };
1276 let be = BuildError::from_error(&err);
1277 assert_eq!(be.file, Some("output/index.html".to_string()));
1278 assert!(be.line.is_none());
1279 assert!(be.message.contains("disk full"));
1280 }
1281
1282 #[test]
1287 fn test_build_error_no_file_no_line() {
1288 let err = BuildError {
1289 file: None,
1290 line: None,
1291 message: "something broke".to_string(),
1292 };
1293 let json = err.to_ws_message();
1294 let parsed: serde_json::Value =
1295 serde_json::from_str(&json).expect("valid JSON");
1296 assert_eq!(parsed["type"], "error");
1297 assert!(parsed["file"].is_null());
1298 assert!(parsed["line"].is_null());
1299 assert_eq!(parsed["message"], "something broke");
1300 }
1301
1302 #[test]
1303 fn test_build_error_clone() {
1304 let err = BuildError {
1305 file: Some("a/b.md".to_string()),
1306 line: Some(10),
1307 message: "oops".to_string(),
1308 };
1309 let cloned = err.clone();
1310 assert_eq!(cloned.file, err.file);
1311 assert_eq!(cloned.line, err.line);
1312 assert_eq!(cloned.message, err.message);
1313 }
1314
1315 #[test]
1316 fn test_build_error_debug() {
1317 let err = BuildError {
1318 file: None,
1319 line: None,
1320 message: "debug test".to_string(),
1321 };
1322 let debug = format!("{err:?}");
1323 assert!(debug.contains("BuildError"));
1324 assert!(debug.contains("debug test"));
1325 }
1326
1327 #[test]
1328 fn test_build_error_from_error_no_file() {
1329 let err = SsgError::Core(ssg_core::Error::FrontmatterParse {
1330 syntax: "generic error without any file path".to_string(),
1331 });
1332 let be = BuildError::from_error(&err);
1333 assert!(be.file.is_none());
1334 assert!(be.message.contains("generic error"));
1335 }
1336
1337 #[test]
1338 fn test_build_error_from_error_yml_extension() {
1339 let err = SsgError::Io {
1340 path: PathBuf::from("config/site.yml"),
1341 source: std::io::Error::other("parse error"),
1342 };
1343 let be = BuildError::from_error(&err);
1344 assert_eq!(be.file, Some("config/site.yml".to_string()));
1345 }
1346
1347 #[test]
1348 fn test_build_error_from_error_yaml_extension() {
1349 let err = SsgError::Io {
1350 path: PathBuf::from("data/settings.yaml"),
1351 source: std::io::Error::other("error at line 3"),
1352 };
1353 let be = BuildError::from_error(&err);
1354 assert_eq!(be.file, Some("data/settings.yaml".to_string()));
1355 }
1356
1357 #[test]
1362 fn test_extract_file_with_punctuation_around_path() {
1363 let msg = "error: 'templates/base.html' not found";
1364 let result = extract_file_from_error(msg);
1365 assert_eq!(result, Some("templates/base.html".to_string()));
1366 }
1367
1368 #[test]
1369 fn test_extract_file_no_slash_in_word() {
1370 let msg = "file not found: base.html";
1371 let result = extract_file_from_error(msg);
1372 assert!(result.is_none(), "no slash means no file path extraction");
1373 }
1374
1375 #[test]
1376 fn test_extract_file_multiple_paths_returns_first() {
1377 let msg = "failed to read src/a.md and src/b.html";
1378 let result = extract_file_from_error(msg);
1379 assert_eq!(result, Some("src/a.md".to_string()));
1380 }
1381
1382 #[test]
1383 fn test_extract_file_toml_with_trailing_colon() {
1384 let msg = "invalid key in config/site.toml: 'foo'";
1385 let result = extract_file_from_error(msg);
1386 assert_eq!(result, Some("config/site.toml".to_string()));
1387 }
1388
1389 #[test]
1394 fn test_clear_error_message_is_valid_json() {
1395 let msg = clear_error_message();
1396 let parsed: serde_json::Value =
1397 serde_json::from_str(&msg).expect("valid JSON");
1398 assert_eq!(parsed["type"], "clear-error");
1399 assert_eq!(parsed.as_object().unwrap().len(), 1);
1401 }
1402
1403 #[test]
1408 fn test_resolve_dirs_no_serve_dir() {
1409 use crate::cmd::SsgConfig;
1410 use std::path::PathBuf;
1411 let mut config = SsgConfig::default();
1412 config.output_dir = PathBuf::from("out");
1413 config.serve_dir = None;
1414
1415 let (build, site) = resolve_build_and_site_dirs(&config);
1416 assert_eq!(site, PathBuf::from("out"));
1417 assert_ne!(build, site);
1419 }
1420
1421 #[test]
1422 fn test_resolve_dirs_serve_differs_from_output() {
1423 use crate::cmd::SsgConfig;
1424 use std::path::PathBuf;
1425 let mut config = SsgConfig::default();
1426 config.output_dir = PathBuf::from("build");
1427 config.serve_dir = Some(PathBuf::from("public"));
1428
1429 let (build, site) = resolve_build_and_site_dirs(&config);
1430 assert_eq!(build, PathBuf::from("build"));
1431 assert_eq!(site, PathBuf::from("public"));
1432 }
1433
1434 #[test]
1435 fn test_resolve_dirs_serve_equals_output() {
1436 use crate::cmd::SsgConfig;
1437 use std::path::PathBuf;
1438 let mut config = SsgConfig::default();
1439 config.output_dir = PathBuf::from("dist");
1440 config.serve_dir = Some(PathBuf::from("dist"));
1441
1442 let (build, site) = resolve_build_and_site_dirs(&config);
1443 assert_eq!(site, PathBuf::from("dist"));
1444 assert_ne!(build, site);
1445 assert!(build.to_string_lossy().contains("build-tmp"));
1446 }
1447
1448 #[test]
1453 fn test_run_options_defaults() {
1454 use crate::cmd::Cli;
1455 let cli = Cli::build();
1456 let matches = cli.try_get_matches_from(vec!["ssg"]).unwrap();
1457 let opts = RunOptions::from_matches(&matches);
1458
1459 assert!(!opts.quiet);
1460 assert!(!opts.include_drafts);
1461 assert!(opts.deploy_target.is_none());
1462 assert!(!opts.validate_only);
1463 assert!(opts.jobs.is_none());
1464 assert!(opts.max_memory_mb.is_none());
1465 assert!(!opts.ai_fix);
1466 assert!(!opts.ai_fix_dry_run);
1467 }
1468
1469 #[test]
1470 fn test_run_options_ai_fix_flags() {
1471 use crate::cmd::Cli;
1472 let cli = Cli::build();
1473 let matches = cli
1474 .try_get_matches_from(vec!["ssg", "--ai-fix", "--ai-fix-dry-run"])
1475 .unwrap();
1476 let opts = RunOptions::from_matches(&matches);
1477 assert!(opts.ai_fix);
1478 assert!(opts.ai_fix_dry_run);
1479 }
1480
1481 #[test]
1482 fn test_run_options_from_matches_incremental_no_llm_cache_isr_flags() {
1483 use crate::cmd::Cli;
1488 let cli = Cli::build();
1489 let matches = cli
1490 .try_get_matches_from(vec![
1491 "ssg",
1492 "--incremental",
1493 "--no-llm-cache",
1494 "--isr",
1495 ])
1496 .unwrap();
1497 let opts = RunOptions::from_matches(&matches);
1498 assert!(opts.incremental);
1499 assert!(opts.no_llm_cache);
1500 assert!(opts.isr);
1501 }
1502
1503 #[test]
1504 fn test_run_options_debug() {
1505 use crate::cmd::Cli;
1506 let cli = Cli::build();
1507 let matches = cli.try_get_matches_from(vec!["ssg"]).unwrap();
1508 let opts = RunOptions::from_matches(&matches);
1509 let debug = format!("{opts:?}");
1510 assert!(debug.contains("RunOptions"));
1511 assert!(debug.contains("quiet"));
1512 }
1513
1514 #[test]
1515 fn test_run_options_clone() {
1516 use crate::cmd::Cli;
1517 let cli = Cli::build();
1518 let matches = cli
1519 .try_get_matches_from(vec!["ssg", "--quiet", "--jobs", "2"])
1520 .unwrap();
1521 let opts = RunOptions::from_matches(&matches);
1522 let cloned = opts.clone();
1523 assert_eq!(cloned.quiet, opts.quiet);
1524 assert_eq!(cloned.jobs, opts.jobs);
1525 }
1526
1527 #[test]
1532 fn test_register_default_plugins_minimum_count() {
1533 use crate::cmd::SsgConfig;
1534 use crate::plugin::PluginManager;
1535
1536 let config = SsgConfig::default();
1537 let mut pm = PluginManager::new();
1538 register_default_plugins(&mut pm, &config, false, None);
1539
1540 let count = pm.len();
1542 assert!(
1543 count >= 15,
1544 "expected at least 15 default plugins, got {count}"
1545 );
1546 }
1547
1548 #[test]
1549 fn test_register_default_plugins_includes_key_plugins() {
1550 use crate::cmd::SsgConfig;
1551 use crate::plugin::PluginManager;
1552
1553 let config = SsgConfig::default();
1554 let mut pm = PluginManager::new();
1555 register_default_plugins(&mut pm, &config, false, None);
1556
1557 let names = pm.names();
1558 assert!(names.contains(&"content-validation"));
1559 assert!(names.contains(&"drafts"));
1560 assert!(names.contains(&"shortcodes"));
1561 assert!(names.contains(&"seo"));
1562 assert!(names.contains(&"search"));
1563 assert!(names.contains(&"minify"));
1564 assert!(names.contains(&"livereload"));
1565 }
1566
1567 #[test]
1568 fn test_register_default_plugins_with_deploy_adds_deploy_plugin() {
1569 use crate::cmd::SsgConfig;
1570 use crate::plugin::PluginManager;
1571
1572 let config = SsgConfig::default();
1573 let mut pm_without = PluginManager::new();
1574 register_default_plugins(&mut pm_without, &config, false, None);
1575 let count_without = pm_without.len();
1576
1577 let mut pm_with = PluginManager::new();
1578 register_default_plugins(&mut pm_with, &config, false, Some("netlify"));
1579
1580 assert_eq!(pm_with.len(), count_without + 1);
1581 assert!(pm_with.names().contains(&"deploy"));
1582 }
1583
1584 #[test]
1585 fn test_register_default_plugins_unknown_deploy_skipped() {
1586 use crate::cmd::SsgConfig;
1587 use crate::plugin::PluginManager;
1588
1589 let config = SsgConfig::default();
1590 let mut pm = PluginManager::new();
1591 register_default_plugins(
1592 &mut pm,
1593 &config,
1594 false,
1595 Some("nonexistent-platform"),
1596 );
1597
1598 assert!(
1599 !pm.names().contains(&"deploy"),
1600 "unknown deploy target should not register a deploy plugin"
1601 );
1602 }
1603
1604 #[test]
1609 fn test_build_pipeline_returns_valid_dirs() {
1610 use crate::cmd::SsgConfig;
1611
1612 let temp = tempfile::tempdir().unwrap();
1613 let mut config = SsgConfig::default();
1614 config.content_dir = temp.path().join("content");
1615 config.output_dir = temp.path().join("public");
1616 config.template_dir = temp.path().join("templates");
1617
1618 let opts = RunOptions {
1619 quiet: true,
1620 include_drafts: false,
1621 deploy_target: None,
1622 validate_only: false,
1623 jobs: None,
1624 max_memory_mb: None,
1625 ai_fix: false,
1626 ai_fix_dry_run: false,
1627 incremental: false,
1628 no_llm_cache: false,
1629 isr: false,
1630 };
1631
1632 let (plugins, ctx, build_dir, site_dir) =
1633 build_pipeline(&config, &opts);
1634
1635 assert!(!plugins.is_empty());
1636 assert_ne!(build_dir, site_dir);
1637 assert_eq!(ctx.content_dir, temp.path().join("content"));
1638 }
1639
1640 #[test]
1645 fn test_run_options_from_subcommand_reads_max_memory() {
1646 use crate::cmd::Cli;
1647 let matches = Cli::subcommand_app().get_matches_from(vec![
1648 "ssg",
1649 "build",
1650 "--max-memory",
1651 "64",
1652 ]);
1653 let sub_m = matches.subcommand_matches("build").unwrap();
1654 let opts = RunOptions::from_subcommand_matches(sub_m);
1655 assert_eq!(opts.max_memory_mb, Some(64));
1656 }
1657
1658 #[test]
1663 fn test_build_pipeline_no_llm_cache_exports_env_flag() {
1664 use std::sync::Mutex;
1666 static ENV_LOCK: Mutex<()> = Mutex::new(());
1667 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1668 let prev = std::env::var("SSG_NO_LLM_CACHE").ok();
1669 std::env::remove_var("SSG_NO_LLM_CACHE");
1670
1671 let config = SsgConfig::default();
1672 let opts = RunOptions {
1673 no_llm_cache: true,
1674 ..RunOptions::default()
1675 };
1676 let (plugins, _ctx, _build, _site) = build_pipeline(&config, &opts);
1677 let seen = std::env::var("SSG_NO_LLM_CACHE").ok();
1678
1679 match prev {
1681 Some(v) => std::env::set_var("SSG_NO_LLM_CACHE", v),
1682 None => std::env::remove_var("SSG_NO_LLM_CACHE"),
1683 }
1684 assert_eq!(seen.as_deref(), Some("1"));
1685 assert!(!plugins.is_empty());
1686 }
1687
1688 #[test]
1689 fn test_register_isr_plugins_appends_isr_pair() {
1690 use crate::plugin::PluginManager;
1691 let mut pm = PluginManager::new();
1692 register_isr_plugins(&mut pm);
1693 assert_eq!(pm.len(), 2, "ISR manifest + RPC schema plugins");
1694 }
1695
1696 #[test]
1697 fn test_build_pipeline_isr_flag_appends_plugins() {
1698 let config = SsgConfig::default();
1699 let base = build_pipeline(&config, &RunOptions::default()).0.len();
1700 let opts = RunOptions {
1701 isr: true,
1702 ..RunOptions::default()
1703 };
1704 let with_isr = build_pipeline(&config, &opts).0.len();
1705 assert_eq!(with_isr, base + 2);
1706 }
1707
1708 #[cfg(feature = "i18n")]
1713 #[test]
1714 fn test_register_default_plugins_multi_locale_adds_i18n() {
1715 use crate::plugin::PluginManager;
1716 let mut config = SsgConfig::default();
1717 config.i18n = Some(i18n::I18nConfig {
1718 default_locale: "en".to_string(),
1719 locales: vec!["en".to_string(), "fr".to_string()],
1720 url_prefix: Default::default(),
1721 });
1722
1723 let mut pm = PluginManager::new();
1724 register_default_plugins(&mut pm, &config, false, None);
1725 assert!(
1726 pm.names().contains(&"i18n"),
1727 "two locales must register the i18n plugin: {:?}",
1728 pm.names()
1729 );
1730 }
1731
1732 #[cfg(feature = "i18n")]
1733 #[test]
1734 fn test_register_default_plugins_single_locale_skips_i18n() {
1735 use crate::plugin::PluginManager;
1736 let mut config = SsgConfig::default();
1737 config.i18n = Some(i18n::I18nConfig::default());
1738
1739 let mut pm = PluginManager::new();
1740 register_default_plugins(&mut pm, &config, false, None);
1741 assert!(!pm.names().contains(&"i18n"));
1742 }
1743
1744 #[test]
1745 fn test_register_default_plugins_transitions_opt_in() {
1746 use crate::plugin::PluginManager;
1747 let mut config = SsgConfig::default();
1748 config.transitions = true;
1749
1750 let mut pm = PluginManager::new();
1751 register_default_plugins(&mut pm, &config, false, None);
1752 assert!(pm.names().contains(&"view-transitions"));
1753 }
1754
1755 #[test]
1760 #[serial_test::serial(cwd)]
1761 fn test_depgraph_cache_root_falls_back_without_target_dir() {
1762 let tmp = tempfile::tempdir().unwrap();
1765 let prev = std::env::current_dir().expect("read current dir");
1766 std::env::set_current_dir(tmp.path()).expect("pushd");
1767
1768 let root = depgraph_cache_root(Path::new("/tmp/site"));
1769
1770 std::env::set_current_dir(&prev).expect("popd");
1771 assert_eq!(root, Path::new("/tmp/site").join(".ssg-cache"));
1772 }
1773
1774 #[test]
1779 #[cfg(unix)]
1780 fn test_compile_maps_unreadable_template_dir_to_io_error() {
1781 use std::os::unix::fs::PermissionsExt;
1782 let tmp = tempfile::tempdir().unwrap();
1783 let content = tmp.path().join("content");
1784 let build = tmp.path().join("build");
1785 let site = tmp.path().join("public");
1786 let templates = tmp.path().join("templates");
1787 std::fs::create_dir_all(&content).unwrap();
1788 std::fs::create_dir_all(&templates).unwrap();
1789 std::fs::set_permissions(
1790 &templates,
1791 std::fs::Permissions::from_mode(0o000),
1792 )
1793 .unwrap();
1794
1795 let res = compile_site_with_base_url(
1796 &build, &content, &site, &templates, None,
1797 );
1798
1799 let _ = std::fs::set_permissions(
1800 &templates,
1801 std::fs::Permissions::from_mode(0o755),
1802 );
1803 assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1805 }
1806
1807 fn build_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf, PathBuf)
1815 {
1816 crate::test_support::init_logger();
1817 let tmp = tempfile::tempdir().expect("tempdir");
1818 let content = tmp.path().join("content");
1819 let build = tmp.path().join("build");
1820 let site = tmp.path().join("public");
1821 let templates = tmp.path().join("templates");
1822 std::fs::create_dir_all(&content).expect("mkdir content");
1823 std::fs::create_dir_all(&templates).expect("mkdir templates");
1824 std::fs::create_dir_all(&build).expect("mkdir build");
1825 std::fs::write(
1826 content.join("index.md"),
1827 "---\ntitle: \"Home\"\ndescription: \"home\"\n\
1828 permalink: \"https://example.com/\"\n---\nhome body",
1829 )
1830 .expect("write index.md");
1831 std::fs::write(
1832 content.join("about.md"),
1833 "---\ntitle: \"About\"\ndescription: \"about\"\n\
1834 permalink: \"https://example.com/about/\"\n---\nabout body",
1835 )
1836 .expect("write about.md");
1837 std::fs::write(
1838 templates.join("page.html"),
1839 "<!doctype html><html><body>{{ content }}</body></html>",
1840 )
1841 .expect("write template");
1842 (tmp, content, build, site, templates)
1843 }
1844
1845 #[derive(Debug)]
1847 struct FailingPlugin {
1848 phase: &'static str,
1849 }
1850
1851 impl plugin::Plugin for FailingPlugin {
1852 fn name(&self) -> &'static str {
1853 "failing-test-plugin"
1854 }
1855 fn before_compile(
1856 &self,
1857 _ctx: &plugin::PluginContext,
1858 ) -> Result<(), SsgError> {
1859 if self.phase == "before" {
1860 return Err(SsgError::Validation {
1861 field: "test".to_string(),
1862 message: "injected before_compile failure".to_string(),
1863 });
1864 }
1865 Ok(())
1866 }
1867 fn after_compile(
1868 &self,
1869 _ctx: &plugin::PluginContext,
1870 ) -> Result<(), SsgError> {
1871 if self.phase == "after" {
1872 return Err(SsgError::Validation {
1873 field: "test".to_string(),
1874 message: "injected after_compile failure".to_string(),
1875 });
1876 }
1877 Ok(())
1878 }
1879 fn has_transform(&self) -> bool {
1880 self.phase == "transform"
1881 }
1882 fn transform_html(
1883 &self,
1884 _html: &str,
1885 _path: &Path,
1886 _ctx: &plugin::PluginContext,
1887 ) -> Result<String, SsgError> {
1888 Err(SsgError::Validation {
1889 field: "test".to_string(),
1890 message: "injected transform failure".to_string(),
1891 })
1892 }
1893 }
1894
1895 #[derive(Debug)]
1900 struct SabotagePlugin {
1901 mode: &'static str,
1902 }
1903
1904 impl plugin::Plugin for SabotagePlugin {
1905 fn name(&self) -> &'static str {
1906 "sabotage-test-plugin"
1907 }
1908 fn after_compile(
1909 &self,
1910 ctx: &plugin::PluginContext,
1911 ) -> Result<(), SsgError> {
1912 if self.mode == "block-plugin-cache" {
1913 let _ = std::fs::create_dir_all(
1914 ctx.site_dir.join(".ssg-plugin-cache.json"),
1915 );
1916 }
1917 #[cfg(unix)]
1918 if self.mode == "lock-subdir" {
1919 use std::os::unix::fs::PermissionsExt;
1920 let locked = ctx.site_dir.join("locked");
1921 let _ = std::fs::create_dir_all(&locked);
1922 let _ = std::fs::set_permissions(
1923 &locked,
1924 std::fs::Permissions::from_mode(0o000),
1925 );
1926 }
1927 Ok(())
1928 }
1929 }
1930
1931 fn run_fixture_with_plugins(
1932 pm: &plugin::PluginManager,
1933 incremental: bool,
1934 ) -> (tempfile::TempDir, PathBuf, Result<(), SsgError>) {
1935 let (tmp, content, build, site, templates) = build_fixture();
1936 let ctx =
1937 plugin::PluginContext::new(&content, &build, &site, &templates);
1938 let res = execute_build_pipeline_with(
1939 pm,
1940 &ctx,
1941 &build,
1942 &content,
1943 &site,
1944 &templates,
1945 true,
1946 incremental,
1947 );
1948 (tmp, site, res)
1949 }
1950
1951 #[test]
1952 fn test_pipeline_propagates_before_compile_failure() {
1953 let mut pm = plugin::PluginManager::new();
1954 pm.register(FailingPlugin { phase: "before" });
1955 let (_tmp, _site, res) = run_fixture_with_plugins(&pm, false);
1956 assert!(res.is_err());
1957 }
1958
1959 #[test]
1960 #[serial_test::parallel(stager_fp)]
1961 fn test_pipeline_propagates_after_compile_failure() {
1962 let mut pm = plugin::PluginManager::new();
1963 pm.register(FailingPlugin { phase: "after" });
1964 let (_tmp, _site, res) = run_fixture_with_plugins(&pm, false);
1965 assert!(res.is_err());
1966 }
1967
1968 #[test]
1969 #[serial_test::parallel(stager_fp)]
1970 fn test_pipeline_propagates_transform_failure() {
1971 let mut pm = plugin::PluginManager::new();
1972 pm.register(FailingPlugin { phase: "transform" });
1973 let (_tmp, _site, res) = run_fixture_with_plugins(&pm, false);
1974 assert!(res.is_err());
1975 }
1976
1977 #[test]
1978 #[serial_test::serial(ssg_cache, stager_fp)]
1979 fn test_pipeline_streams_when_budget_explicitly_set() {
1980 let (_tmp, content, build, site, templates) = build_fixture();
1981 let mut ctx =
1982 plugin::PluginContext::new(&content, &build, &site, &templates);
1983 ctx.memory_budget = Some(streaming::MemoryBudget::from_mb(1));
1985
1986 let pm = plugin::PluginManager::new();
1987 execute_build_pipeline_with(
1988 &pm, &ctx, &build, &content, &site, &templates, true, false,
1989 )
1990 .expect("streamed build should succeed");
1991
1992 assert!(
1993 site.join("about").join("index.html").exists(),
1994 "batched compile must emit the page outputs"
1995 );
1996 }
1997
1998 #[test]
1999 #[serial_test::serial(cwd, ssg_cache, stager_fp)]
2000 fn test_pipeline_incremental_fast_path_and_delete_sweep() {
2001 let (_tmp, content, build, site, templates) = build_fixture();
2002 let ctx =
2003 plugin::PluginContext::new(&content, &build, &site, &templates);
2004 let pm = plugin::PluginManager::new();
2005
2006 let cache_root = depgraph_cache_root(&site);
2008 let _ = std::fs::remove_file(
2009 cache_root.join(crate::depgraph::DEP_GRAPH_FILE),
2010 );
2011
2012 execute_build_pipeline_with(
2014 &pm, &ctx, &build, &content, &site, &templates, false, true,
2015 )
2016 .expect("cold incremental build should succeed");
2017 let about_out = site.join("about").join("index.html");
2018 assert!(about_out.exists());
2019
2020 std::fs::write(&about_out, "MARKER").unwrap();
2024 execute_build_pipeline_with(
2025 &pm, &ctx, &build, &content, &site, &templates, false, true,
2026 )
2027 .expect("warm incremental build should succeed");
2028 assert_eq!(
2029 std::fs::read_to_string(&about_out).unwrap(),
2030 "MARKER",
2031 "fast path must not recompile unchanged sources"
2032 );
2033
2034 std::fs::remove_file(content.join("about.md")).unwrap();
2037 execute_build_pipeline_with(
2038 &pm, &ctx, &build, &content, &site, &templates, false, true,
2039 )
2040 .expect("incremental rebuild after delete should succeed");
2041 assert!(!about_out.exists(), "deleted source's output must be swept");
2042 }
2043
2044 #[test]
2045 #[cfg(unix)]
2046 #[serial_test::serial(ssg_cache, stager_fp)]
2047 fn test_pipeline_warns_but_succeeds_when_populate_fails() {
2048 let (_tmp, content, build, site, templates) = build_fixture();
2052 std::os::unix::fs::symlink(
2053 content.join("nowhere.md"),
2054 content.join("ghost.md"),
2055 )
2056 .unwrap();
2057 let ctx =
2058 plugin::PluginContext::new(&content, &build, &site, &templates);
2059 let pm = plugin::PluginManager::new();
2060
2061 execute_build_pipeline_with(
2062 &pm, &ctx, &build, &content, &site, &templates, true, false,
2063 )
2064 .expect("populate failure must be non-fatal");
2065 }
2066
2067 #[test]
2068 #[serial_test::serial(cwd, ssg_cache, stager_fp)]
2069 fn test_pipeline_warns_but_succeeds_when_graph_save_fails() {
2070 let (_tmp, content, build, site, templates) = build_fixture();
2073 let ctx =
2074 plugin::PluginContext::new(&content, &build, &site, &templates);
2075 let pm = plugin::PluginManager::new();
2076
2077 let cwd_tmp = tempfile::tempdir().expect("cwd tempdir");
2090 std::fs::create_dir_all(cwd_tmp.path().join("target"))
2091 .expect("create target dir");
2092 let prev_cwd = std::env::current_dir().expect("read current dir");
2093 std::env::set_current_dir(cwd_tmp.path()).expect("pushd");
2094
2095 let cache_root = depgraph_cache_root(&site);
2096 let blocker =
2097 cache_root.join(format!("{}.tmp", crate::depgraph::DEP_GRAPH_FILE));
2098 std::fs::create_dir_all(&blocker).unwrap();
2099 std::fs::write(blocker.join("keep.txt"), "x").unwrap();
2100 assert!(
2101 !blocker.starts_with(&site),
2102 "cache root must sit outside the site dir the build clears"
2103 );
2104
2105 let res = execute_build_pipeline_with(
2106 &pm, &ctx, &build, &content, &site, &templates, true, false,
2107 );
2108
2109 let blocked = blocker.is_dir();
2110 let _ = std::fs::remove_dir_all(&blocker);
2111 std::env::set_current_dir(&prev_cwd).expect("popd");
2112 res.expect("graph-save failure must be non-fatal");
2113 assert!(blocked, "blocker must have survived the build");
2114 }
2115
2116 #[test]
2117 #[serial_test::serial(ssg_cache, stager_fp)]
2118 fn test_pipeline_warns_but_succeeds_when_plugin_cache_save_fails() {
2119 let mut pm = plugin::PluginManager::new();
2123 pm.register(SabotagePlugin {
2124 mode: "block-plugin-cache",
2125 });
2126 let (_tmp, site, res) = run_fixture_with_plugins(&pm, false);
2127 res.expect("plugin-cache save failure must be non-fatal");
2128 assert!(
2129 site.join(".ssg-plugin-cache.json").is_dir(),
2130 "blocker must be present for the warn arm to have fired"
2131 );
2132 }
2133
2134 #[test]
2135 #[serial_test::serial(ssg_cache, stager_fp)]
2136 fn test_execute_build_pipeline_with_config_derives_base_url_for_non_streaming_compile(
2137 ) {
2138 use crate::cmd::SsgConfig;
2145 let (_tmp, content, build, site, templates) = build_fixture();
2146 let config = SsgConfig {
2147 base_url: "https://example.com".to_string(),
2148 ..SsgConfig::default()
2149 };
2150 let ctx = plugin::PluginContext::with_config(
2151 &content, &build, &site, &templates, config,
2152 );
2153 let pm = plugin::PluginManager::new();
2154 execute_build_pipeline_with(
2155 &pm, &ctx, &build, &content, &site, &templates, true, false,
2156 )
2157 .expect("build with a configured base_url should succeed");
2158 assert!(
2159 site.join("about").join("index.html").exists(),
2160 "compile must still emit page outputs when config carries a base_url"
2161 );
2162 }
2163
2164 #[test]
2165 #[cfg(unix)]
2166 #[serial_test::serial(cwd, ssg_cache, stager_fp)]
2167 fn test_pipeline_incremental_propagates_current_hashes_failure() {
2168 use std::os::unix::fs::PermissionsExt;
2175 let (_tmp, content, build, site, templates) = build_fixture();
2176 let ctx =
2177 plugin::PluginContext::new(&content, &build, &site, &templates);
2178 let pm = plugin::PluginManager::new();
2179
2180 std::fs::set_permissions(
2181 &content,
2182 std::fs::Permissions::from_mode(0o000),
2183 )
2184 .unwrap();
2185
2186 let res = execute_build_pipeline_with(
2187 &pm, &ctx, &build, &content, &site, &templates, true, true,
2188 );
2189
2190 let _ = std::fs::set_permissions(
2191 &content,
2192 std::fs::Permissions::from_mode(0o755),
2193 );
2194 assert!(
2195 res.is_err(),
2196 "unreadable content_dir must fail current_hashes and propagate"
2197 );
2198 }
2199
2200 #[test]
2201 #[cfg(unix)]
2202 #[serial_test::serial(ssg_cache, stager_fp)]
2203 fn test_pipeline_tolerates_unwalkable_site_dir() {
2204 use std::os::unix::fs::PermissionsExt;
2209 let mut pm = plugin::PluginManager::new();
2210 pm.register(SabotagePlugin {
2211 mode: "lock-subdir",
2212 });
2213 let (_tmp, site, res) = run_fixture_with_plugins(&pm, false);
2214
2215 let locked = site.join("locked");
2216 let was_locked = locked.is_dir();
2217 let _ = std::fs::set_permissions(
2218 &locked,
2219 std::fs::Permissions::from_mode(0o755),
2220 );
2221 res.expect("unwalkable site dir must be non-fatal");
2222 assert!(was_locked, "sabotage dir must have survived the build");
2223 }
2224}