Skip to main content

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}