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}