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)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn memory_provider_round_trip() {
378 let mut mem = MemoryContentProvider::new();
379 assert!(mem.is_empty());
380 let _ = mem.insert("a.md", b"hello".to_vec());
381 assert_eq!(mem.len(), 1);
382 assert!(!mem.is_empty());
383 assert!(mem.contains("a.md"));
384 assert!(!mem.contains("missing"));
385
386 let bytes = mem.fetch("a.md").unwrap();
387 assert_eq!(bytes, b"hello");
388 let text = mem.fetch_string("a.md").unwrap();
389 assert_eq!(text, "hello");
390 }
391
392 #[test]
393 fn memory_provider_not_found_is_distinct() {
394 let mem = MemoryContentProvider::new();
395 match mem.fetch("nope") {
396 Err(ProviderError::NotFound { key }) => assert_eq!(key, "nope"),
397 other => panic!("expected NotFound, got {other:?}"),
398 }
399 }
400
401 #[test]
402 fn memory_provider_invalid_utf8_is_backend_error() {
403 let mut mem = MemoryContentProvider::new();
404 let _ = mem.insert("bad", vec![0xffu8, 0xfe, 0xfd]);
405 match mem.fetch_string("bad") {
406 Err(ProviderError::Backend { detail }) => {
407 assert!(detail.contains("invalid utf-8"));
408 }
409 other => panic!("expected Backend, got {other:?}"),
410 }
411 }
412
413 #[test]
414 fn provider_error_display() {
415 let nf = ProviderError::NotFound { key: "a".into() };
416 let be = ProviderError::Backend {
417 detail: "boom".into(),
418 };
419 assert!(format!("{nf}").contains("not found"));
420 assert!(format!("{be}").contains("backend"));
421 }
422
423 #[test]
424 fn fs_provider_reads_file() {
425 let dir = tempfile::tempdir().unwrap();
426 let path = dir.path().join("hello.md");
427 std::fs::write(&path, b"# Hello").unwrap();
428
429 let fs = FsContentProvider::new(dir.path());
430 assert_eq!(fs.root(), dir.path());
431 let bytes = fs.fetch("hello.md").unwrap();
432 assert_eq!(bytes, b"# Hello");
433 assert!(fs.contains("hello.md"));
434 assert!(!fs.contains("absent.md"));
435 }
436
437 #[test]
438 fn fs_provider_rejects_traversal() {
439 let dir = tempfile::tempdir().unwrap();
440 let fs = FsContentProvider::new(dir.path());
441 match fs.fetch("../etc/passwd") {
442 Err(ProviderError::Backend { detail }) => {
443 assert!(detail.contains("traversal"));
444 }
445 other => panic!("expected traversal rejection, got {other:?}"),
446 }
447 assert!(!fs.contains("../etc/passwd"));
448 }
449
450 #[test]
451 fn fs_provider_missing_is_not_found() {
452 let dir = tempfile::tempdir().unwrap();
453 let fs = FsContentProvider::new(dir.path());
454 match fs.fetch("nope.md") {
455 Err(ProviderError::NotFound { key }) => assert_eq!(key, "nope.md"),
456 other => panic!("expected NotFound, got {other:?}"),
457 }
458 }
459
460 #[test]
461 fn provider_error_debug() {
462 let nf = ProviderError::NotFound { key: "k".into() };
463 let s = format!("{nf:?}");
464 assert!(s.contains("NotFound"));
465 }
466
467 #[test]
468 fn fs_provider_root_accessor() {
469 let dir = tempfile::tempdir().unwrap();
470 let fs = FsContentProvider::new(dir.path());
471 assert_eq!(fs.root(), dir.path());
472 }
473
474 #[test]
475 fn fs_provider_clone() {
476 let dir = tempfile::tempdir().unwrap();
477 let fs = FsContentProvider::new(dir.path());
478 let cloned = fs.clone();
479 assert_eq!(cloned.root(), fs.root());
480 }
481
482 #[test]
483 fn fs_provider_fetch_string_decodes_utf8() {
484 let dir = tempfile::tempdir().unwrap();
485 std::fs::write(dir.path().join("a.md"), "héllo").unwrap();
486 let fs = FsContentProvider::new(dir.path());
487 assert_eq!(fs.fetch_string("a.md").unwrap(), "héllo");
488 }
489
490 #[test]
491 fn fs_provider_fetch_string_rejects_invalid_utf8() {
492 let dir = tempfile::tempdir().unwrap();
493 std::fs::write(dir.path().join("bad.md"), [0xffu8, 0xfe, 0xfd])
494 .unwrap();
495 let fs = FsContentProvider::new(dir.path());
496 match fs.fetch_string("bad.md") {
497 Err(ProviderError::Backend { detail }) => {
498 assert!(detail.contains("invalid utf-8"));
499 }
500 other => panic!("expected Backend, got {other:?}"),
501 }
502 }
503
504 #[test]
505 fn fs_provider_contains_when_present() {
506 let dir = tempfile::tempdir().unwrap();
507 std::fs::write(dir.path().join("a.md"), "x").unwrap();
508 let fs = FsContentProvider::new(dir.path());
509 assert!(fs.contains("a.md"));
510 }
511
512 #[test]
513 fn fs_provider_nested_traversal_rejected() {
514 let dir = tempfile::tempdir().unwrap();
515 let fs = FsContentProvider::new(dir.path());
516 match fs.fetch("a/../../b") {
517 Err(ProviderError::Backend { detail }) => {
518 assert!(detail.contains("traversal"));
519 }
520 other => panic!("expected traversal rejection, got {other:?}"),
521 }
522 }
523
524 #[test]
525 fn memory_provider_insert_returns_previous_value() {
526 let mut mem = MemoryContentProvider::new();
527 assert!(mem.insert("k", b"v1".to_vec()).is_none());
528 let prev = mem.insert("k", b"v2".to_vec());
529 assert_eq!(prev.as_deref(), Some(&b"v1"[..]));
530 assert_eq!(mem.fetch("k").unwrap(), b"v2");
531 }
532
533 #[test]
534 fn memory_provider_default_equivalent_to_new() {
535 let a = MemoryContentProvider::default();
536 let b = MemoryContentProvider::new();
537 assert_eq!(a.len(), b.len());
538 assert!(a.is_empty());
539 }
540
541 #[test]
542 fn provider_error_display_messages() {
543 let nf = ProviderError::NotFound { key: "x".into() };
544 assert_eq!(format!("{nf}"), "ContentProvider: key not found: x");
545 let be = ProviderError::Backend { detail: "y".into() };
546 assert_eq!(format!("{be}"), "ContentProvider: backend error: y");
547 }
548
549 #[test]
550 fn provider_error_is_std_error() {
551 let err: Box<dyn std::error::Error> =
552 Box::new(ProviderError::NotFound { key: "k".into() });
553 assert!(err.to_string().contains("not found"));
554 }
555
556 #[test]
557 fn memory_provider_contains_via_trait_object() {
558 let mut mem = MemoryContentProvider::new();
559 let _ = mem.insert("a", b"1".to_vec());
560 let provider: &dyn ContentProvider = &mem;
561 assert!(provider.contains("a"));
562 assert!(!provider.contains("missing"));
563 }
564}