Skip to main content

ssg/
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 anyhow::{anyhow, Result};
9use staticdatagen::compile;
10
11use crate::cmd::SsgConfig;
12use crate::{
13    accessibility, ai, assets, content, csp, deploy, drafts, highlight, i18n,
14    islands, livereload, pagination, plugin, plugins as plugins_mod,
15    postprocess, search, seo, shortcodes, streaming, taxonomy, walk,
16};
17
18// ---------------------------------------------------------------------------
19// BuildError — serialisable build error for browser overlay delivery
20// ---------------------------------------------------------------------------
21
22/// Serialisable build error for browser overlay delivery.
23#[derive(Debug, Clone, serde::Serialize)]
24#[allow(dead_code)]
25pub struct BuildError {
26    /// Source file path (if extractable from the error chain).
27    pub file: Option<String>,
28    /// Line number (if extractable).
29    pub line: Option<usize>,
30    /// Human-readable error message.
31    pub message: String,
32}
33
34impl BuildError {
35    /// Creates a `BuildError` from an `anyhow` error, attempting to extract
36    /// file path and line number from the error chain.
37    #[must_use]
38    #[allow(dead_code)]
39    pub fn from_anyhow(err: &anyhow::Error) -> Self {
40        let message = format!("{err:#}");
41        let file = extract_file_from_error(&message);
42        Self {
43            file,
44            line: None,
45            message,
46        }
47    }
48
49    /// Serializes to a WebSocket JSON message.
50    #[must_use]
51    #[allow(dead_code)]
52    pub fn to_ws_message(&self) -> String {
53        serde_json::json!({
54            "type": "error",
55            "file": self.file,
56            "line": self.line,
57            "message": self.message,
58        })
59        .to_string()
60    }
61}
62
63/// Returns the JSON message to clear the error overlay.
64#[must_use]
65#[allow(dead_code)]
66pub fn clear_error_message() -> String {
67    r#"{"type":"clear-error"}"#.to_string()
68}
69
70/// Extracts a file path from an error message by scanning for path-like
71/// tokens ending in known extensions.
72#[allow(dead_code)]
73fn extract_file_from_error(msg: &str) -> Option<String> {
74    for word in msg.split_whitespace() {
75        let trimmed = word.trim_matches(|c: char| {
76            !c.is_alphanumeric() && c != '/' && c != '.' && c != '_' && c != '-'
77        });
78        if trimmed.contains('/')
79            && (trimmed.ends_with(".md")
80                || trimmed.ends_with(".html")
81                || trimmed.ends_with(".toml")
82                || trimmed.ends_with(".yml")
83                || trimmed.ends_with(".yaml"))
84        {
85            return Some(trimmed.to_string());
86        }
87    }
88    None
89}
90
91/// CLI-driven options that don't live in `SsgConfig` itself.
92///
93/// Extracted from clap matches so the run pipeline can be unit-tested
94/// without going through `Cli::build()`. **Internal**: this is a
95/// CLI-implementation type, not part of the library surface. The
96/// containing module is `pub(crate)`, so this `pub` is effectively
97/// crate-local — clippy's `redundant_pub_crate` flagged the prior
98/// `pub(crate)` here. See
99/// [API stability audit](../../docs/architecture/api-stability-audit.md)
100/// (Tier C) for context.
101#[derive(Debug, Clone)]
102#[allow(clippy::struct_excessive_bools)]
103pub struct RunOptions {
104    /// Suppress banner and timing print-outs.
105    pub quiet: bool,
106    /// Include draft files (skip the `DraftPlugin` filter).
107    pub include_drafts: bool,
108    /// Optional deploy target — `netlify`, `vercel`, `cloudflare`, `github`.
109    pub deploy_target: Option<String>,
110    /// Validate content schemas only (no build).
111    pub validate_only: bool,
112    /// Number of parallel threads for Rayon (`--jobs`).
113    /// `None` means use all available CPUs.
114    pub jobs: Option<usize>,
115    /// Peak memory budget in MB for streaming compilation.
116    /// `None` means use the default (512 MB).
117    pub max_memory_mb: Option<usize>,
118    /// Run the agentic AI pipeline to audit and fix content.
119    #[allow(dead_code)]
120    pub ai_fix: bool,
121    /// Preview AI fixes without writing files.
122    #[allow(dead_code)]
123    pub ai_fix_dry_run: bool,
124}
125
126impl RunOptions {
127    /// Builds a `RunOptions` from a parsed `clap::ArgMatches`.
128    pub fn from_matches(matches: &clap::ArgMatches) -> Self {
129        Self {
130            quiet: matches.get_flag("quiet"),
131            include_drafts: matches.get_flag("drafts"),
132            deploy_target: matches.get_one::<String>("deploy").cloned(),
133            validate_only: matches.get_flag("validate"),
134            jobs: matches.get_one::<usize>("jobs").copied(),
135            max_memory_mb: matches.get_one::<usize>("max-memory").copied(),
136            ai_fix: matches.get_flag("ai-fix"),
137            ai_fix_dry_run: matches.get_flag("ai-fix-dry-run"),
138        }
139    }
140}
141
142/// Resolves distinct build and site directories for compilation.
143///
144/// `staticdatagen::compile` finalizes output by renaming the build directory
145/// into the site directory. If both paths are identical, finalization fails.
146/// This helper guarantees distinct paths when needed.
147pub fn resolve_build_and_site_dirs(config: &SsgConfig) -> (PathBuf, PathBuf) {
148    let site_dir = config
149        .serve_dir
150        .clone()
151        .unwrap_or_else(|| config.output_dir.clone());
152
153    let build_dir = if site_dir == config.output_dir {
154        config.output_dir.with_extension("build-tmp")
155    } else {
156        config.output_dir.clone()
157    };
158
159    (build_dir, site_dir)
160}
161
162/// Builds a fully-populated plugin manager and plugin context for a build.
163///
164/// Extracted so unit tests can construct the same wiring without
165/// needing to fake CLI argument parsing.
166pub fn build_pipeline(
167    config: &SsgConfig,
168    opts: &RunOptions,
169) -> (
170    plugin::PluginManager,
171    plugin::PluginContext,
172    PathBuf,
173    PathBuf,
174) {
175    let (build_dir, site_dir) = resolve_build_and_site_dirs(config);
176
177    let mut ctx = plugin::PluginContext::with_config(
178        &config.content_dir,
179        &build_dir,
180        &site_dir,
181        &config.template_dir,
182        config.clone(),
183    );
184
185    // Set memory budget if --max-memory was specified
186    if let Some(mb) = opts.max_memory_mb {
187        ctx.memory_budget = Some(streaming::MemoryBudget::from_mb(mb));
188    }
189
190    let mut plugins = plugin::PluginManager::new();
191    register_default_plugins(
192        &mut plugins,
193        config,
194        opts.include_drafts,
195        opts.deploy_target.as_deref(),
196    );
197
198    (plugins, ctx, build_dir, site_dir)
199}
200
201/// Runs the build half of the pipeline: `before_compile` → compile →
202/// `after_compile`. Does not start the dev server.
203///
204/// Extracted from `run()` so the actual build can be unit-tested
205/// against a tempdir without booting an HTTP server.
206#[cfg_attr(
207    feature = "otel",
208    tracing::instrument(skip(plugins, ctx), fields(
209        content_dir = %content_dir.display(),
210        site_dir = %site_dir.display(),
211        quiet,
212    ))
213)]
214pub fn execute_build_pipeline(
215    plugins: &plugin::PluginManager,
216    ctx: &plugin::PluginContext,
217    build_dir: &Path,
218    content_dir: &Path,
219    site_dir: &Path,
220    template_dir: &Path,
221    quiet: bool,
222) -> Result<()> {
223    let start = std::time::Instant::now();
224
225    // Load plugin cache for incremental builds
226    let cache = plugin::PluginCache::load(site_dir);
227    let dep_graph = crate::depgraph::DepGraph::load(site_dir);
228    let mut ctx = ctx.clone();
229    ctx.cache = Some(cache);
230    ctx.dep_graph = Some(dep_graph);
231
232    plugins.run_before_compile(&ctx)?;
233
234    // Use streaming compilation for large sites when --max-memory is set
235    // or the site exceeds the default batch size.
236    let budget = ctx
237        .memory_budget
238        .unwrap_or_else(streaming::MemoryBudget::default_budget);
239    let explicitly_set = ctx.memory_budget.is_some();
240
241    if streaming::should_stream(content_dir, &budget, explicitly_set) {
242        let batches = streaming::batched_content_files(content_dir, &budget)?;
243        for (i, batch) in batches.iter().enumerate() {
244            streaming::compile_batch(
245                batch,
246                content_dir,
247                build_dir,
248                site_dir,
249                template_dir,
250                i,
251            )?;
252        }
253    } else {
254        compile_site(build_dir, content_dir, site_dir, template_dir)?;
255    }
256
257    // Cache HTML file list once — shared by all after_compile plugins,
258    // eliminating 8+ redundant directory walks.
259    ctx.cache_html_files();
260
261    plugins.run_after_compile(&ctx)?;
262
263    // Fused transform pass: read each HTML once → pipe through all
264    // transform plugins → write once. Eliminates redundant I/O.
265    plugins.run_fused_transforms(&ctx)?;
266
267    // Rebuild and save cache: snapshot all HTML files in site_dir
268    if let Some(ref mut cache) = ctx.cache {
269        if let Ok(files) = walk::walk_files(site_dir, "html") {
270            for file in &files {
271                cache.update(file);
272            }
273        }
274        if let Err(e) = cache.save(site_dir) {
275            log::warn!("Failed to save plugin cache: {e}");
276        }
277    }
278
279    // Persist the dependency graph for next incremental build
280    if let Some(ref dg) = ctx.dep_graph {
281        if let Err(e) = dg.save(site_dir) {
282            log::warn!("Failed to save dependency graph: {e}");
283        }
284    }
285
286    let elapsed = start.elapsed();
287    if !quiet {
288        println!(
289            "Site built in {:.2}s ({} plugin(s))",
290            elapsed.as_secs_f64(),
291            plugins.len()
292        );
293    }
294    Ok(())
295}
296
297/// Compiles the static site from source directories.
298pub fn compile_site(
299    build_dir: &Path,
300    content_dir: &Path,
301    site_dir: &Path,
302    template_dir: &Path,
303) -> Result<()> {
304    compile(build_dir, content_dir, site_dir, template_dir).map_err(|e| {
305        eprintln!("    Error compiling site: {e:?}");
306        anyhow!("Failed to compile site: {e:?}")
307    })
308}
309
310/// Registers the default plugin pipeline.
311///
312/// Plugins execute in registration order. The ordering is:
313/// 1. SEO plugins (meta tags, canonical URLs, robots.txt)
314/// 2. Search index generation
315/// 3. HTML minification (must be last content transform)
316/// 4. Live reload (`on_serve` only)
317pub fn register_default_plugins(
318    plugins: &mut plugin::PluginManager,
319    config: &SsgConfig,
320    include_drafts: bool,
321    deploy_target: Option<&str>,
322) {
323    let base_url = config.base_url.clone();
324
325    // Before-compile plugins
326    plugins.register(content::ContentValidationPlugin);
327    plugins.register(drafts::DraftPlugin::new(include_drafts));
328    plugins.register(shortcodes::ShortcodePlugin);
329
330    // Template engine (must run first in after_compile)
331    #[cfg(feature = "templates")]
332    plugins.register(
333        crate::template_plugin::TemplatePlugin::from_template_dir(
334            &config.template_dir,
335        ),
336    );
337
338    // Post-processing fixes for staticdatagen output (run early,
339    // before SEO plugins read/modify the HTML)
340    plugins.register(postprocess::SitemapFixPlugin);
341    plugins.register(postprocess::NewsSitemapFixPlugin);
342    plugins.register(postprocess::RssAggregatePlugin);
343    plugins.register(postprocess::AtomFeedPlugin);
344    plugins.register(postprocess::ManifestFixPlugin);
345    plugins.register(postprocess::HtmlFixPlugin);
346
347    // Syntax highlighting
348    plugins.register(highlight::HighlightPlugin::default());
349
350    // SEO plugins
351    plugins.register(seo::SeoPlugin);
352    plugins
353        .register(seo::JsonLdPlugin::from_site(&base_url, &config.site_name));
354    plugins.register(seo::CanonicalPlugin::new(base_url.clone()));
355    plugins.register(seo::RobotsPlugin::new(base_url));
356
357    // AI readiness
358    plugins.register(ai::AiPlugin);
359
360    // Taxonomy and pagination
361    plugins.register(taxonomy::TaxonomyPlugin);
362    plugins.register(pagination::PaginationPlugin::default());
363
364    // Search & optimization
365    plugins.register(search::SearchPlugin);
366
367    // Accessibility validation
368    plugins.register(accessibility::AccessibilityPlugin);
369
370    // Image optimization (WebP, responsive srcset)
371    #[cfg(feature = "image-optimization")]
372    plugins.register(crate::image_plugin::ImageOptimizationPlugin::default());
373
374    // I18n hreflang injection and per-locale sitemaps
375    if let Some(ref i18n_cfg) = config.i18n {
376        if i18n_cfg.locales.len() > 1 {
377            plugins.register(i18n::I18nPlugin::new(i18n_cfg.clone()));
378        }
379    }
380
381    // Interactive islands (Web Components)
382    plugins.register(islands::IslandPlugin);
383
384    // CSP hardening: extract inline styles/scripts to external files with SRI
385    plugins.register(csp::CspPlugin);
386
387    // SBOM emission + per-page link (resolves #457). Runs before
388    // FingerprintPlugin so the SBOM filename itself isn't subject to
389    // content-hash renaming (consumers fetch a stable URL).
390    plugins.register(crate::sbom::SbomPlugin);
391
392    // Asset fingerprinting + SRI (after all content transforms)
393    plugins.register(assets::FingerprintPlugin);
394
395    // Minification (must be last content transform)
396    plugins.register(plugins_mod::MinifyPlugin);
397
398    // Deployment config generation (opt-in via --deploy flag)
399    if let Some(target) = deploy_target {
400        let dt = match target {
401            "netlify" => Some(deploy::DeployTarget::Netlify),
402            "vercel" => Some(deploy::DeployTarget::Vercel),
403            "cloudflare" => Some(deploy::DeployTarget::CloudflarePages),
404            "github" => Some(deploy::DeployTarget::GithubPages),
405            _ => {
406                log::warn!("Unknown deploy target: {target}");
407                None
408            }
409        };
410        if let Some(dt) = dt {
411            plugins.register(deploy::DeployPlugin::new(dt));
412        }
413    }
414
415    // Dev server
416    plugins.register(livereload::LiveReloadPlugin::default());
417}
418
419#[cfg(test)]
420#[allow(clippy::unwrap_used, clippy::expect_used)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn test_build_error_serialization() {
426        let err = BuildError {
427            file: Some("content/post.md".to_string()),
428            line: Some(42),
429            message: "unexpected token".to_string(),
430        };
431        let json = err.to_ws_message();
432        let parsed: serde_json::Value =
433            serde_json::from_str(&json).expect("valid JSON");
434        assert_eq!(parsed["type"], "error");
435        assert_eq!(parsed["file"], "content/post.md");
436        assert_eq!(parsed["line"], 42);
437        assert_eq!(parsed["message"], "unexpected token");
438    }
439
440    #[test]
441    fn test_clear_error_message() {
442        let msg = clear_error_message();
443        let parsed: serde_json::Value =
444            serde_json::from_str(&msg).expect("valid JSON");
445        assert_eq!(parsed["type"], "clear-error");
446    }
447
448    #[test]
449    fn test_extract_file_from_error_md() {
450        let msg = "cannot read content/posts/hello.md: permission denied";
451        assert_eq!(
452            extract_file_from_error(msg),
453            Some("content/posts/hello.md".to_string())
454        );
455    }
456
457    #[test]
458    fn test_extract_file_from_error_html() {
459        let msg = "template error in templates/base.html";
460        assert_eq!(
461            extract_file_from_error(msg),
462            Some("templates/base.html".to_string())
463        );
464    }
465
466    #[test]
467    fn test_extract_file_from_error_toml() {
468        let msg = "parse error in config/site.toml at line 5";
469        assert_eq!(
470            extract_file_from_error(msg),
471            Some("config/site.toml".to_string())
472        );
473    }
474
475    #[test]
476    fn test_extract_file_from_error_none() {
477        let msg = "something went wrong with no file path";
478        assert_eq!(extract_file_from_error(msg), None);
479    }
480
481    #[test]
482    fn test_build_error_from_anyhow() {
483        let err = anyhow::anyhow!("cannot write output/index.html: disk full");
484        let be = BuildError::from_anyhow(&err);
485        assert_eq!(be.file, Some("output/index.html".to_string()));
486        assert!(be.line.is_none());
487        assert!(be.message.contains("disk full"));
488    }
489
490    // -----------------------------------------------------------------
491    // BuildError — additional coverage
492    // -----------------------------------------------------------------
493
494    #[test]
495    fn test_build_error_no_file_no_line() {
496        let err = BuildError {
497            file: None,
498            line: None,
499            message: "something broke".to_string(),
500        };
501        let json = err.to_ws_message();
502        let parsed: serde_json::Value =
503            serde_json::from_str(&json).expect("valid JSON");
504        assert_eq!(parsed["type"], "error");
505        assert!(parsed["file"].is_null());
506        assert!(parsed["line"].is_null());
507        assert_eq!(parsed["message"], "something broke");
508    }
509
510    #[test]
511    fn test_build_error_clone() {
512        let err = BuildError {
513            file: Some("a/b.md".to_string()),
514            line: Some(10),
515            message: "oops".to_string(),
516        };
517        let cloned = err.clone();
518        assert_eq!(cloned.file, err.file);
519        assert_eq!(cloned.line, err.line);
520        assert_eq!(cloned.message, err.message);
521    }
522
523    #[test]
524    fn test_build_error_debug() {
525        let err = BuildError {
526            file: None,
527            line: None,
528            message: "debug test".to_string(),
529        };
530        let debug = format!("{err:?}");
531        assert!(debug.contains("BuildError"));
532        assert!(debug.contains("debug test"));
533    }
534
535    #[test]
536    fn test_build_error_from_anyhow_no_file() {
537        let err = anyhow::anyhow!("generic error without any file path");
538        let be = BuildError::from_anyhow(&err);
539        assert!(be.file.is_none());
540        assert!(be.message.contains("generic error"));
541    }
542
543    #[test]
544    fn test_build_error_from_anyhow_yml_extension() {
545        let err = anyhow::anyhow!("parse error in config/site.yml");
546        let be = BuildError::from_anyhow(&err);
547        assert_eq!(be.file, Some("config/site.yml".to_string()));
548    }
549
550    #[test]
551    fn test_build_error_from_anyhow_yaml_extension() {
552        let err = anyhow::anyhow!("error in data/settings.yaml at line 3");
553        let be = BuildError::from_anyhow(&err);
554        assert_eq!(be.file, Some("data/settings.yaml".to_string()));
555    }
556
557    // -----------------------------------------------------------------
558    // extract_file_from_error — additional coverage
559    // -----------------------------------------------------------------
560
561    #[test]
562    fn test_extract_file_with_punctuation_around_path() {
563        let msg = "error: 'templates/base.html' not found";
564        let result = extract_file_from_error(msg);
565        assert_eq!(result, Some("templates/base.html".to_string()));
566    }
567
568    #[test]
569    fn test_extract_file_no_slash_in_word() {
570        let msg = "file not found: base.html";
571        let result = extract_file_from_error(msg);
572        assert!(result.is_none(), "no slash means no file path extraction");
573    }
574
575    #[test]
576    fn test_extract_file_multiple_paths_returns_first() {
577        let msg = "failed to read src/a.md and src/b.html";
578        let result = extract_file_from_error(msg);
579        assert_eq!(result, Some("src/a.md".to_string()));
580    }
581
582    #[test]
583    fn test_extract_file_toml_with_trailing_colon() {
584        let msg = "invalid key in config/site.toml: 'foo'";
585        let result = extract_file_from_error(msg);
586        assert_eq!(result, Some("config/site.toml".to_string()));
587    }
588
589    // -----------------------------------------------------------------
590    // clear_error_message — sanity
591    // -----------------------------------------------------------------
592
593    #[test]
594    fn test_clear_error_message_is_valid_json() {
595        let msg = clear_error_message();
596        let parsed: serde_json::Value =
597            serde_json::from_str(&msg).expect("valid JSON");
598        assert_eq!(parsed["type"], "clear-error");
599        // Ensure no extra keys leak
600        assert_eq!(parsed.as_object().unwrap().len(), 1);
601    }
602
603    // -----------------------------------------------------------------
604    // resolve_build_and_site_dirs — coverage from pipeline module
605    // -----------------------------------------------------------------
606
607    #[test]
608    fn test_resolve_dirs_no_serve_dir() {
609        use crate::cmd::SsgConfig;
610        use std::path::PathBuf;
611        let mut config = SsgConfig::default();
612        config.output_dir = PathBuf::from("out");
613        config.serve_dir = None;
614
615        let (build, site) = resolve_build_and_site_dirs(&config);
616        assert_eq!(site, PathBuf::from("out"));
617        // build should differ from site
618        assert_ne!(build, site);
619    }
620
621    #[test]
622    fn test_resolve_dirs_serve_differs_from_output() {
623        use crate::cmd::SsgConfig;
624        use std::path::PathBuf;
625        let mut config = SsgConfig::default();
626        config.output_dir = PathBuf::from("build");
627        config.serve_dir = Some(PathBuf::from("public"));
628
629        let (build, site) = resolve_build_and_site_dirs(&config);
630        assert_eq!(build, PathBuf::from("build"));
631        assert_eq!(site, PathBuf::from("public"));
632    }
633
634    #[test]
635    fn test_resolve_dirs_serve_equals_output() {
636        use crate::cmd::SsgConfig;
637        use std::path::PathBuf;
638        let mut config = SsgConfig::default();
639        config.output_dir = PathBuf::from("dist");
640        config.serve_dir = Some(PathBuf::from("dist"));
641
642        let (build, site) = resolve_build_and_site_dirs(&config);
643        assert_eq!(site, PathBuf::from("dist"));
644        assert_ne!(build, site);
645        assert!(build.to_string_lossy().contains("build-tmp"));
646    }
647
648    // -----------------------------------------------------------------
649    // RunOptions — construction from matches
650    // -----------------------------------------------------------------
651
652    #[test]
653    fn test_run_options_defaults() {
654        use crate::cmd::Cli;
655        let cli = Cli::build();
656        let matches = cli.try_get_matches_from(vec!["ssg"]).unwrap();
657        let opts = RunOptions::from_matches(&matches);
658
659        assert!(!opts.quiet);
660        assert!(!opts.include_drafts);
661        assert!(opts.deploy_target.is_none());
662        assert!(!opts.validate_only);
663        assert!(opts.jobs.is_none());
664        assert!(opts.max_memory_mb.is_none());
665        assert!(!opts.ai_fix);
666        assert!(!opts.ai_fix_dry_run);
667    }
668
669    #[test]
670    fn test_run_options_ai_fix_flags() {
671        use crate::cmd::Cli;
672        let cli = Cli::build();
673        let matches = cli
674            .try_get_matches_from(vec!["ssg", "--ai-fix", "--ai-fix-dry-run"])
675            .unwrap();
676        let opts = RunOptions::from_matches(&matches);
677        assert!(opts.ai_fix);
678        assert!(opts.ai_fix_dry_run);
679    }
680
681    #[test]
682    fn test_run_options_debug() {
683        use crate::cmd::Cli;
684        let cli = Cli::build();
685        let matches = cli.try_get_matches_from(vec!["ssg"]).unwrap();
686        let opts = RunOptions::from_matches(&matches);
687        let debug = format!("{opts:?}");
688        assert!(debug.contains("RunOptions"));
689        assert!(debug.contains("quiet"));
690    }
691
692    #[test]
693    fn test_run_options_clone() {
694        use crate::cmd::Cli;
695        let cli = Cli::build();
696        let matches = cli
697            .try_get_matches_from(vec!["ssg", "--quiet", "--jobs", "2"])
698            .unwrap();
699        let opts = RunOptions::from_matches(&matches);
700        let cloned = opts.clone();
701        assert_eq!(cloned.quiet, opts.quiet);
702        assert_eq!(cloned.jobs, opts.jobs);
703    }
704
705    // -----------------------------------------------------------------
706    // register_default_plugins — plugin count and ordering
707    // -----------------------------------------------------------------
708
709    #[test]
710    fn test_register_default_plugins_minimum_count() {
711        use crate::cmd::SsgConfig;
712        use crate::plugin::PluginManager;
713
714        let config = SsgConfig::default();
715        let mut pm = PluginManager::new();
716        register_default_plugins(&mut pm, &config, false, None);
717
718        // We expect a substantial number of default plugins
719        assert!(
720            pm.len() >= 15,
721            "expected at least 15 default plugins, got {}",
722            pm.len()
723        );
724    }
725
726    #[test]
727    fn test_register_default_plugins_includes_key_plugins() {
728        use crate::cmd::SsgConfig;
729        use crate::plugin::PluginManager;
730
731        let config = SsgConfig::default();
732        let mut pm = PluginManager::new();
733        register_default_plugins(&mut pm, &config, false, None);
734
735        let names = pm.names();
736        assert!(names.contains(&"content-validation"));
737        assert!(names.contains(&"drafts"));
738        assert!(names.contains(&"shortcodes"));
739        assert!(names.contains(&"seo"));
740        assert!(names.contains(&"search"));
741        assert!(names.contains(&"minify"));
742        assert!(names.contains(&"livereload"));
743    }
744
745    #[test]
746    fn test_register_default_plugins_with_deploy_adds_deploy_plugin() {
747        use crate::cmd::SsgConfig;
748        use crate::plugin::PluginManager;
749
750        let config = SsgConfig::default();
751        let mut pm_without = PluginManager::new();
752        register_default_plugins(&mut pm_without, &config, false, None);
753        let count_without = pm_without.len();
754
755        let mut pm_with = PluginManager::new();
756        register_default_plugins(&mut pm_with, &config, false, Some("netlify"));
757
758        assert_eq!(pm_with.len(), count_without + 1);
759        assert!(pm_with.names().contains(&"deploy"));
760    }
761
762    #[test]
763    fn test_register_default_plugins_unknown_deploy_skipped() {
764        use crate::cmd::SsgConfig;
765        use crate::plugin::PluginManager;
766
767        let config = SsgConfig::default();
768        let mut pm = PluginManager::new();
769        register_default_plugins(
770            &mut pm,
771            &config,
772            false,
773            Some("nonexistent-platform"),
774        );
775
776        assert!(
777            !pm.names().contains(&"deploy"),
778            "unknown deploy target should not register a deploy plugin"
779        );
780    }
781
782    // -----------------------------------------------------------------
783    // build_pipeline — basic wiring
784    // -----------------------------------------------------------------
785
786    #[test]
787    fn test_build_pipeline_returns_valid_dirs() {
788        use crate::cmd::SsgConfig;
789
790        let temp = tempfile::tempdir().unwrap();
791        let mut config = SsgConfig::default();
792        config.content_dir = temp.path().join("content");
793        config.output_dir = temp.path().join("public");
794        config.template_dir = temp.path().join("templates");
795
796        let opts = RunOptions {
797            quiet: true,
798            include_drafts: false,
799            deploy_target: None,
800            validate_only: false,
801            jobs: None,
802            max_memory_mb: None,
803            ai_fix: false,
804            ai_fix_dry_run: false,
805        };
806
807        let (plugins, ctx, build_dir, site_dir) =
808            build_pipeline(&config, &opts);
809
810        assert!(!plugins.is_empty());
811        assert_ne!(build_dir, site_dir);
812        assert_eq!(ctx.content_dir, temp.path().join("content"));
813    }
814}