ssg_core/content_provider.rs
1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! `ContentProvider` — abstract I/O for the renderer.
5//!
6//! The build-time pipeline reads markdown + templates from the local
7//! filesystem (`std::fs`). The Edge / WASM renderer needs the same
8//! sources but from Cloudflare KV, Vercel Edge Config, an in-memory
9//! cache, or anywhere else.
10//!
11//! This trait is the seam: every renderer code path that needs to
12//! resolve a source dependency goes through `ContentProvider`.
13//! Build-time uses [`FsContentProvider`]; runtime adapters
14//! (Cloudflare Workers, Vercel Edge) supply their own implementations.
15//!
16//! ## Why in `ssg-core`?
17//!
18//! `ssg-core` is the WASM-compatible crate. The trait must compile to
19//! `wasm32-unknown-unknown` so the same Rust renderer code can be
20//! consumed by both the native build binary and the Edge WASM renderer.
21//!
22//! ## Determinism
23//!
24//! Implementations MUST be deterministic for the lifetime of a single
25//! render request. If the same key is fetched twice in one render,
26//! both calls must return the same bytes. Adapters that wrap a
27//! mutable backing store (KV, Edge Config) should snapshot at the
28//! start of a render request.
29
30use std::collections::BTreeMap;
31use std::path::{Path, PathBuf};
32
33/// Outcome of a `ContentProvider` lookup.
34///
35/// Kept distinct from `Result<Option<…>>` because adapters frequently
36/// want to distinguish a hard error (KV unreachable) from a benign
37/// miss (key not in store).
38///
39/// # Examples
40///
41/// ```
42/// use ssg_core::ProviderError;
43///
44/// let err = ProviderError::NotFound { key: "foo.md".into() };
45/// assert!(err.to_string().contains("not found"));
46/// assert!(err.to_string().contains("foo.md"));
47/// ```
48#[derive(Debug)]
49pub enum ProviderError {
50 /// Key was not present in the underlying store.
51 NotFound {
52 /// The key that was requested.
53 key: String,
54 },
55 /// Backend I/O failure (network, disk, decode).
56 Backend {
57 /// Human-readable detail, suitable for logs.
58 detail: String,
59 },
60}
61
62impl std::fmt::Display for ProviderError {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 match self {
65 Self::NotFound { key } => {
66 write!(f, "ContentProvider: key not found: {key}")
67 }
68 Self::Backend { detail } => {
69 write!(f, "ContentProvider: backend error: {detail}")
70 }
71 }
72 }
73}
74
75impl std::error::Error for ProviderError {}
76
77/// Specialised `Result` for [`ContentProvider`] lookups.
78pub type ProviderResult<T> = Result<T, ProviderError>;
79
80/// Abstract content store consumed by the renderer.
81///
82/// Keys are stable, URL-safe path-shaped strings (`content/posts/foo.md`,
83/// `templates/post.html`). Adapters MAY mangle keys internally (KV
84/// namespace prefixing, slash-to-underscore, etc.) but MUST present the
85/// canonical key surface to the renderer.
86///
87/// ## Object safety
88///
89/// The trait is intentionally object-safe so renderer code can hold a
90/// `&dyn ContentProvider` without monomorphising every site that uses
91/// a different adapter.
92pub trait ContentProvider {
93 /// Fetches the raw bytes for `key`, or returns an error.
94 ///
95 /// Implementations should be cheap to call — the renderer may
96 /// fetch the same key multiple times in a single request and
97 /// expects in-process memoisation upstream.
98 ///
99 /// # Errors
100 /// - [`ProviderError::NotFound`] if `key` is not present.
101 /// - [`ProviderError::Backend`] for any other failure.
102 ///
103 /// # Examples
104 ///
105 /// ```
106 /// use ssg_core::{ContentProvider, MemoryContentProvider};
107 ///
108 /// let mut mem = MemoryContentProvider::new();
109 /// mem.insert("page.md", b"# Hello".to_vec());
110 /// let bytes = mem.fetch("page.md").unwrap();
111 /// assert_eq!(bytes, b"# Hello");
112 /// ```
113 fn fetch(&self, key: &str) -> ProviderResult<Vec<u8>>;
114
115 /// Convenience: fetches `key` and decodes as UTF-8.
116 ///
117 /// Default impl wraps [`Self::fetch`] + `String::from_utf8`.
118 /// Adapters that store text natively (KV strings, Edge Config
119 /// JSON values) can override for a zero-copy path.
120 ///
121 /// # Errors
122 /// - Any error returned by [`Self::fetch`].
123 /// - [`ProviderError::Backend`] if the bytes are not valid UTF-8.
124 ///
125 /// # Examples
126 ///
127 /// ```
128 /// use ssg_core::{ContentProvider, MemoryContentProvider};
129 ///
130 /// let mut mem = MemoryContentProvider::new();
131 /// mem.insert("a.md", b"hello".to_vec());
132 /// assert_eq!(mem.fetch_string("a.md").unwrap(), "hello");
133 /// ```
134 fn fetch_string(&self, key: &str) -> ProviderResult<String> {
135 let bytes = self.fetch(key)?;
136 String::from_utf8(bytes).map_err(|e| ProviderError::Backend {
137 detail: format!("invalid utf-8 in {key}: {e}"),
138 })
139 }
140
141 /// Reports whether `key` exists without materialising the bytes.
142 ///
143 /// Default impl delegates to [`Self::fetch`] and discards the
144 /// payload. Adapters with a cheaper HEAD-style probe (CDN cache,
145 /// KV metadata) SHOULD override.
146 ///
147 /// # Examples
148 ///
149 /// ```
150 /// use ssg_core::{ContentProvider, MemoryContentProvider};
151 ///
152 /// let mut mem = MemoryContentProvider::new();
153 /// mem.insert("k", b"v".to_vec());
154 /// assert!(mem.contains("k"));
155 /// assert!(!mem.contains("missing"));
156 /// ```
157 fn contains(&self, key: &str) -> bool {
158 self.fetch(key).is_ok()
159 }
160}
161
162// ---------------------------------------------------------------------------
163// FsContentProvider — std::fs-backed (build time)
164// ---------------------------------------------------------------------------
165
166/// Filesystem-backed `ContentProvider` for the build-time pipeline.
167///
168/// Resolves keys relative to a configured root directory. This is the
169/// default adapter used by `ssg build` and is intentionally a thin
170/// wrapper around `std::fs::read` so the existing batch pipeline keeps
171/// its byte-identical behaviour (AC9).
172///
173/// # Examples
174///
175/// ```
176/// use ssg_core::{ContentProvider, FsContentProvider};
177///
178/// let dir = tempfile::tempdir().unwrap();
179/// std::fs::write(dir.path().join("a.md"), b"# A").unwrap();
180/// let fs = FsContentProvider::new(dir.path());
181/// assert_eq!(fs.fetch("a.md").unwrap(), b"# A");
182/// ```
183#[derive(Debug, Clone)]
184pub struct FsContentProvider {
185 root: PathBuf,
186}
187
188impl FsContentProvider {
189 /// Constructs an `FsContentProvider` rooted at `root`.
190 ///
191 /// `root` is typically the site directory — every fetched key is
192 /// resolved as `root.join(key)`.
193 ///
194 /// # Examples
195 ///
196 /// ```
197 /// use ssg_core::FsContentProvider;
198 ///
199 /// let dir = tempfile::tempdir().unwrap();
200 /// let fs = FsContentProvider::new(dir.path());
201 /// assert_eq!(fs.root(), dir.path());
202 /// ```
203 #[must_use]
204 pub fn new<P: Into<PathBuf>>(root: P) -> Self {
205 Self { root: root.into() }
206 }
207
208 /// Returns the configured root directory.
209 ///
210 /// # Examples
211 ///
212 /// ```
213 /// use ssg_core::FsContentProvider;
214 /// use std::path::Path;
215 ///
216 /// let fs = FsContentProvider::new("/tmp/site");
217 /// assert_eq!(fs.root(), Path::new("/tmp/site"));
218 /// ```
219 #[must_use]
220 pub fn root(&self) -> &Path {
221 &self.root
222 }
223
224 /// Resolves a key against the configured root.
225 ///
226 /// Rejects keys containing `..` segments to prevent escape from
227 /// the root directory — adapters MUST NOT trust untrusted keys at
228 /// the Edge and the same caution applies at build time.
229 fn resolve(&self, key: &str) -> ProviderResult<PathBuf> {
230 if key.split('/').any(|seg| seg == "..") {
231 return Err(ProviderError::Backend {
232 detail: format!("rejected traversal key: {key}"),
233 });
234 }
235 Ok(self.root.join(key))
236 }
237}
238
239impl ContentProvider for FsContentProvider {
240 fn fetch(&self, key: &str) -> ProviderResult<Vec<u8>> {
241 let path = self.resolve(key)?;
242 match std::fs::read(&path) {
243 Ok(bytes) => Ok(bytes),
244 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
245 Err(ProviderError::NotFound { key: key.into() })
246 }
247 Err(e) => Err(ProviderError::Backend {
248 detail: format!("read {}: {e}", path.display()),
249 }),
250 }
251 }
252
253 fn contains(&self, key: &str) -> bool {
254 self.resolve(key).is_ok_and(|p| p.exists())
255 }
256}
257
258// ---------------------------------------------------------------------------
259// MemoryContentProvider — in-process map (tests + WASM bootstrap)
260// ---------------------------------------------------------------------------
261
262/// In-memory `ContentProvider` backed by a key→bytes map.
263///
264/// Suited to unit tests (no tempdir setup) and to the WASM Edge
265/// runtime where the JS host pre-loads a small set of source files
266/// before calling `render_page_isr`.
267///
268/// # Examples
269///
270/// ```
271/// use ssg_core::{ContentProvider, MemoryContentProvider};
272///
273/// let mut mem = MemoryContentProvider::new();
274/// mem.insert("a", b"1".to_vec());
275/// assert!(mem.contains("a"));
276/// assert_eq!(mem.fetch("a").unwrap(), b"1");
277/// ```
278#[derive(Debug, Clone, Default)]
279pub struct MemoryContentProvider {
280 map: BTreeMap<String, Vec<u8>>,
281}
282
283impl MemoryContentProvider {
284 /// Constructs an empty `MemoryContentProvider`.
285 ///
286 /// # Examples
287 ///
288 /// ```
289 /// use ssg_core::MemoryContentProvider;
290 ///
291 /// let mem = MemoryContentProvider::new();
292 /// assert!(mem.is_empty());
293 /// ```
294 #[must_use]
295 pub fn new() -> Self {
296 Self::default()
297 }
298
299 /// Inserts a key/value pair, returning the previous value (if any).
300 ///
301 /// # Examples
302 ///
303 /// ```
304 /// use ssg_core::MemoryContentProvider;
305 ///
306 /// let mut mem = MemoryContentProvider::new();
307 /// assert!(mem.insert("k", b"v1".to_vec()).is_none());
308 /// let prev = mem.insert("k", b"v2".to_vec());
309 /// assert_eq!(prev.as_deref(), Some(&b"v1"[..]));
310 /// ```
311 pub fn insert<K: Into<String>, V: Into<Vec<u8>>>(
312 &mut self,
313 key: K,
314 value: V,
315 ) -> Option<Vec<u8>> {
316 self.map.insert(key.into(), value.into())
317 }
318
319 /// Returns the number of keys currently stored.
320 ///
321 /// # Examples
322 ///
323 /// ```
324 /// use ssg_core::MemoryContentProvider;
325 ///
326 /// let mut mem = MemoryContentProvider::new();
327 /// assert_eq!(mem.len(), 0);
328 /// mem.insert("a", b"x".to_vec());
329 /// mem.insert("b", b"y".to_vec());
330 /// assert_eq!(mem.len(), 2);
331 /// ```
332 #[must_use]
333 pub fn len(&self) -> usize {
334 self.map.len()
335 }
336
337 /// Reports whether the provider holds no entries.
338 ///
339 /// # Examples
340 ///
341 /// ```
342 /// use ssg_core::MemoryContentProvider;
343 ///
344 /// let mut mem = MemoryContentProvider::new();
345 /// assert!(mem.is_empty());
346 /// mem.insert("k", b"v".to_vec());
347 /// assert!(!mem.is_empty());
348 /// ```
349 #[must_use]
350 pub fn is_empty(&self) -> bool {
351 self.map.is_empty()
352 }
353}
354
355impl ContentProvider for MemoryContentProvider {
356 fn fetch(&self, key: &str) -> ProviderResult<Vec<u8>> {
357 self.map
358 .get(key)
359 .cloned()
360 .ok_or_else(|| ProviderError::NotFound { key: key.into() })
361 }
362
363 fn contains(&self, key: &str) -> bool {
364 self.map.contains_key(key)
365 }
366}
367
368// ---------------------------------------------------------------------------
369// Tests
370// ---------------------------------------------------------------------------
371
372#[cfg(test)]
373#[allow(clippy::unwrap_used, clippy::expect_used)]
374mod tests {
375 use super::*;
376
377 #[test]
378 fn memory_provider_round_trip() {
379 let mut mem = MemoryContentProvider::new();
380 assert!(mem.is_empty());
381 let _ = mem.insert("a.md", b"hello".to_vec());
382 assert_eq!(mem.len(), 1);
383 assert!(!mem.is_empty());
384 assert!(mem.contains("a.md"));
385 assert!(!mem.contains("missing"));
386
387 let bytes = mem.fetch("a.md").unwrap();
388 assert_eq!(bytes, b"hello");
389 let text = mem.fetch_string("a.md").unwrap();
390 assert_eq!(text, "hello");
391 }
392
393 #[test]
394 fn memory_provider_not_found_is_distinct() {
395 let mem = MemoryContentProvider::new();
396 match mem.fetch("nope") {
397 Err(ProviderError::NotFound { key }) => assert_eq!(key, "nope"),
398 other => panic!("expected NotFound, got {other:?}"),
399 }
400 }
401
402 #[test]
403 fn memory_provider_invalid_utf8_is_backend_error() {
404 let mut mem = MemoryContentProvider::new();
405 let _ = mem.insert("bad", vec![0xffu8, 0xfe, 0xfd]);
406 match mem.fetch_string("bad") {
407 Err(ProviderError::Backend { detail }) => {
408 assert!(detail.contains("invalid utf-8"));
409 }
410 other => panic!("expected Backend, got {other:?}"),
411 }
412 }
413
414 #[test]
415 fn provider_error_display() {
416 let nf = ProviderError::NotFound { key: "a".into() };
417 let be = ProviderError::Backend {
418 detail: "boom".into(),
419 };
420 assert!(format!("{nf}").contains("not found"));
421 assert!(format!("{be}").contains("backend"));
422 }
423
424 #[test]
425 fn fs_provider_reads_file() {
426 let dir = tempfile::tempdir().unwrap();
427 let path = dir.path().join("hello.md");
428 std::fs::write(&path, b"# Hello").unwrap();
429
430 let fs = FsContentProvider::new(dir.path());
431 assert_eq!(fs.root(), dir.path());
432 let bytes = fs.fetch("hello.md").unwrap();
433 assert_eq!(bytes, b"# Hello");
434 assert!(fs.contains("hello.md"));
435 assert!(!fs.contains("absent.md"));
436 }
437
438 #[test]
439 fn fs_provider_rejects_traversal() {
440 let dir = tempfile::tempdir().unwrap();
441 let fs = FsContentProvider::new(dir.path());
442 match fs.fetch("../etc/passwd") {
443 Err(ProviderError::Backend { detail }) => {
444 assert!(detail.contains("traversal"));
445 }
446 other => panic!("expected traversal rejection, got {other:?}"),
447 }
448 assert!(!fs.contains("../etc/passwd"));
449 }
450
451 #[test]
452 fn fs_provider_missing_is_not_found() {
453 let dir = tempfile::tempdir().unwrap();
454 let fs = FsContentProvider::new(dir.path());
455 match fs.fetch("nope.md") {
456 Err(ProviderError::NotFound { key }) => assert_eq!(key, "nope.md"),
457 other => panic!("expected NotFound, got {other:?}"),
458 }
459 }
460
461 #[test]
462 fn provider_error_debug() {
463 let nf = ProviderError::NotFound { key: "k".into() };
464 let s = format!("{nf:?}");
465 assert!(s.contains("NotFound"));
466 }
467
468 #[test]
469 fn fs_provider_root_accessor() {
470 let dir = tempfile::tempdir().unwrap();
471 let fs = FsContentProvider::new(dir.path());
472 assert_eq!(fs.root(), dir.path());
473 }
474
475 #[test]
476 fn fs_provider_clone() {
477 let dir = tempfile::tempdir().unwrap();
478 let fs = FsContentProvider::new(dir.path());
479 let cloned = fs.clone();
480 assert_eq!(cloned.root(), fs.root());
481 }
482
483 #[test]
484 fn fs_provider_fetch_string_decodes_utf8() {
485 let dir = tempfile::tempdir().unwrap();
486 std::fs::write(dir.path().join("a.md"), "héllo").unwrap();
487 let fs = FsContentProvider::new(dir.path());
488 assert_eq!(fs.fetch_string("a.md").unwrap(), "héllo");
489 }
490
491 #[test]
492 fn fs_provider_fetch_string_rejects_invalid_utf8() {
493 let dir = tempfile::tempdir().unwrap();
494 std::fs::write(dir.path().join("bad.md"), [0xffu8, 0xfe, 0xfd])
495 .unwrap();
496 let fs = FsContentProvider::new(dir.path());
497 match fs.fetch_string("bad.md") {
498 Err(ProviderError::Backend { detail }) => {
499 assert!(detail.contains("invalid utf-8"));
500 }
501 other => panic!("expected Backend, got {other:?}"),
502 }
503 }
504
505 #[test]
506 fn fs_provider_contains_when_present() {
507 let dir = tempfile::tempdir().unwrap();
508 std::fs::write(dir.path().join("a.md"), "x").unwrap();
509 let fs = FsContentProvider::new(dir.path());
510 assert!(fs.contains("a.md"));
511 }
512
513 #[test]
514 fn fs_provider_nested_traversal_rejected() {
515 let dir = tempfile::tempdir().unwrap();
516 let fs = FsContentProvider::new(dir.path());
517 match fs.fetch("a/../../b") {
518 Err(ProviderError::Backend { detail }) => {
519 assert!(detail.contains("traversal"));
520 }
521 other => panic!("expected traversal rejection, got {other:?}"),
522 }
523 }
524
525 #[test]
526 fn memory_provider_insert_returns_previous_value() {
527 let mut mem = MemoryContentProvider::new();
528 assert!(mem.insert("k", b"v1".to_vec()).is_none());
529 let prev = mem.insert("k", b"v2".to_vec());
530 assert_eq!(prev.as_deref(), Some(&b"v1"[..]));
531 assert_eq!(mem.fetch("k").unwrap(), b"v2");
532 }
533
534 #[test]
535 fn memory_provider_default_equivalent_to_new() {
536 let a = MemoryContentProvider::default();
537 let b = MemoryContentProvider::new();
538 assert_eq!(a.len(), b.len());
539 assert!(a.is_empty());
540 }
541
542 #[test]
543 fn provider_error_display_messages() {
544 let nf = ProviderError::NotFound { key: "x".into() };
545 assert_eq!(format!("{nf}"), "ContentProvider: key not found: x");
546 let be = ProviderError::Backend { detail: "y".into() };
547 assert_eq!(format!("{be}"), "ContentProvider: backend error: y");
548 }
549
550 #[test]
551 fn provider_error_is_std_error() {
552 let err: Box<dyn std::error::Error> =
553 Box::new(ProviderError::NotFound { key: "k".into() });
554 assert!(err.to_string().contains("not found"));
555 }
556
557 #[test]
558 fn memory_provider_contains_via_trait_object() {
559 let mut mem = MemoryContentProvider::new();
560 let _ = mem.insert("a", b"1".to_vec());
561 let provider: &dyn ContentProvider = &mem;
562 assert!(provider.contains("a"));
563 assert!(!provider.contains("missing"));
564 }
565}