Skip to main content

ssg/core/
deploy_adapter.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Deploy adapter trait + per-target stubs for the `ssg deploy`
5//! subcommand (issue #527 AC4).
6//!
7//! The legacy [`crate::deploy::DeployPlugin`] generates platform-specific
8//! configuration files (`netlify.toml`, `vercel.json`, …) **at build
9//! time**. This module is responsible for the *upload* half — taking a
10//! freshly built `site_dir` and shipping it to the target platform.
11//!
12//! Each adapter is a stub at the moment: it prints
13//! `"deploy adapter for <target>: not yet implemented; see #527"` to
14//! stderr and exits cleanly. The wiring and CLI surface are in place
15//! so we can land the rest of the implementation behind it without
16//! breaking the `ssg deploy --target …` API.
17//!
18//! ## Stability
19//!
20//! [`Target`] is `#[non_exhaustive]` — new targets can be added in
21//! minor releases. Downstream `match` arms must include a wildcard.
22
23use crate::error::SsgError;
24use std::path::Path;
25
26/// Deploy targets accepted by `ssg deploy --target <TARGET>`.
27///
28/// Kept in lockstep with [`crate::cmd::DEPLOY_TARGETS`] (the CLI-facing
29/// list); this enum is the typed view used inside the deploy pipeline.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31#[non_exhaustive]
32pub enum Target {
33    /// Netlify static-site hosting (`SSG_NETLIFY_TOKEN`).
34    Netlify,
35    /// Vercel static deployment (`SSG_VERCEL_TOKEN`).
36    Vercel,
37    /// Cloudflare Pages direct-upload (`SSG_CLOUDFLARE_TOKEN`).
38    CloudflarePages,
39    /// GitHub Pages branch push (`SSG_GITHUB_TOKEN`).
40    GithubPages,
41    /// Amazon S3 bucket sync (`SSG_S3_BUCKET`, `AWS_*`).
42    S3,
43    /// No-op: build only, do not upload. Useful for CI dry-runs.
44    None,
45}
46
47impl Target {
48    /// Parses a CLI string into a [`Target`].
49    ///
50    /// Unknown values are rejected by clap before reaching here (see
51    /// `Cli::subcommand_app` -> `--target` `PossibleValuesParser`),
52    /// but we treat an unknown input as a defensive validation error
53    /// rather than panic.
54    ///
55    /// # Examples
56    ///
57    /// ```rust
58    /// use ssg::deploy_adapter::Target;
59    ///
60    /// assert_eq!(Target::from_cli("netlify").unwrap(), Target::Netlify);
61    /// assert!(Target::from_cli("not-a-target").is_err());
62    /// ```
63    ///
64    /// # Errors
65    /// Returns [`SsgError::Validation`] if `name` is not one of the
66    /// six supported targets.
67    pub fn from_cli(name: &str) -> Result<Self, SsgError> {
68        match name {
69            "netlify" => Ok(Self::Netlify),
70            "vercel" => Ok(Self::Vercel),
71            "cloudflare-pages" => Ok(Self::CloudflarePages),
72            "github-pages" => Ok(Self::GithubPages),
73            "s3" => Ok(Self::S3),
74            "none" => Ok(Self::None),
75            other => Err(SsgError::Validation {
76                field: "deploy.target".to_string(),
77                message: format!("unknown deploy target: {other}"),
78            }),
79        }
80    }
81
82    /// Returns the canonical CLI name for this target.
83    ///
84    /// # Examples
85    ///
86    /// ```rust
87    /// use ssg::deploy_adapter::Target;
88    ///
89    /// assert_eq!(Target::Netlify.as_str(), "netlify");
90    /// assert_eq!(Target::None.as_str(), "none");
91    /// ```
92    #[must_use]
93    pub const fn as_str(self) -> &'static str {
94        match self {
95            Self::Netlify => "netlify",
96            Self::Vercel => "vercel",
97            Self::CloudflarePages => "cloudflare-pages",
98            Self::GithubPages => "github-pages",
99            Self::S3 => "s3",
100            Self::None => "none",
101            // NOTE: no wildcard arm — this match is already exhaustive
102            // over every variant defined in *this* crate. `Target` is
103            // `#[non_exhaustive]` only so *downstream* crates are forced
104            // to add a wildcard; here a wildcard arm would be dead code
105            // (rustc proves it unreachable) and adding a new variant to
106            // this enum will make this match fail to compile until a
107            // real arm is added — which is what we want.
108        }
109    }
110}
111
112/// Trait implemented by per-target deploy adapters.
113///
114/// All current implementations are stubs (issue #527) — they log
115/// `"deploy adapter for <name>: not yet implemented; see #527"` and
116/// return `Ok(())`. The trait shape is the stable contract.
117pub trait DeployAdapter: Send + Sync {
118    /// Short, human-readable name for logs (e.g. `"netlify"`).
119    fn name(&self) -> &'static str;
120
121    /// Performs the deploy. `site_dir` is the freshly built site root.
122    ///
123    /// Stubs print a "not yet implemented" message and exit cleanly so
124    /// users can wire CI now and pick up the actual upload behaviour in
125    /// a follow-up patch.
126    ///
127    /// # Errors
128    /// Returns [`SsgError`] if the deploy fails. Stubs never error;
129    /// they always succeed after printing the placeholder message.
130    fn deploy(&self, site_dir: &Path) -> Result<(), SsgError>;
131}
132
133/// Returns the adapter implementation for a given [`Target`].
134///
135/// # Examples
136///
137/// ```rust
138/// use ssg::deploy_adapter::{adapter_for, Target};
139///
140/// let a = adapter_for(Target::None);
141/// assert_eq!(a.name(), "none");
142/// ```
143#[must_use]
144pub fn adapter_for(target: Target) -> Box<dyn DeployAdapter> {
145    match target {
146        Target::Netlify => Box::new(NetlifyAdapter),
147        Target::Vercel => Box::new(VercelAdapter),
148        Target::CloudflarePages => Box::new(CloudflarePagesAdapter),
149        Target::GithubPages => Box::new(GithubPagesAdapter),
150        Target::S3 => Box::new(S3Adapter),
151        Target::None => Box::new(NoneAdapter),
152        // NOTE: no wildcard arm — see `Target::as_str` for why. Adding
153        // a new variant to this enum will force a compile error here
154        // until a real adapter is wired up for it, rather than silently
155        // falling back to a no-op.
156    }
157}
158
159/// Helper used by every stub. Centralises the message text so tests
160/// can assert on it without depending on string concatenation rules.
161fn stub_message(target: &str) {
162    eprintln!("deploy adapter for {target}: not yet implemented; see #527");
163}
164
165// ----- stubs ---------------------------------------------------------
166
167/// Stub adapter for Netlify.
168#[derive(Debug, Clone, Copy)]
169pub struct NetlifyAdapter;
170impl DeployAdapter for NetlifyAdapter {
171    fn name(&self) -> &'static str {
172        "netlify"
173    }
174    fn deploy(&self, _site_dir: &Path) -> Result<(), SsgError> {
175        stub_message(self.name());
176        Ok(())
177    }
178}
179
180/// Stub adapter for Vercel.
181#[derive(Debug, Clone, Copy)]
182pub struct VercelAdapter;
183impl DeployAdapter for VercelAdapter {
184    fn name(&self) -> &'static str {
185        "vercel"
186    }
187    fn deploy(&self, _site_dir: &Path) -> Result<(), SsgError> {
188        stub_message(self.name());
189        Ok(())
190    }
191}
192
193/// Stub adapter for Cloudflare Pages.
194#[derive(Debug, Clone, Copy)]
195pub struct CloudflarePagesAdapter;
196impl DeployAdapter for CloudflarePagesAdapter {
197    fn name(&self) -> &'static str {
198        "cloudflare-pages"
199    }
200    fn deploy(&self, _site_dir: &Path) -> Result<(), SsgError> {
201        stub_message(self.name());
202        Ok(())
203    }
204}
205
206/// Stub adapter for GitHub Pages.
207#[derive(Debug, Clone, Copy)]
208pub struct GithubPagesAdapter;
209impl DeployAdapter for GithubPagesAdapter {
210    fn name(&self) -> &'static str {
211        "github-pages"
212    }
213    fn deploy(&self, _site_dir: &Path) -> Result<(), SsgError> {
214        stub_message(self.name());
215        Ok(())
216    }
217}
218
219/// Stub adapter for Amazon S3.
220#[derive(Debug, Clone, Copy)]
221pub struct S3Adapter;
222impl DeployAdapter for S3Adapter {
223    fn name(&self) -> &'static str {
224        "s3"
225    }
226    fn deploy(&self, _site_dir: &Path) -> Result<(), SsgError> {
227        stub_message(self.name());
228        Ok(())
229    }
230}
231
232/// No-op adapter — build only, no upload.
233#[derive(Debug, Clone, Copy)]
234pub struct NoneAdapter;
235impl DeployAdapter for NoneAdapter {
236    fn name(&self) -> &'static str {
237        "none"
238    }
239    fn deploy(&self, _site_dir: &Path) -> Result<(), SsgError> {
240        // The `none` target is intentionally silent — it's the
241        // documented "build only" path. No stub message.
242        Ok(())
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use std::path::PathBuf;
250
251    #[test]
252    fn target_from_cli_accepts_all_six() {
253        for (name, expected) in [
254            ("netlify", Target::Netlify),
255            ("vercel", Target::Vercel),
256            ("cloudflare-pages", Target::CloudflarePages),
257            ("github-pages", Target::GithubPages),
258            ("s3", Target::S3),
259            ("none", Target::None),
260        ] {
261            assert_eq!(Target::from_cli(name).unwrap(), expected);
262        }
263    }
264
265    #[test]
266    fn target_from_cli_rejects_unknown() {
267        assert!(Target::from_cli("moon").is_err());
268    }
269
270    #[test]
271    fn target_as_str_round_trips() {
272        for t in [
273            Target::Netlify,
274            Target::Vercel,
275            Target::CloudflarePages,
276            Target::GithubPages,
277            Target::S3,
278            Target::None,
279        ] {
280            let s = t.as_str();
281            assert_eq!(Target::from_cli(s).unwrap(), t);
282        }
283    }
284
285    #[test]
286    fn adapter_for_returns_correct_name() {
287        let pairs = [
288            (Target::Netlify, "netlify"),
289            (Target::Vercel, "vercel"),
290            (Target::CloudflarePages, "cloudflare-pages"),
291            (Target::GithubPages, "github-pages"),
292            (Target::S3, "s3"),
293            (Target::None, "none"),
294        ];
295        for (t, expected) in pairs {
296            let a = adapter_for(t);
297            assert_eq!(a.name(), expected);
298        }
299    }
300
301    #[test]
302    fn stub_adapters_succeed() {
303        // Every stub returns Ok(()) so wiring downstream consumers
304        // doesn't fail before the real implementations land.
305        let dir = PathBuf::from("/tmp/ssg-test-site");
306        for t in [
307            Target::Netlify,
308            Target::Vercel,
309            Target::CloudflarePages,
310            Target::GithubPages,
311            Target::S3,
312            Target::None,
313        ] {
314            let a = adapter_for(t);
315            let name = a.name();
316            let result = a.deploy(&dir);
317            assert!(result.is_ok(), "adapter {name} failed");
318        }
319    }
320}