Skip to main content

ssg/core/
pipeline.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Build pipeline: plugin orchestration and site compilation.
5
6use 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// ---------------------------------------------------------------------------
21// BuildError — serialisable build error for browser overlay delivery
22// ---------------------------------------------------------------------------
23
24/// Serialisable build error for browser overlay delivery.
25#[derive(Debug, Clone, serde::Serialize)]
26#[allow(dead_code)]
27pub struct BuildError {
28    /// Source file path (if extractable from the error chain).
29    pub file: Option<String>,
30    /// Line number (if extractable).
31    pub line: Option<usize>,
32    /// Human-readable error message.
33    pub message: String,
34}
35
36impl BuildError {
37    /// Creates a `BuildError` from an `SsgError` error, attempting to extract
38    /// file path and line number from the error chain.
39    ///
40    /// # Examples
41    ///
42    /// ```rust
43    /// use ssg::pipeline::BuildError;
44    /// use ssg::SsgError;
45    ///
46    /// let err = SsgError::Validation { field: "x".into(), message: "nope".into() };
47    /// let be = BuildError::from_error(&err);
48    /// assert!(be.message.contains("nope"));
49    /// ```
50    #[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    /// Serializes to a WebSocket JSON message.
63    ///
64    /// # Examples
65    ///
66    /// ```rust
67    /// use ssg::pipeline::BuildError;
68    ///
69    /// let be = BuildError { file: None, line: None, message: "boom".into() };
70    /// let msg = be.to_ws_message();
71    /// assert!(msg.contains("\"type\":\"error\""));
72    /// assert!(msg.contains("boom"));
73    /// ```
74    #[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/// Returns the JSON message to clear the error overlay.
88///
89/// # Examples
90///
91/// ```rust
92/// use ssg::pipeline::clear_error_message;
93///
94/// assert!(clear_error_message().contains("clear-error"));
95/// ```
96#[must_use]
97#[allow(dead_code)]
98pub fn clear_error_message() -> String {
99    r#"{"type":"clear-error"}"#.to_string()
100}
101
102/// Extracts a file path from an error message by scanning for path-like
103/// tokens ending in known extensions.
104#[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/// CLI-driven options that don't live in `SsgConfig` itself.
124///
125/// Extracted from clap matches so the run pipeline can be unit-tested
126/// without going through `Cli::build()`. **Internal**: this is a
127/// CLI-implementation type, not part of the library surface. The
128/// containing module is `pub(crate)`, so this `pub` is effectively
129/// crate-local — clippy's `redundant_pub_crate` flagged the prior
130/// `pub(crate)` here. See
131/// [API stability audit](../../docs/architecture/api-stability-audit.md)
132/// (Tier C) for context.
133#[derive(Debug, Clone, Default)]
134#[allow(clippy::struct_excessive_bools)]
135pub struct RunOptions {
136    /// Suppress banner and timing print-outs.
137    pub quiet: bool,
138    /// Include draft files (skip the `DraftPlugin` filter).
139    pub include_drafts: bool,
140    /// Optional deploy target — `netlify`, `vercel`, `cloudflare`, `github`.
141    pub deploy_target: Option<String>,
142    /// Validate content schemas only (no build).
143    pub validate_only: bool,
144    /// Number of parallel threads for Rayon (`--jobs`).
145    /// `None` means use all available CPUs.
146    pub jobs: Option<usize>,
147    /// Peak memory budget in MB for streaming compilation.
148    /// `None` means use the default (512 MB).
149    pub max_memory_mb: Option<usize>,
150    /// Run the agentic AI pipeline to audit and fix content.
151    #[allow(dead_code)]
152    pub ai_fix: bool,
153    /// Preview AI fixes without writing files.
154    #[allow(dead_code)]
155    pub ai_fix_dry_run: bool,
156    /// Use the cached dependency graph to skip work on unchanged
157    /// sources (`ssg build --incremental`, issue #524).
158    pub incremental: bool,
159    /// Disable the deterministic LLM inference cache (issue #528).
160    /// Surfaces as `--no-llm-cache` on the CLI and is exported to
161    /// `LlmConfig::default` via the `SSG_NO_LLM_CACHE` env var so
162    /// any code path constructing an `LlmConfig` from defaults
163    /// (CLI helpers, integration tests, plugin re-entrants) sees a
164    /// consistent setting.
165    pub no_llm_cache: bool,
166    /// Emit ISR build manifest + raw KV payloads under `dist/.ssg/`
167    /// (issue #546). Off by default — when false the build is
168    /// byte-identical to v0.0.43 (AC9).
169    pub isr: bool,
170}
171
172impl RunOptions {
173    /// Builds a `RunOptions` from a parsed `clap::ArgMatches`.
174    ///
175    /// # Examples
176    ///
177    /// ```rust
178    /// use ssg::cmd::Cli;
179    /// use ssg::pipeline::RunOptions;
180    ///
181    /// let matches = Cli::build().get_matches_from(vec!["ssg", "--quiet"]);
182    /// let opts = RunOptions::from_matches(&matches);
183    /// assert!(opts.quiet);
184    /// ```
185    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    /// Builds a `RunOptions` from subcommand-style matches.
209    ///
210    /// The subcommand parser exposes a narrower flag set — `--quiet`,
211    /// `--drafts`, `--jobs`, and on the `build` subcommand
212    /// `--max-memory`. Anything else falls back to the defaults so
213    /// downstream callers don't have to special-case missing IDs.
214    ///
215    /// # Examples
216    ///
217    /// ```rust
218    /// use ssg::cmd::Cli;
219    /// use ssg::pipeline::RunOptions;
220    ///
221    /// let matches = Cli::subcommand_app().get_matches_from(vec!["ssg", "build"]);
222    /// let sub_m = matches.subcommand_matches("build").unwrap();
223    /// let opts = RunOptions::from_subcommand_matches(sub_m);
224    /// assert!(!opts.quiet);
225    /// ```
226    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` lives on the deploy subcommand as `--target`.
248            // We map it across so the existing
249            // `register_default_plugins(..., deploy_target)` keeps its
250            // contract.
251            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
264/// Resolves distinct build and site directories for compilation.
265///
266/// `staticdatagen::compile` finalizes output by renaming the build directory
267/// into the site directory. If both paths are identical, finalization fails.
268/// This helper guarantees distinct paths when needed.
269///
270/// # Examples
271///
272/// ```rust
273/// use ssg::cmd::SsgConfig;
274/// use ssg::pipeline::resolve_build_and_site_dirs;
275///
276/// let cfg = SsgConfig::default();
277/// let (build, site) = resolve_build_and_site_dirs(&cfg);
278/// // When serve_dir is unset and equals output_dir, build dir differs.
279/// assert_ne!(build, site);
280/// ```
281pub 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
296/// Builds a fully-populated plugin manager and plugin context for a build.
297///
298/// Extracted so unit tests can construct the same wiring without
299/// needing to fake CLI argument parsing.
300///
301/// # Examples
302///
303/// ```rust
304/// use ssg::cmd::SsgConfig;
305/// use ssg::pipeline::{build_pipeline, RunOptions};
306///
307/// let cfg = SsgConfig::default();
308/// let opts = RunOptions::default();
309/// let (_plugins, _ctx, build, site) = build_pipeline(&cfg, &opts);
310/// assert_ne!(build, site);
311/// ```
312pub 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    // Issue #528 — propagate `--no-llm-cache` to every `LlmConfig`
324    // constructed downstream by exporting `SSG_NO_LLM_CACHE=1` once
325    // here. The env-var approach avoids threading a new parameter
326    // through `register_default_plugins` and through every direct
327    // `LlmConfig::default()` call site (CLI helpers, tests,
328    // integration entry points). The plugin reads the env var inside
329    // its `Default` impl.
330    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    // Set memory budget if --max-memory was specified
343    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
361/// Appends ISR-specific plugins (currently just
362/// [`crate::isr_manifest::IsrManifestPlugin`]).
363///
364/// Pulled out of [`register_default_plugins`] so the default plugin
365/// graph stays byte-identical when `--isr` is not passed (AC9 of
366/// issue #546). Anything registered here MUST be a strict superset
367/// of the v0.0.43 output; failing AC9 fails the entire epic.
368///
369/// # Examples
370///
371/// ```rust
372/// use ssg::pipeline::register_isr_plugins;
373/// use ssg::plugin::PluginManager;
374///
375/// let mut pm = PluginManager::new();
376/// register_isr_plugins(&mut pm);
377/// // ISR plugins get appended without panicking.
378/// ```
379pub fn register_isr_plugins(plugins: &mut plugin::PluginManager) {
380    plugins.register(crate::isr_manifest::IsrManifestPlugin::new());
381    // Edge RPC schema emitter (issue #548). Registered alongside ISR
382    // because both target the same `dist/.ssg/` artefact directory
383    // and both are no-ops without the matching opt-in. When zero
384    // `#[ssg_rpc]` functions are linked, the plugin writes nothing,
385    // preserving the v0.0.43 byte-identical promise.
386    plugins.register(crate::rpc_schema::RpcSchemaPlugin::new());
387}
388
389/// Runs the build half of the pipeline: `before_compile` → compile →
390/// `after_compile`. Does not start the dev server.
391///
392/// Extracted from `run()` so the actual build can be unit-tested
393/// against a tempdir without booting an HTTP server.
394#[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)]
402///
403/// # Examples
404///
405/// ```no_run
406/// use ssg::cmd::SsgConfig;
407/// use ssg::pipeline::{build_pipeline, execute_build_pipeline, RunOptions};
408///
409/// let cfg = SsgConfig::default();
410/// let opts = RunOptions::default();
411/// let (plugins, ctx, build, site) = build_pipeline(&cfg, &opts);
412/// // Wired but not invoked here — would need real content/template dirs.
413/// let _ = execute_build_pipeline(&plugins, &ctx, &build, &cfg.content_dir, &site, &cfg.template_dir, true);
414/// ```
415pub 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/// Variant of [`execute_build_pipeline`] that accepts the
437/// `--incremental` flag.
438///
439/// When `incremental` is `true` and the persisted dependency graph at
440/// `<cache_root>/depgraph.json` shows no source-side changes, the
441/// full compile + transform passes are skipped — the site on disk
442/// from the previous build is the authoritative output. When sources
443/// did change, the full compile runs but the resulting graph is
444/// persisted with fresh sha256 freshness keys so the next incremental
445/// invocation can short-circuit.
446///
447/// The cache root is `<build_dir>/../target/ssg-cache/` when
448/// `build_dir` is a sibling of `target/`; otherwise it lives directly
449/// under `<site_dir>/.ssg-cache/`. The chosen path is logged.
450#[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)]
459///
460/// # Examples
461///
462/// ```no_run
463/// use ssg::cmd::SsgConfig;
464/// use ssg::pipeline::{build_pipeline, execute_build_pipeline_with, RunOptions};
465///
466/// let cfg = SsgConfig::default();
467/// let opts = RunOptions::default();
468/// let (plugins, ctx, build, site) = build_pipeline(&cfg, &opts);
469/// let _ = execute_build_pipeline_with(
470///     &plugins, &ctx, &build, &cfg.content_dir, &site, &cfg.template_dir,
471///     true, false,
472/// );
473/// ```
474pub 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    // Load plugin cache + dep graph from the canonical cache root.
489    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    // ----- Incremental fast path ---------------------------------
497    // Compute current hashes and diff against the cached graph. If
498    // nothing changed and the previous output still exists on disk,
499    // we can skip the entire compile + after_compile + transform
500    // chain. This is the warm-cache <200ms target (AC4).
501    if incremental {
502        let current =
503            crate::depgraph::current_hashes(content_dir, template_dir)?;
504        let diff = prev_graph.diff(&current);
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        // Handle deletes: remove stale outputs and drop the deleted
518        // entries from the persisted graph (AC5).
519        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    // Use streaming compilation for large sites when --max-memory is set
530    // or the site exceeds the default batch size.
531    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        // Spec A2/B1 (plan §2 item 1.2, issue #586): thread the site's
550        // base URL into the compile so the content stager can inject a
551        // derived `permalink:` for pages that don't declare one —
552        // mirroring how the postprocess plugins source `base_url` from
553        // the plugin context's config.
554        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    // Cache HTML file list once — shared by all after_compile plugins,
571    // eliminating 8+ redundant directory walks.
572    ctx.cache_html_files();
573
574    plugins.run_after_compile(&ctx)?;
575
576    // Fused transform pass: read each HTML once → pipe through all
577    // transform plugins → write once. Eliminates redundant I/O.
578    plugins.run_fused_transforms(&ctx)?;
579
580    // Master Quality Gate & Compliance Audit
581    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    // Rebuild the dep graph from scratch on a successful compile so
605    // the next `--incremental` invocation sees a consistent snapshot.
606    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    // Rebuild and save the plugin content-hash cache.
621    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/// Resolves the on-disk cache root for the persisted dependency graph.
644///
645/// Issue #524 specifies `target/ssg-cache/`; when no `target/`
646/// directory is available (e.g. tests or sites built outside cargo),
647/// the cache lives at `<site_dir>/.ssg-cache/`.
648///
649/// # Examples
650///
651/// ```rust
652/// use ssg::pipeline::depgraph_cache_root;
653/// use std::path::Path;
654///
655/// let cache_root = depgraph_cache_root(Path::new("/tmp/site"));
656/// assert!(cache_root.exists() || cache_root.ends_with(".ssg-cache") || cache_root.ends_with("ssg-cache"));
657/// ```
658#[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
668/// Compiles the static site from source directories.
669///
670/// Convenience wrapper over [`compile_site_with_base_url`] with no
671/// base URL — no `permalink:` derivation happens on staged content.
672/// The full build pipeline calls [`compile_site_with_base_url`] with
673/// the configured `base_url` instead (spec A2/B1, plan §2 item 1.2).
674///
675/// # Examples
676///
677/// ```no_run
678/// use ssg::pipeline::compile_site;
679/// use std::path::Path;
680///
681/// // Real call requires populated content/template trees; only the
682/// // signature is exercised here.
683/// let _ = compile_site(
684///     Path::new("build"), Path::new("content"),
685///     Path::new("site"), Path::new("templates"),
686/// );
687/// ```
688pub 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
703/// Compiles the static site, deriving a `permalink:` for every staged
704/// markdown page that declares neither `permalink` nor `url` when
705/// `base_url` is provided (spec A2/B1, plan §2 item 1.2, issue #586).
706///
707/// The derived value is [`crate::urls::derive_permalink`] applied to
708/// `(base_url, content_rel_path)` —
709/// i.e. the pretty directory URL of the page's compiled output — so
710/// the injected permalink, the canonical `<link>`, and the feed
711/// `<link>` all come from one code path
712/// ([`crate::urls::derive_page_url`]). This makes `rss-gen`'s
713/// "channel.link is missing" hard-fail unreachable for pages without
714/// author-specified permalinks.
715///
716/// Passing `base_url: None` (or an empty string) skips the permalink
717/// derivation entirely and behaves like [`compile_site`].
718///
719/// # Examples
720///
721/// ```no_run
722/// use ssg::pipeline::compile_site_with_base_url;
723/// use std::path::Path;
724///
725/// // Real call requires populated content/template trees; only the
726/// // signature is exercised here.
727/// let _ = compile_site_with_base_url(
728///     Path::new("build"), Path::new("content"),
729///     Path::new("site"), Path::new("templates"),
730///     Some("https://example.com"),
731/// );
732/// ```
733pub 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
750/// As [`compile_site_with_base_url`], plus the configured locales so the
751/// content stager can derive `locale_path` / `locale_url` per page.
752///
753/// Separate from the public entry point so that signature stays stable for
754/// embedders; the pipeline itself always calls this one.
755pub 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    // v0.0.46: `staticdatagen 0.0.10` (closes upstream #67, #68, #69,
764    // #70, #71) handles missing layout keys, absent aux files
765    // (`main.js`/`sw.js`), absent tags-page templates, nested locale
766    // walk, and success-log ordering natively — three of the v0.0.45
767    // stager shims were retired in this release. The two surviving
768    // shims:
769    //
770    //   * `collect_template_vars` + `stage_content_with_site_defaults`
771    //     pre-fill empty `key: ""` frontmatter entries for every
772    //     `{{ var }}` reference the templates make. staticweaver
773    //     0.0.3 has `with_lax_undefined(true)` (closes upstream
774    //     staticweaver#28) but staticdatagen 0.0.10 doesn't yet opt
775    //     into it. Tracked: <https://github.com/sebastienrousseau/staticdatagen/issues/99>.
776    //
777    //   * The same staging pass also collapses multi-line double-quoted
778    //     YAML scalars before staticdatagen sees them. `metadata-gen 0.0.5`
779    //     (closes upstream metadata-gen#20) handles this natively,
780    //     but staticdatagen 0.0.10 still pins `metadata-gen = "0.0.4"`.
781    //     Tracked: <https://github.com/sebastienrousseau/staticdatagen/issues/100>.
782    //
783    // Once those two upstream follow-ups land, the residual shim
784    // collapses to ~50 LOC.
785    //
786    // The same staging pass also threads `base_url` through so the
787    // stager can inject a derived `permalink:` for pages that declare
788    // neither `permalink` nor `url` (spec A2/B1, plan §2 item 1.2,
789    // issue #586).
790    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` reads the StaticWeaver templates from the root of the
805    // template directory, choosing one per page from its layout. Which
806    // of them a given site needs therefore depends on its content —
807    // a project with only `page.html` builds fine — so this is not a
808    // precondition that can be checked up front.
809    //
810    // What it is is undiagnosable when it fails. StaticWeaver surfaces a
811    // bare `No such file or directory` carrying no path, and the wrapper
812    // could only name `build_dir`: the directory being written *to*. A
813    // user following `examples/basic`, which shipped without templates,
814    // was told `I/O error at 'public.build-tmp'` about a file under
815    // `templates/` that had never existed (issue #752, and again on a
816    // hand-made project in v0.0.60).
817    //
818    // So keep the behaviour and fix the diagnosis: on a not-found,
819    // report the template directory and which of the usual four are
820    // absent from it.
821    let compiled = compile(build_dir, &staged_content, site_dir, template_dir);
822    // The staged copy exists only for `compile`. Remove it whatever the
823    // outcome: it sits under the system temp directory, keyed by process
824    // and build directory, so every build that left it there left a full
825    // copy of the site's content behind.
826    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
832/// Copies the static assets that sit beside the templates into the site.
833fn copy_template_assets(
834    template_dir: &Path,
835    site_dir: &Path,
836) -> Result<(), SsgError> {
837    // Copy any static assets from template_dir (e.g. styles.css, theme-init.js, favicon.ico, images)
838    // to site_dir so they are available in public/ and fingerprinted by assets plugin.
839    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
851/// Maps a failed `compile` to the error ssg reports.
852fn compile_error(
853    e: &anyhow::Error,
854    build_dir: &Path,
855    template_dir: &Path,
856) -> SsgError {
857    eprintln!("    Error compiling site: {e:?}");
858    // Portability: the not-found test must not be a match on
859    // English Unix wording. Windows says "The system cannot
860    // find the file specified.", so `contains("No such file or
861    // directory")` silently never fired there and the
862    // diagnostic below was dead on that platform — caught by
863    // the windows-latest leg of CI, not by review.
864    //
865    // Prefer the error kind, which is platform-independent.
866    // Fall back to the platform's *own* text for ENOENT, taken
867    // from the OS at runtime rather than hardcoded, for the
868    // case where the chain has flattened the io::Error into a
869    // formatted string.
870    let enoent = std::io::Error::from_raw_os_error(2).to_string();
871    // "No such file or directory (os error 2)" on Unix; "The
872    // system cannot find the file specified. (os error 2)" on
873    // Windows. Compare on the prose alone — the numeric suffix
874    // is not guaranteed to survive re-formatting.
875    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
957/// Registers the default plugin pipeline.
958///
959/// Plugins execute in registration order. The ordering is:
960/// 1. SEO plugins (meta tags, canonical URLs, robots.txt)
961/// 2. Search index generation
962/// 3. HTML minification — last in registration order, but note that
963///    `after_compile` hooks all run *before* any `transform_html`, so
964///    minification precedes the fused transform pass rather than
965///    following it (see the note at its registration below)
966/// 4. Live reload (`on_serve` only)
967///
968/// # Examples
969///
970/// ```rust
971/// use ssg::cmd::SsgConfig;
972/// use ssg::pipeline::register_default_plugins;
973/// use ssg::plugin::PluginManager;
974///
975/// let cfg = SsgConfig::default();
976/// let mut pm = PluginManager::new();
977/// register_default_plugins(&mut pm, &cfg, false, None);
978/// assert!(pm.len() > 0);
979/// ```
980pub 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    // Before-compile plugins
989    plugins.register(content::ContentValidationPlugin);
990    plugins.register(drafts::DraftPlugin::new(include_drafts));
991    plugins.register(shortcodes::ShortcodePlugin);
992
993    // Template engine (must run first in after_compile)
994    #[cfg(feature = "templates")]
995    plugins.register(
996        crate::template_plugin::TemplatePlugin::from_template_dir(
997            &config.template_dir,
998        ),
999    );
1000
1001    // Post-processing fixes for staticdatagen output (run early,
1002    // before SEO plugins read/modify the HTML)
1003    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    // There is one SBOM emitter, `crate::sbom::SbomPlugin` ("sbom"). A second
1011    // one, `postprocess::SbomPlugin` ("sbom-generator"), wrote the same
1012    // `sbom.cdx.json` and was overwritten by it, so the build serialised the
1013    // dependency tree twice and discarded one copy. It was unregistered,
1014    // deprecated in 0.0.58 and removed in 0.0.64. The survivor is the more
1015    // complete of the two: it also injects `<link rel="sbom">` into every
1016    // document head.
1017
1018    // Agentic discovery (#552): agents.txt + .well-known/ai-plugin.json
1019    // + .well-known/mcp.json. No-op when `[agents]` is absent from
1020    // `ssg.toml`, so existing sites see no behavioural change.
1021    plugins.register(postprocess::AgenticDiscoveryPlugin);
1022
1023    // Syntax highlighting. Before FingerprintPlugin, which renames
1024    // `highlight.css` and rewrites the page links to it (#790).
1025    plugins.register(highlight::HighlightPlugin::default());
1026
1027    // SEO plugins
1028    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    // AI readiness
1035    plugins.register(ai::AiPlugin);
1036
1037    // Agent JSON API (#586 port 3): /api/agents/{index,posts,topics,
1038    // person}.json. Default-on like AiPlugin; programmatic opt-out via
1039    // AgentApiPlugin::disabled(). (The oEmbed emitter — port 4 — is
1040    // opt-in and therefore NOT registered here; see crate::oembed.)
1041    plugins.register(crate::agent_api::AgentApiPlugin::default());
1042
1043    // Taxonomy and pagination
1044    plugins.register(taxonomy::TaxonomyPlugin);
1045    plugins.register(pagination::PaginationPlugin::default());
1046
1047    // Search & optimization
1048    plugins.register(search::SearchPlugin);
1049
1050    // Accessibility validation
1051    plugins.register(accessibility::AccessibilityPlugin);
1052
1053    // Master Quality Gate & Compliance Audit
1054    plugins.register(crate::plugins_group::audit::AuditPlugin);
1055
1056    // Image optimization (WebP, responsive srcset)
1057    #[cfg(feature = "image-optimization")]
1058    plugins.register(crate::image_plugin::ImageOptimizationPlugin::default());
1059
1060    // I18n hreflang injection and per-locale sitemaps
1061    #[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    // Interactive islands (Web Components)
1069    plugins.register(islands::IslandPlugin);
1070
1071    // View Transitions API + lazy-nav client (issue #547, opt-in).
1072    // Registered after islands so the transitions client can call the
1073    // `<ssg-island>` `detach()` hook on the outgoing page.
1074    if crate::view_transitions::ViewTransitionsPlugin::enabled(config) {
1075        plugins.register(crate::view_transitions::ViewTransitionsPlugin::new());
1076    }
1077
1078    // CSP hardening: extract inline styles/scripts to external files with SRI
1079    plugins.register(csp::CspPlugin);
1080
1081    // SBOM emission + per-page link (resolves #457). Runs before
1082    // FingerprintPlugin so the SBOM filename itself isn't subject to
1083    // content-hash renaming (consumers fetch a stable URL).
1084    plugins.register(crate::sbom::SbomPlugin);
1085
1086    // Asset fingerprinting + SRI (after all content transforms)
1087    plugins.register(assets::FingerprintPlugin);
1088
1089    // Minification. Registered last, but that does not make it run last:
1090    // MinifyPlugin only implements `after_compile`, and every
1091    // `after_compile` hook runs before any `transform_html`, so it
1092    // rewrites markup that later transforms then read.
1093    //
1094    // Two consequences worth knowing before relying on ordering here:
1095    //
1096    //   - The walk is recursive, so every page under `site_dir` is
1097    //     minified. It used to be top-level only unless the `minify`
1098    //     feature was on, which left the two halves of a site
1099    //     inconsistently minified.
1100    //   - `html-generator` minifies some pages during generation, before
1101    //     any plugin runs at all, which no plugin ordering can affect.
1102    //     That is why the i18n language-switcher marker is an element
1103    //     rather than a comment: comments do not survive it.
1104    //
1105    // Moving it into a post-transform phase was tried and reverted: it
1106    // changes the observable behaviour of the public `run_after_compile`,
1107    // which silently stopped minifying for every caller outside this
1108    // function. Doing it properly needs its own change with a migration
1109    // note.
1110    plugins.register(plugins_mod::MinifyPlugin);
1111
1112    // Edge-runtime header emitter (issue #550). Opt-in via the
1113    // `[edge_headers] targets = [...]` section of ssg.toml; the
1114    // plugin is a no-op when targets is empty so unconditional
1115    // registration here is safe and keeps the wiring simple.
1116    plugins.register(postprocess::EdgeHeadersPlugin);
1117
1118    // Deployment config generation (opt-in via --deploy flag)
1119    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    // Dev server
1136    plugins.register(livereload::LiveReloadPlugin::default());
1137}
1138
1139#[cfg(test)]
1140mod tests {
1141
1142    /// The default pipeline must register each plugin name once. Two
1143    /// `SbomPlugin` implementations were both registered before 0.0.58: they
1144    /// wrote the same `sbom.cdx.json`, so the later one silently overwrote the
1145    /// earlier and the dependency tree was serialised twice per build.
1146    #[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    /// WS0.5's acceptance condition: "`ssg plugins list` shows exactly one
1165    /// Deploy and one SBOM plugin".
1166    ///
1167    /// Without a target the deploy plugin does not register at all, which is
1168    /// correct for a plain build but means the deploy stage cannot be
1169    /// inspected. `--target` mirrors what `ssg deploy` would register.
1170    #[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    /// Exactly one SBOM emitter, and it is the one that also links the
1200    /// document head. A duplicate was removed in 0.0.64; this guards
1201    /// against a second emitter being registered again.
1202    #[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    /// The inventory is ordered, and that order is execution order — the
1218    /// property `ssg plugins list` reports and the README count derives from.
1219    #[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    // -----------------------------------------------------------------
1304    // BuildError — additional coverage
1305    // -----------------------------------------------------------------
1306
1307    #[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    // -----------------------------------------------------------------
1379    // extract_file_from_error — additional coverage
1380    // -----------------------------------------------------------------
1381
1382    #[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    // -----------------------------------------------------------------
1411    // clear_error_message — sanity
1412    // -----------------------------------------------------------------
1413
1414    #[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        // Ensure no extra keys leak
1421        assert_eq!(parsed.as_object().unwrap().len(), 1);
1422    }
1423
1424    // -----------------------------------------------------------------
1425    // resolve_build_and_site_dirs — coverage from pipeline module
1426    // -----------------------------------------------------------------
1427
1428    #[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        // build should differ from site
1439        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    // -----------------------------------------------------------------
1470    // RunOptions — construction from matches
1471    // -----------------------------------------------------------------
1472
1473    #[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        // `from_matches`'s `incremental` / `no_llm_cache` / `isr` fields
1505        // each short-circuit on `try_contains_id`; the legacy `Cli`
1506        // defines all three ids, so this drives the true-arm of every
1507        // `&&` (the id is present *and* the flag was actually passed).
1508        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    // -----------------------------------------------------------------
1549    // register_default_plugins — plugin count and ordering
1550    // -----------------------------------------------------------------
1551
1552    #[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        // We expect a substantial number of default plugins
1562        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    // -----------------------------------------------------------------
1626    // build_pipeline — basic wiring
1627    // -----------------------------------------------------------------
1628
1629    #[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    // -----------------------------------------------------------------
1662    // RunOptions::from_subcommand_matches — populated values
1663    // -----------------------------------------------------------------
1664
1665    #[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    // -----------------------------------------------------------------
1680    // build_pipeline — env export, ISR registration
1681    // -----------------------------------------------------------------
1682
1683    #[test]
1684    fn test_build_pipeline_no_llm_cache_exports_env_flag() {
1685        // Serialised env-var scoping (mirrors the llm.rs pattern).
1686        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        // Restore machine state before asserting.
1701        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    // -----------------------------------------------------------------
1730    // register_default_plugins — conditional registrations
1731    // -----------------------------------------------------------------
1732
1733    #[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    // -----------------------------------------------------------------
1777    // depgraph_cache_root — no-target fallback
1778    // -----------------------------------------------------------------
1779
1780    #[test]
1781    #[serial_test::serial(cwd)]
1782    fn test_depgraph_cache_root_falls_back_without_target_dir() {
1783        // From a cwd without a `target/` directory the cache root
1784        // lands under the site dir.
1785        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    // -----------------------------------------------------------------
1796    // compile_site_with_base_url — template-collection failure
1797    // -----------------------------------------------------------------
1798
1799    #[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        // Root bypasses permissions on some CI runners, so tolerate Ok.
1825        assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1826    }
1827
1828    // -----------------------------------------------------------------
1829    // execute_build_pipeline_with — plugin failures, streaming,
1830    // incremental fast path, and non-fatal cache warnings
1831    // -----------------------------------------------------------------
1832
1833    /// Minimal compilable site fixture (mirrors
1834    /// tests/core/pipeline.rs): two pages + one template.
1835    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    /// The staged copy of the content lives under the system temp
1867    /// directory and is only read by `compile`. It used to be left
1868    /// there, so every build kept a full copy of the site's content.
1869    #[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    /// The same when `compile` fails part-way.
1886    #[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    /// Test plugin that fails in exactly one pipeline phase.
1904    #[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    /// Test plugin that sabotages the site dir *after* compile, so
1954    /// the post-build cache bookkeeping hits its non-fatal error arms
1955    /// (the compile step recreates the site dir wholesale, so
1956    /// obstructions must be planted from inside the pipeline).
1957    #[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        // An explicit budget forces the streaming/batched compile.
2042        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        // Fresh cache root so a previous test's graph can't leak in.
2065        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        // Run 1: cold cache — full build, graph persisted.
2071        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        // Run 2: nothing changed — the fast path must skip the
2079        // compile entirely, so a marker planted in the output
2080        // survives verbatim.
2081        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        // Run 3: delete a source — its stale output is swept and the
2093        // site is rebuilt without it.
2094        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        // A dangling .md symlink survives staging (symlinks are
2107        // skipped) but makes depgraph::populate fail post-compile —
2108        // the build must still succeed with a warning.
2109        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        // A directory squatting on the graph's tmp path makes
2129        // DepGraph::save fail — the build must still succeed.
2130        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        // `depgraph_cache_root` returns `target/<CACHE_DIRNAME>` when a
2136        // `target/` directory exists relative to the *current* working
2137        // directory, and falls back to `site_dir/.ssg-cache` otherwise.
2138        // The build clears the site directory, so under the fallback the
2139        // blocker is swept away and the scenario this test exists to
2140        // cover cannot be constructed at all.
2141        //
2142        // Which branch is taken therefore depends on whether the
2143        // developer's cargo writes into `./target` — a global
2144        // `build.target-dir` in ~/.cargo/config.toml is enough to flip
2145        // it, and CI runners always have `./target`. Pin it here so the
2146        // test means the same thing everywhere.
2147        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        // The sabotage plugin plants a directory on the
2178        // `.ssg-plugin-cache.json` path after compile, so
2179        // PluginCache::save fails — the build must still succeed.
2180        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        // `ctx.config` is only ever `Some(..)` when built through
2197        // `PluginContext::with_config` (as `build_pipeline` wires it
2198        // up); the fixture-based tests elsewhere in this module use
2199        // `PluginContext::new`, which leaves it `None` and never runs
2200        // the `ctx.config.as_ref().map(|c| c.base_url.clone())` closure
2201        // in the non-streaming branch of `execute_build_pipeline_with`.
2202        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        // `current_hashes(content_dir, template_dir)?` is the first
2227        // thing the incremental fast path does. Walking an existing
2228        // but unreadable content dir makes `fs::read_dir` fail inside
2229        // `walk_files_bounded_depth`, so the `?` here propagates —
2230        // a branch none of the other incremental tests exercise since
2231        // they all use a normally-readable fixture.
2232        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        // The sabotage plugin plants an unreadable subdirectory in
2263        // the site dir after compile, so the post-build HTML walk
2264        // fails; the cache-update block skips silently and the build
2265        // still succeeds.
2266        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}