Skip to main content

ssg/core/
deploy.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Deployment adapter generation.
5//!
6//! Generates platform-specific configuration files for common hosting
7//! providers, including cache headers and security headers.
8
9use crate::error::SsgError;
10use crate::plugin::{Plugin, PluginContext};
11use anyhow::Result;
12use std::fs;
13
14/// Supported deployment targets.
15///
16/// Marked `#[non_exhaustive]` so future targets (AWS, Azure Static Web
17/// Apps, Cloudflare R2 sites) can be added without a major version bump.
18/// Downstream consumers must use a wildcard arm in `match` expressions.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum DeployTarget {
22    /// Netlify (`netlify.toml`).
23    Netlify,
24    /// Vercel (`vercel.json`).
25    Vercel,
26    /// Cloudflare Pages (`_headers`, `_redirects`).
27    CloudflarePages,
28    /// GitHub Pages (`.nojekyll`, `CNAME`).
29    GithubPages,
30}
31
32/// Plugin that generates deployment configuration files.
33#[derive(Debug, Clone, Copy)]
34pub struct DeployPlugin {
35    target: DeployTarget,
36}
37
38impl DeployPlugin {
39    /// Creates a new `DeployPlugin` for the given target.
40    ///
41    /// # Examples
42    ///
43    /// ```rust
44    /// use ssg::deploy::{DeployPlugin, DeployTarget};
45    /// use ssg::plugin::Plugin;
46    ///
47    /// let p = DeployPlugin::new(DeployTarget::Netlify);
48    /// assert_eq!(p.name(), "deploy");
49    /// ```
50    #[must_use]
51    pub const fn new(target: DeployTarget) -> Self {
52        Self { target }
53    }
54}
55
56impl Plugin for DeployPlugin {
57    fn name(&self) -> &'static str {
58        "deploy"
59    }
60
61    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
62        if !ctx.site_dir.exists() {
63            return Ok(());
64        }
65
66        match self.target {
67            DeployTarget::Netlify => generate_netlify(&ctx.site_dir)
68                .map_err(|e| SsgError::io(e, &ctx.site_dir))?,
69            DeployTarget::Vercel => generate_vercel(&ctx.site_dir)
70                .map_err(|e| SsgError::io(e, &ctx.site_dir))?,
71            DeployTarget::CloudflarePages => {
72                generate_cloudflare(&ctx.site_dir)
73                    .map_err(|e| SsgError::io(e, &ctx.site_dir))?;
74            }
75            DeployTarget::GithubPages => {
76                generate_github_pages(&ctx.site_dir)
77                    .map_err(|e| SsgError::io(e, &ctx.site_dir))?;
78            }
79        }
80
81        log::info!("[deploy] Generated {:?} config", self.target);
82        Ok(())
83    }
84}
85
86/// Security headers shared across all platforms.
87///
88/// The `Content-Security-Policy` value is sourced from
89/// [`crate::csp::computed_policy`] so the deploy adapter and the
90/// `<meta>`-injecting CSP plugin stay in lock-step — and so the new
91/// `edge_headers` postprocess plugin (issue #550) has a single source
92/// of truth for the CSP string.
93const fn security_headers() -> [(&'static str, &'static str); 7] {
94    [
95        ("X-Content-Type-Options", "nosniff"),
96        ("X-Frame-Options", "DENY"),
97        ("X-XSS-Protection", "1; mode=block"),
98        ("Referrer-Policy", "strict-origin-when-cross-origin"),
99        (
100            "Permissions-Policy",
101            "camera=(), microphone=(), geolocation=()",
102        ),
103        ("Content-Security-Policy", crate::csp::computed_policy()),
104        (
105            "Strict-Transport-Security",
106            "max-age=31536000; includeSubDomains",
107        ),
108    ]
109}
110
111fn generate_netlify(site_dir: &std::path::Path) -> Result<()> {
112    let mut headers = String::from("/*\n");
113    for (k, v) in security_headers() {
114        headers.push_str(&format!("  {k} = {v}\n"));
115    }
116    // Content-addressable assets (CSS/JS/images/fonts) — see issue
117    // #468. The fingerprint plugin renames every *.{css,js,png,jpg,
118    // webp,svg,woff2,...} to *.<hash>.<ext>, so each emit is
119    // intrinsically immutable.
120    headers.push_str(
121        "\n/assets/*\n  Cache-Control: public, max-age=31536000, immutable\n",
122    );
123    for ext in [
124        "css", "js", "mjs", "png", "jpg", "jpeg", "webp", "avif", "gif", "svg",
125        "woff", "woff2",
126    ] {
127        headers.push_str(&format!(
128            "\n/*.{ext}\n  Cache-Control: public, max-age=31536000, immutable\n"
129        ));
130    }
131    // HTML and feed/index files always revalidate so content updates
132    // are visible immediately.
133    headers.push_str("\n/*.html\n  Cache-Control: no-cache, must-revalidate\n");
134    for path in [
135        "/sitemap.xml",
136        "/sitemap-news.xml",
137        "/atom.xml",
138        "/rss.xml",
139        "/manifest.json",
140        "/robots.txt",
141        "/search-index.json",
142    ] {
143        headers.push_str(&format!(
144            "\n{path}\n  Cache-Control: no-cache, must-revalidate\n"
145        ));
146    }
147
148    fs::write(site_dir.join("_headers"), &headers)?;
149    fs::write(site_dir.join("_redirects"), "")?;
150
151    let toml = r#"[build]
152  publish = "public"
153  command = "cargo run -- -c content -o public -t templates"
154
155[[headers]]
156  for = "/assets/*"
157  [headers.values]
158    Cache-Control = "public, max-age=31536000, immutable"
159"#;
160    fs::write(site_dir.join("netlify.toml"), toml)?;
161    Ok(())
162}
163
164fn generate_vercel(site_dir: &std::path::Path) -> Result<()> {
165    let mut headers_arr = Vec::new();
166    for (k, v) in security_headers() {
167        headers_arr.push(serde_json::json!({"key": k, "value": v}));
168    }
169
170    // Per-extension immutable headers for content-addressable assets
171    // (#468). The fingerprint plugin renames these to name.hash.ext
172    // so the file body is bound to its name.
173    let immutable = serde_json::json!([
174        {"key": "Cache-Control", "value": "public, max-age=31536000, immutable"}
175    ]);
176    let no_cache = serde_json::json!([
177        {"key": "Cache-Control", "value": "no-cache, must-revalidate"}
178    ]);
179
180    let config = serde_json::json!({
181        "headers": [
182            {"source": "/(.*)", "headers": headers_arr},
183            {"source": "/assets/(.*)", "headers": immutable},
184            {"source": "/(.*)\\.(css|js|mjs|png|jpg|jpeg|webp|avif|gif|svg|woff|woff2)",
185             "headers": immutable},
186            {"source": "/(.*)\\.html", "headers": no_cache},
187            {"source": "/(sitemap|sitemap-news|atom|rss)\\.xml",
188             "headers": no_cache},
189            {"source": "/(manifest|search-index)\\.json", "headers": no_cache},
190            {"source": "/robots\\.txt", "headers": no_cache}
191        ]
192    });
193
194    let json = serde_json::to_string_pretty(&config)?;
195    fs::write(site_dir.join("vercel.json"), json)?;
196    Ok(())
197}
198
199fn generate_cloudflare(site_dir: &std::path::Path) -> Result<()> {
200    let mut headers = String::from("/*\n");
201    for (k, v) in security_headers() {
202        headers.push_str(&format!("  {k} : {v}\n"));
203    }
204    // Content-addressable assets (#468) — same set as Netlify.
205    headers.push_str(
206        "\n/assets/*\n  Cache-Control: public, max-age=31536000, immutable\n",
207    );
208    for ext in [
209        "css", "js", "mjs", "png", "jpg", "jpeg", "webp", "avif", "gif", "svg",
210        "woff", "woff2",
211    ] {
212        headers.push_str(&format!(
213            "\n/*.{ext}\n  Cache-Control: public, max-age=31536000, immutable\n"
214        ));
215    }
216    headers.push_str("\n/*.html\n  Cache-Control: no-cache, must-revalidate\n");
217    for path in [
218        "/sitemap.xml",
219        "/atom.xml",
220        "/rss.xml",
221        "/manifest.json",
222        "/robots.txt",
223    ] {
224        headers.push_str(&format!(
225            "\n{path}\n  Cache-Control: no-cache, must-revalidate\n"
226        ));
227    }
228
229    fs::write(site_dir.join("_headers"), &headers)?;
230    fs::write(site_dir.join("_redirects"), "")?;
231    Ok(())
232}
233
234fn generate_github_pages(site_dir: &std::path::Path) -> Result<()> {
235    // .nojekyll prevents GitHub Pages from processing with Jekyll
236    fs::write(site_dir.join(".nojekyll"), "")?;
237    Ok(())
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::test_support::init_logger;
244    use std::path::{Path, PathBuf};
245    use tempfile::{tempdir, TempDir};
246
247    // -------------------------------------------------------------------
248    // Test fixtures
249    // -------------------------------------------------------------------
250
251    /// Builds a fresh temp dir containing a `site/` subdirectory and a
252    /// `PluginContext` pointing at it. Used by every plugin-trait test.
253    fn make_ctx_with_site() -> (TempDir, PathBuf, PluginContext) {
254        init_logger();
255        let dir = tempdir().expect("create tempdir");
256        let site = dir.path().join("site");
257        fs::create_dir_all(&site).expect("create site dir");
258        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
259        (dir, site, ctx)
260    }
261
262    /// Asserts that *every* security header documented in
263    /// [`security_headers`] appears verbatim (key + value) inside `body`.
264    fn assert_all_security_headers_present(body: &str) {
265        for (k, v) in security_headers() {
266            assert!(
267                body.contains(k),
268                "missing header key `{k}` in body:\n{body}"
269            );
270            assert!(
271                body.contains(v),
272                "missing header value `{v}` in body:\n{body}"
273            );
274        }
275    }
276
277    // -------------------------------------------------------------------
278    // DeployTarget — derives, equality, copy semantics
279    // -------------------------------------------------------------------
280
281    #[test]
282    fn deploy_target_equality_reflexive_for_each_variant() {
283        // Arrange
284        let variants = [
285            DeployTarget::Netlify,
286            DeployTarget::Vercel,
287            DeployTarget::CloudflarePages,
288            DeployTarget::GithubPages,
289        ];
290
291        // Act + Assert: every variant is equal to itself.
292        for v in variants {
293            assert_eq!(v, v, "{v:?} should equal itself");
294        }
295    }
296
297    #[test]
298    fn deploy_target_distinct_variants_are_not_equal() {
299        // Distinct variants must compare unequal — guards against
300        // accidental duplicate discriminants if the enum is reordered.
301        assert_ne!(DeployTarget::Netlify, DeployTarget::Vercel);
302        assert_ne!(DeployTarget::Vercel, DeployTarget::CloudflarePages);
303        assert_ne!(DeployTarget::CloudflarePages, DeployTarget::GithubPages);
304        assert_ne!(DeployTarget::GithubPages, DeployTarget::Netlify);
305    }
306
307    #[test]
308    fn deploy_target_every_unordered_pair_is_not_equal() {
309        // The test above only walks one adjacent cycle (4 of the 6
310        // unordered pairs over 4 variants). Exercise the two remaining
311        // pairs (Netlify/CloudflarePages and Vercel/GithubPages) too,
312        // so every branch of the derived `PartialEq` impl has actually
313        // been driven by at least one inequality comparison.
314        let variants = [
315            DeployTarget::Netlify,
316            DeployTarget::Vercel,
317            DeployTarget::CloudflarePages,
318            DeployTarget::GithubPages,
319        ];
320        for (i, a) in variants.iter().enumerate() {
321            for b in &variants[i + 1..] {
322                assert_ne!(a, b, "{a:?} should not equal {b:?}");
323            }
324        }
325    }
326
327    #[test]
328    fn deploy_target_is_copy_after_move() {
329        // Verifies the `Copy` derive is in effect: the binding remains
330        // usable after being passed by value.
331        let target = DeployTarget::Netlify;
332        let _copy = target;
333        assert_eq!(target, DeployTarget::Netlify);
334    }
335
336    #[test]
337    fn deploy_target_debug_format_contains_variant_name() {
338        assert!(format!("{:?}", DeployTarget::Netlify).contains("Netlify"));
339        assert!(format!("{:?}", DeployTarget::Vercel).contains("Vercel"));
340        assert!(format!("{:?}", DeployTarget::CloudflarePages)
341            .contains("CloudflarePages"));
342        assert!(
343            format!("{:?}", DeployTarget::GithubPages).contains("GithubPages")
344        );
345    }
346
347    // -------------------------------------------------------------------
348    // DeployPlugin — constructor & trait surface
349    // -------------------------------------------------------------------
350
351    #[test]
352    fn new_constructs_plugin_for_every_target_variant() {
353        // Table-driven: every DeployTarget must be a valid argument to
354        // `DeployPlugin::new` and must round-trip through the field.
355        let cases = [
356            DeployTarget::Netlify,
357            DeployTarget::Vercel,
358            DeployTarget::CloudflarePages,
359            DeployTarget::GithubPages,
360        ];
361        for target in cases {
362            let plugin = DeployPlugin::new(target);
363            assert_eq!(
364                plugin.target, target,
365                "constructor must store the supplied target"
366            );
367        }
368    }
369
370    #[test]
371    fn name_returns_static_deploy_identifier() {
372        // The plugin name is part of the public contract — registries
373        // and log lines key off it, so it must be stable.
374        let plugin = DeployPlugin::new(DeployTarget::Netlify);
375        assert_eq!(plugin.name(), "deploy");
376    }
377
378    #[test]
379    fn deploy_plugin_is_copy_after_move() {
380        let plugin = DeployPlugin::new(DeployTarget::Vercel);
381        let _copy = plugin;
382        assert_eq!(plugin.name(), "deploy");
383    }
384
385    #[test]
386    fn deploy_plugin_debug_format_contains_type_name() {
387        // `DeployPlugin` derives `Debug` but no existing test formats
388        // it directly (only `DeployTarget`'s `Debug` was exercised).
389        let plugin = DeployPlugin::new(DeployTarget::CloudflarePages);
390        let formatted = format!("{plugin:?}");
391        assert!(formatted.contains("DeployPlugin"), "got: {formatted}");
392        assert!(formatted.contains("CloudflarePages"), "got: {formatted}");
393    }
394
395    // -------------------------------------------------------------------
396    // after_compile — short-circuit on missing site directory
397    // -------------------------------------------------------------------
398
399    #[test]
400    fn after_compile_missing_site_dir_returns_ok_without_writing() {
401        // The hook must be a no-op when the build hasn't produced a
402        // site directory yet. This guards the early-return at line 46.
403        let dir = tempdir().expect("tempdir");
404        let missing_site = dir.path().join("does-not-exist");
405        let ctx = PluginContext::new(
406            dir.path(),
407            dir.path(),
408            &missing_site,
409            dir.path(),
410        );
411
412        let plugin = DeployPlugin::new(DeployTarget::Netlify);
413        plugin
414            .after_compile(&ctx)
415            .expect("missing site_dir is not an error");
416
417        // Nothing should have been created.
418        assert!(!missing_site.exists());
419        assert!(!dir.path().join("_headers").exists());
420        assert!(!dir.path().join("netlify.toml").exists());
421    }
422
423    // -------------------------------------------------------------------
424    // after_compile — full trait dispatch for every target
425    // -------------------------------------------------------------------
426
427    #[test]
428    fn after_compile_netlify_writes_all_expected_artifacts() {
429        let (_tmp, site, ctx) = make_ctx_with_site();
430        DeployPlugin::new(DeployTarget::Netlify)
431            .after_compile(&ctx)
432            .expect("netlify after_compile");
433
434        for f in ["_headers", "_redirects", "netlify.toml"] {
435            assert!(
436                site.join(f).exists(),
437                "Netlify dispatch must produce `{f}`"
438            );
439        }
440    }
441
442    #[test]
443    fn after_compile_vercel_writes_well_formed_json() {
444        let (_tmp, site, ctx) = make_ctx_with_site();
445        DeployPlugin::new(DeployTarget::Vercel)
446            .after_compile(&ctx)
447            .expect("vercel after_compile");
448
449        let raw = fs::read_to_string(site.join("vercel.json"))
450            .expect("vercel.json should exist");
451        let parsed: serde_json::Value =
452            serde_json::from_str(&raw).expect("vercel.json must be valid JSON");
453        assert!(
454            parsed.get("headers").and_then(|v| v.as_array()).is_some(),
455            "vercel.json must have a top-level `headers` array"
456        );
457    }
458
459    #[test]
460    fn after_compile_cloudflare_writes_headers_and_redirects() {
461        let (_tmp, site, ctx) = make_ctx_with_site();
462        DeployPlugin::new(DeployTarget::CloudflarePages)
463            .after_compile(&ctx)
464            .expect("cloudflare after_compile");
465
466        assert!(site.join("_headers").exists());
467        assert!(site.join("_redirects").exists());
468    }
469
470    #[test]
471    fn after_compile_github_pages_writes_only_nojekyll() {
472        let (_tmp, site, ctx) = make_ctx_with_site();
473        DeployPlugin::new(DeployTarget::GithubPages)
474            .after_compile(&ctx)
475            .expect("github pages after_compile");
476
477        assert!(site.join(".nojekyll").exists());
478        // GitHub Pages dispatch should NOT touch the Netlify/Vercel/CF
479        // artifacts — guard against cross-contamination if a future
480        // refactor accidentally calls multiple generators.
481        assert!(!site.join("_headers").exists());
482        assert!(!site.join("netlify.toml").exists());
483        assert!(!site.join("vercel.json").exists());
484    }
485
486    // -------------------------------------------------------------------
487    // Generators — header content completeness
488    // -------------------------------------------------------------------
489
490    #[test]
491    fn generate_netlify_headers_file_contains_every_security_header() {
492        let dir = tempdir().expect("tempdir");
493        generate_netlify(dir.path()).expect("generate netlify");
494
495        let body = fs::read_to_string(dir.path().join("_headers"))
496            .expect("read _headers");
497        assert_all_security_headers_present(&body);
498    }
499
500    #[test]
501    fn generate_netlify_headers_file_contains_cache_directives() {
502        let dir = tempdir().expect("tempdir");
503        generate_netlify(dir.path()).expect("generate netlify");
504
505        let body = fs::read_to_string(dir.path().join("_headers"))
506            .expect("read _headers");
507        // Issue #468: long-lived immutable for content-addressable
508        // assets (CSS/JS/images/fonts) + always-revalidate for HTML
509        // and feeds.
510        assert!(body.contains("/assets/*"));
511        assert!(body.contains("max-age=31536000"));
512        assert!(body.contains("immutable"));
513        assert!(body.contains("/*.html"));
514        assert!(body.contains("no-cache"));
515        // Per-extension immutable rules now exist for the wider asset set.
516        assert!(body.contains("/*.png"));
517        assert!(body.contains("/*.woff2"));
518    }
519
520    #[test]
521    fn generate_netlify_toml_contains_build_publish_directive() {
522        let dir = tempdir().expect("tempdir");
523        generate_netlify(dir.path()).expect("generate netlify");
524
525        let toml = fs::read_to_string(dir.path().join("netlify.toml"))
526            .expect("read netlify.toml");
527        assert!(toml.contains("[build]"));
528        assert!(toml.contains("publish"));
529        assert!(toml.contains("[[headers]]"));
530    }
531
532    #[test]
533    fn generate_netlify_creates_empty_redirects_file() {
534        let dir = tempdir().expect("tempdir");
535        generate_netlify(dir.path()).expect("generate netlify");
536
537        let redirects = fs::read_to_string(dir.path().join("_redirects"))
538            .expect("read _redirects");
539        assert!(redirects.is_empty(), "_redirects starts empty by design");
540    }
541
542    #[test]
543    fn generate_vercel_json_contains_every_security_header_value() {
544        let dir = tempdir().expect("tempdir");
545        generate_vercel(dir.path()).expect("generate vercel");
546
547        let json = fs::read_to_string(dir.path().join("vercel.json"))
548            .expect("read vercel.json");
549        assert_all_security_headers_present(&json);
550    }
551
552    #[test]
553    fn generate_vercel_json_has_asset_cache_route() {
554        let dir = tempdir().expect("tempdir");
555        generate_vercel(dir.path()).expect("generate vercel");
556
557        let raw = fs::read_to_string(dir.path().join("vercel.json"))
558            .expect("read vercel.json");
559        let parsed: serde_json::Value =
560            serde_json::from_str(&raw).expect("valid JSON");
561
562        let routes = parsed["headers"].as_array().expect("headers is an array");
563        let sources: Vec<&str> =
564            routes.iter().filter_map(|r| r["source"].as_str()).collect();
565        assert!(sources.iter().any(|s| s.contains("/assets/")));
566        assert!(sources.iter().any(|s| s.contains("/(.*)")));
567    }
568
569    #[test]
570    fn generate_cloudflare_headers_file_uses_colon_separator() {
571        // Cloudflare's _headers syntax differs from Netlify's: it
572        // uses `Key: Value` rather than `Key = Value`. Guard the
573        // separator to prevent silent regressions.
574        let dir = tempdir().expect("tempdir");
575        generate_cloudflare(dir.path()).expect("generate cloudflare");
576
577        let body = fs::read_to_string(dir.path().join("_headers"))
578            .expect("read _headers");
579        assert!(body.contains("X-Content-Type-Options : nosniff"));
580        assert_all_security_headers_present(&body);
581    }
582
583    #[test]
584    fn generate_cloudflare_writes_empty_redirects_file() {
585        let dir = tempdir().expect("tempdir");
586        generate_cloudflare(dir.path()).expect("generate cloudflare");
587
588        let redirects = fs::read_to_string(dir.path().join("_redirects"))
589            .expect("read _redirects");
590        assert!(redirects.is_empty());
591    }
592
593    #[test]
594    fn generate_github_pages_writes_empty_nojekyll_marker() {
595        let dir = tempdir().expect("tempdir");
596        generate_github_pages(dir.path()).expect("generate github pages");
597
598        let nojekyll = dir.path().join(".nojekyll");
599        assert!(nojekyll.exists());
600        let contents = fs::read_to_string(&nojekyll).expect("read .nojekyll");
601        assert!(
602            contents.is_empty(),
603            ".nojekyll is a marker file and must be empty"
604        );
605    }
606
607    // -------------------------------------------------------------------
608    // Idempotency — running the plugin twice must succeed
609    // -------------------------------------------------------------------
610
611    #[test]
612    fn after_compile_idempotent_for_every_target() {
613        // Re-running after_compile must not fail (file overwrite, not
614        // append). Guards against any future use of `OpenOptions::new
615        // ().create_new(true)` that would break re-builds.
616        for target in [
617            DeployTarget::Netlify,
618            DeployTarget::Vercel,
619            DeployTarget::CloudflarePages,
620            DeployTarget::GithubPages,
621        ] {
622            let (_tmp, _site, ctx) = make_ctx_with_site();
623            let plugin = DeployPlugin::new(target);
624            plugin
625                .after_compile(&ctx)
626                .expect("first after_compile for target should succeed");
627            plugin
628                .after_compile(&ctx)
629                .expect("second after_compile for target should succeed");
630        }
631    }
632
633    // -------------------------------------------------------------------
634    // Generator error paths — writing into a non-existent parent dir
635    // -------------------------------------------------------------------
636
637    #[test]
638    fn generate_netlify_into_missing_parent_returns_err() {
639        let bogus = Path::new("/this/path/should/not/exist/ssg-test");
640        let result = generate_netlify(bogus);
641        assert!(
642            result.is_err(),
643            "writing into a non-existent parent must error"
644        );
645    }
646
647    #[test]
648    fn generate_vercel_into_missing_parent_returns_err() {
649        let bogus = Path::new("/this/path/should/not/exist/ssg-test");
650        assert!(generate_vercel(bogus).is_err());
651    }
652
653    #[test]
654    fn generate_cloudflare_into_missing_parent_returns_err() {
655        let bogus = Path::new("/this/path/should/not/exist/ssg-test");
656        assert!(generate_cloudflare(bogus).is_err());
657    }
658
659    #[test]
660    fn generate_github_pages_into_missing_parent_returns_err() {
661        let bogus = Path::new("/this/path/should/not/exist/ssg-test");
662        assert!(generate_github_pages(bogus).is_err());
663    }
664
665    // -------------------------------------------------------------------
666    // after_compile error mapping — generator failure surfaces as
667    // SsgError::Io via the per-target `map_err` closures
668    // -------------------------------------------------------------------
669
670    #[test]
671    #[cfg(unix)]
672    fn after_compile_maps_generator_errors_for_every_target() {
673        use std::os::unix::fs::PermissionsExt;
674        for target in [
675            DeployTarget::Netlify,
676            DeployTarget::Vercel,
677            DeployTarget::CloudflarePages,
678            DeployTarget::GithubPages,
679        ] {
680            let (_tmp, site, ctx) = make_ctx_with_site();
681            // Site dir exists but is read-only, so every generator's
682            // first write fails and the `map_err(|e| SsgError::io(..))`
683            // closure for this target runs.
684            fs::set_permissions(&site, fs::Permissions::from_mode(0o555))
685                .expect("chmod site dir");
686
687            let res = DeployPlugin::new(target).after_compile(&ctx);
688
689            let _ =
690                fs::set_permissions(&site, fs::Permissions::from_mode(0o755));
691            // Root bypasses permissions on some CI runners, so
692            // tolerate Ok; when the failure fired, the error must
693            // render non-empty.
694            assert!(
695                res.err().is_none_or(|e| !format!("{e}").is_empty()),
696                "{target:?}"
697            );
698        }
699    }
700
701    // -------------------------------------------------------------------
702    // Generator error paths — later writes in each generator
703    // -------------------------------------------------------------------
704
705    #[test]
706    fn generate_netlify_redirects_write_failure_propagates() {
707        // `_headers` succeeds, `_redirects` fails because a directory
708        // occupies that name (line 149).
709        let dir = tempdir().expect("tempdir");
710        fs::create_dir_all(dir.path().join("_redirects")).unwrap();
711        assert!(generate_netlify(dir.path()).is_err());
712    }
713
714    #[test]
715    fn generate_netlify_toml_write_failure_propagates() {
716        // `_headers` and `_redirects` succeed, `netlify.toml` fails
717        // (line 160).
718        let dir = tempdir().expect("tempdir");
719        fs::create_dir_all(dir.path().join("netlify.toml")).unwrap();
720        assert!(generate_netlify(dir.path()).is_err());
721    }
722
723    #[test]
724    fn generate_cloudflare_redirects_write_failure_propagates() {
725        // `_headers` succeeds, `_redirects` fails (line 230).
726        let dir = tempdir().expect("tempdir");
727        fs::create_dir_all(dir.path().join("_redirects")).unwrap();
728        assert!(generate_cloudflare(dir.path()).is_err());
729    }
730}