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    compile(build_dir, &staged_content, site_dir, template_dir).map_err(
822        |e| {
823            eprintln!("    Error compiling site: {e:?}");
824            // Portability: the not-found test must not be a match on
825            // English Unix wording. Windows says "The system cannot
826            // find the file specified.", so `contains("No such file or
827            // directory")` silently never fired there and the
828            // diagnostic below was dead on that platform — caught by
829            // the windows-latest leg of CI, not by review.
830            //
831            // Prefer the error kind, which is platform-independent.
832            // Fall back to the platform's *own* text for ENOENT, taken
833            // from the OS at runtime rather than hardcoded, for the
834            // case where the chain has flattened the io::Error into a
835            // formatted string.
836            let enoent = std::io::Error::from_raw_os_error(2).to_string();
837            // "No such file or directory (os error 2)" on Unix; "The
838            // system cannot find the file specified. (os error 2)" on
839            // Windows. Compare on the prose alone — the numeric suffix
840            // is not guaranteed to survive re-formatting.
841            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 any static assets from template_dir (e.g. styles.css, theme-init.js, favicon.ico, images)
882    // to site_dir so they are available in public/ and fingerprinted by assets plugin.
883    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
939/// Registers the default plugin pipeline.
940///
941/// Plugins execute in registration order. The ordering is:
942/// 1. SEO plugins (meta tags, canonical URLs, robots.txt)
943/// 2. Search index generation
944/// 3. HTML minification — last in registration order, but note that
945///    `after_compile` hooks all run *before* any `transform_html`, so
946///    minification precedes the fused transform pass rather than
947///    following it (see the note at its registration below)
948/// 4. Live reload (`on_serve` only)
949///
950/// # Examples
951///
952/// ```rust
953/// use ssg::cmd::SsgConfig;
954/// use ssg::pipeline::register_default_plugins;
955/// use ssg::plugin::PluginManager;
956///
957/// let cfg = SsgConfig::default();
958/// let mut pm = PluginManager::new();
959/// register_default_plugins(&mut pm, &cfg, false, None);
960/// assert!(pm.len() > 0);
961/// ```
962pub 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    // Before-compile plugins
971    plugins.register(content::ContentValidationPlugin);
972    plugins.register(drafts::DraftPlugin::new(include_drafts));
973    plugins.register(shortcodes::ShortcodePlugin);
974
975    // Template engine (must run first in after_compile)
976    #[cfg(feature = "templates")]
977    plugins.register(
978        crate::template_plugin::TemplatePlugin::from_template_dir(
979            &config.template_dir,
980        ),
981    );
982
983    // Post-processing fixes for staticdatagen output (run early,
984    // before SEO plugins read/modify the HTML)
985    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    // `postprocess::SbomPlugin` ("sbom-generator") is deliberately not
993    // registered. It wrote the same `sbom.cdx.json` as `crate::sbom::SbomPlugin`
994    // ("sbom"), which registers later and therefore overwrote it — the build
995    // serialised the dependency tree twice and threw one copy away. The
996    // surviving plugin is the more complete of the two: it also injects the
997    // `<link rel="sbom">` into every document head.
998
999    // Agentic discovery (#552): agents.txt + .well-known/ai-plugin.json
1000    // + .well-known/mcp.json. No-op when `[agents]` is absent from
1001    // `ssg.toml`, so existing sites see no behavioural change.
1002    plugins.register(postprocess::AgenticDiscoveryPlugin);
1003
1004    // Syntax highlighting
1005    plugins.register(highlight::HighlightPlugin::default());
1006
1007    // SEO plugins
1008    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    // AI readiness
1015    plugins.register(ai::AiPlugin);
1016
1017    // Agent JSON API (#586 port 3): /api/agents/{index,posts,topics,
1018    // person}.json. Default-on like AiPlugin; programmatic opt-out via
1019    // AgentApiPlugin::disabled(). (The oEmbed emitter — port 4 — is
1020    // opt-in and therefore NOT registered here; see crate::oembed.)
1021    plugins.register(crate::agent_api::AgentApiPlugin::default());
1022
1023    // Taxonomy and pagination
1024    plugins.register(taxonomy::TaxonomyPlugin);
1025    plugins.register(pagination::PaginationPlugin::default());
1026
1027    // Search & optimization
1028    plugins.register(search::SearchPlugin);
1029
1030    // Accessibility validation
1031    plugins.register(accessibility::AccessibilityPlugin);
1032
1033    // Master Quality Gate & Compliance Audit
1034    plugins.register(crate::plugins_group::audit::AuditPlugin);
1035
1036    // Image optimization (WebP, responsive srcset)
1037    #[cfg(feature = "image-optimization")]
1038    plugins.register(crate::image_plugin::ImageOptimizationPlugin::default());
1039
1040    // I18n hreflang injection and per-locale sitemaps
1041    #[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    // Interactive islands (Web Components)
1049    plugins.register(islands::IslandPlugin);
1050
1051    // View Transitions API + lazy-nav client (issue #547, opt-in).
1052    // Registered after islands so the transitions client can call the
1053    // `<ssg-island>` `detach()` hook on the outgoing page.
1054    if crate::view_transitions::ViewTransitionsPlugin::enabled(config) {
1055        plugins.register(crate::view_transitions::ViewTransitionsPlugin::new());
1056    }
1057
1058    // CSP hardening: extract inline styles/scripts to external files with SRI
1059    plugins.register(csp::CspPlugin);
1060
1061    // SBOM emission + per-page link (resolves #457). Runs before
1062    // FingerprintPlugin so the SBOM filename itself isn't subject to
1063    // content-hash renaming (consumers fetch a stable URL).
1064    plugins.register(crate::sbom::SbomPlugin);
1065
1066    // Asset fingerprinting + SRI (after all content transforms)
1067    plugins.register(assets::FingerprintPlugin);
1068
1069    // Minification. Registered last, but that does not make it run last:
1070    // MinifyPlugin only implements `after_compile`, and every
1071    // `after_compile` hook runs before any `transform_html`, so it
1072    // rewrites markup that later transforms then read.
1073    //
1074    // Two consequences worth knowing before relying on ordering here:
1075    //
1076    //   - The walk is recursive, so every page under `site_dir` is
1077    //     minified. It used to be top-level only unless the `minify`
1078    //     feature was on, which left the two halves of a site
1079    //     inconsistently minified.
1080    //   - `html-generator` minifies some pages during generation, before
1081    //     any plugin runs at all, which no plugin ordering can affect.
1082    //     That is why the i18n language-switcher marker is an element
1083    //     rather than a comment: comments do not survive it.
1084    //
1085    // Moving it into a post-transform phase was tried and reverted: it
1086    // changes the observable behaviour of the public `run_after_compile`,
1087    // which silently stopped minifying for every caller outside this
1088    // function. Doing it properly needs its own change with a migration
1089    // note.
1090    plugins.register(plugins_mod::MinifyPlugin);
1091
1092    // Edge-runtime header emitter (issue #550). Opt-in via the
1093    // `[edge_headers] targets = [...]` section of ssg.toml; the
1094    // plugin is a no-op when targets is empty so unconditional
1095    // registration here is safe and keeps the wiring simple.
1096    plugins.register(postprocess::EdgeHeadersPlugin);
1097
1098    // Deployment config generation (opt-in via --deploy flag)
1099    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    // Dev server
1116    plugins.register(livereload::LiveReloadPlugin::default());
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121
1122    /// The default pipeline must register each plugin name once. Two
1123    /// `SbomPlugin` implementations were both registered before 0.0.58: they
1124    /// wrote the same `sbom.cdx.json`, so the later one silently overwrote the
1125    /// earlier and the dependency tree was serialised twice per build.
1126    #[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    /// WS0.5's acceptance condition: "`ssg plugins list` shows exactly one
1145    /// Deploy and one SBOM plugin".
1146    ///
1147    /// Without a target the deploy plugin does not register at all, which is
1148    /// correct for a plain build but means the deploy stage cannot be
1149    /// inspected. `--target` mirrors what `ssg deploy` would register.
1150    #[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    /// Exactly one SBOM emitter, and it is the one that also links the
1180    /// document head — see `postprocess::SbomPlugin`'s deprecation note.
1181    #[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    /// The inventory is ordered, and that order is execution order — the
1197    /// property `ssg plugins list` reports and the README count derives from.
1198    #[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    // -----------------------------------------------------------------
1283    // BuildError — additional coverage
1284    // -----------------------------------------------------------------
1285
1286    #[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    // -----------------------------------------------------------------
1358    // extract_file_from_error — additional coverage
1359    // -----------------------------------------------------------------
1360
1361    #[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    // -----------------------------------------------------------------
1390    // clear_error_message — sanity
1391    // -----------------------------------------------------------------
1392
1393    #[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        // Ensure no extra keys leak
1400        assert_eq!(parsed.as_object().unwrap().len(), 1);
1401    }
1402
1403    // -----------------------------------------------------------------
1404    // resolve_build_and_site_dirs — coverage from pipeline module
1405    // -----------------------------------------------------------------
1406
1407    #[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        // build should differ from site
1418        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    // -----------------------------------------------------------------
1449    // RunOptions — construction from matches
1450    // -----------------------------------------------------------------
1451
1452    #[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        // `from_matches`'s `incremental` / `no_llm_cache` / `isr` fields
1484        // each short-circuit on `try_contains_id`; the legacy `Cli`
1485        // defines all three ids, so this drives the true-arm of every
1486        // `&&` (the id is present *and* the flag was actually passed).
1487        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    // -----------------------------------------------------------------
1528    // register_default_plugins — plugin count and ordering
1529    // -----------------------------------------------------------------
1530
1531    #[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        // We expect a substantial number of default plugins
1541        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    // -----------------------------------------------------------------
1605    // build_pipeline — basic wiring
1606    // -----------------------------------------------------------------
1607
1608    #[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    // -----------------------------------------------------------------
1641    // RunOptions::from_subcommand_matches — populated values
1642    // -----------------------------------------------------------------
1643
1644    #[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    // -----------------------------------------------------------------
1659    // build_pipeline — env export, ISR registration
1660    // -----------------------------------------------------------------
1661
1662    #[test]
1663    fn test_build_pipeline_no_llm_cache_exports_env_flag() {
1664        // Serialised env-var scoping (mirrors the llm.rs pattern).
1665        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        // Restore machine state before asserting.
1680        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    // -----------------------------------------------------------------
1709    // register_default_plugins — conditional registrations
1710    // -----------------------------------------------------------------
1711
1712    #[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    // -----------------------------------------------------------------
1756    // depgraph_cache_root — no-target fallback
1757    // -----------------------------------------------------------------
1758
1759    #[test]
1760    #[serial_test::serial(cwd)]
1761    fn test_depgraph_cache_root_falls_back_without_target_dir() {
1762        // From a cwd without a `target/` directory the cache root
1763        // lands under the site dir.
1764        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    // -----------------------------------------------------------------
1775    // compile_site_with_base_url — template-collection failure
1776    // -----------------------------------------------------------------
1777
1778    #[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        // Root bypasses permissions on some CI runners, so tolerate Ok.
1804        assert!(res.err().is_none_or(|e| !format!("{e}").is_empty()));
1805    }
1806
1807    // -----------------------------------------------------------------
1808    // execute_build_pipeline_with — plugin failures, streaming,
1809    // incremental fast path, and non-fatal cache warnings
1810    // -----------------------------------------------------------------
1811
1812    /// Minimal compilable site fixture (mirrors
1813    /// tests/core/pipeline.rs): two pages + one template.
1814    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    /// Test plugin that fails in exactly one pipeline phase.
1846    #[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    /// Test plugin that sabotages the site dir *after* compile, so
1896    /// the post-build cache bookkeeping hits its non-fatal error arms
1897    /// (the compile step recreates the site dir wholesale, so
1898    /// obstructions must be planted from inside the pipeline).
1899    #[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        // An explicit budget forces the streaming/batched compile.
1984        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        // Fresh cache root so a previous test's graph can't leak in.
2007        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        // Run 1: cold cache — full build, graph persisted.
2013        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        // Run 2: nothing changed — the fast path must skip the
2021        // compile entirely, so a marker planted in the output
2022        // survives verbatim.
2023        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        // Run 3: delete a source — its stale output is swept and the
2035        // site is rebuilt without it.
2036        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        // A dangling .md symlink survives staging (symlinks are
2049        // skipped) but makes depgraph::populate fail post-compile —
2050        // the build must still succeed with a warning.
2051        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        // A directory squatting on the graph's tmp path makes
2071        // DepGraph::save fail — the build must still succeed.
2072        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        // `depgraph_cache_root` returns `target/<CACHE_DIRNAME>` when a
2078        // `target/` directory exists relative to the *current* working
2079        // directory, and falls back to `site_dir/.ssg-cache` otherwise.
2080        // The build clears the site directory, so under the fallback the
2081        // blocker is swept away and the scenario this test exists to
2082        // cover cannot be constructed at all.
2083        //
2084        // Which branch is taken therefore depends on whether the
2085        // developer's cargo writes into `./target` — a global
2086        // `build.target-dir` in ~/.cargo/config.toml is enough to flip
2087        // it, and CI runners always have `./target`. Pin it here so the
2088        // test means the same thing everywhere.
2089        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        // The sabotage plugin plants a directory on the
2120        // `.ssg-plugin-cache.json` path after compile, so
2121        // PluginCache::save fails — the build must still succeed.
2122        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        // `ctx.config` is only ever `Some(..)` when built through
2139        // `PluginContext::with_config` (as `build_pipeline` wires it
2140        // up); the fixture-based tests elsewhere in this module use
2141        // `PluginContext::new`, which leaves it `None` and never runs
2142        // the `ctx.config.as_ref().map(|c| c.base_url.clone())` closure
2143        // in the non-streaming branch of `execute_build_pipeline_with`.
2144        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        // `current_hashes(content_dir, template_dir)?` is the first
2169        // thing the incremental fast path does. Walking an existing
2170        // but unreadable content dir makes `fs::read_dir` fail inside
2171        // `walk_files_bounded_depth`, so the `?` here propagates —
2172        // a branch none of the other incremental tests exercise since
2173        // they all use a normally-readable fixture.
2174        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        // The sabotage plugin plants an unreadable subdirectory in
2205        // the site dir after compile, so the post-build HTML walk
2206        // fails; the cache-update block skips silently and the build
2207        // still succeeds.
2208        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}