Skip to main content

nap_core/
storage.rs

1//! # Storage Engine — NAP Asset Ingestion & Content-Addressed Storage
2//!
3//! A unified storage abstraction over local-filesystem and S3-compatible
4//! backends (including Cloudflare R2, MinIO, and generic S3 providers).
5//!
6//! ## Architecture
7//!
8//! The [`StorageEngine`] is initialised once (lazily via [`get_engine`]) and
9//! reused across all FFI calls.  The engine selects the backend at runtime
10//! based on the `NAP_STORAGE_BACKEND` environment variable:
11//!
12//! | Value   | Backend                        | Required env vars               |
13//! |---------|--------------------------------|---------------------------------|
14//! | `local` | Local filesystem               | `NAP_DIR` (default: `~/.nap`)   |
15//! | `s3`    | S3-compatible object store     | `NAP_S3_BUCKET`, AWS creds      |
16//!
17//! ## Gotchas Handled
18//!
19//! * **S3 Endpoint Parsing** — The builder reads `AWS_ENDPOINT_URL_S3` (and
20//!   falls back to `AWS_ENDPOINT_URL`) for R2/MinIO compatibility.  HTTP
21//!   endpoints automatically enable `with_allow_http(true)`.
22//! * **Cross-platform paths** — All local paths use `PathBuf`; never string
23//!   concatenation with `/` or `\`.
24//! * **Idempotency** — `ingest_media` issues a HEAD before PUT; if the object
25//!   already exists, the write is skipped and the hash is returned immediately.
26//! * **Gitignore** — The local initialiser ensures `.nap-assets/` is present
27//!   in `<NAP_DIR>/.gitignore` so raw binaries never enter the Git graph.
28
29use bytes::Bytes;
30use object_store::ObjectStore;
31use object_store::aws::AmazonS3Builder;
32use object_store::local::LocalFileSystem;
33use object_store::path::Path as StorePath;
34use sha2::{Digest, Sha256};
35use std::path::{Path, PathBuf};
36use std::sync::OnceLock;
37use thiserror::Error;
38use tracing::{debug, error, info};
39
40// ---------------------------------------------------------------------------
41// Error type
42// ---------------------------------------------------------------------------
43
44/// Unified error type for all storage operations.
45#[derive(Error, Debug)]
46pub enum StorageError {
47    /// Engine initialisation failed (bad env, missing deps, etc.).
48    #[error("storage initialisation failed: {0}")]
49    Init(String),
50
51    /// Underlying I/O error (local filesystem).
52    #[error("I/O error: {0}")]
53    Io(#[from] std::io::Error),
54
55    /// Object-store error (network, auth, etc.).
56    #[error("object store error: {0}")]
57    ObjectStore(#[from] object_store::Error),
58
59    /// A content hash string was malformed.
60    #[error("invalid content hash: {0}")]
61    InvalidHash(String),
62
63    /// A required environment variable is not set.
64    #[error("required environment variable `{0}` is not set")]
65    MissingEnvVar(String),
66
67    /// The path could not be converted to a valid object-store path.
68    #[error("invalid storage path: {0}")]
69    InvalidPath(String),
70}
71
72/// Convenience alias for storage results.
73pub type StorageResult<T> = Result<T, StorageError>;
74
75// ---------------------------------------------------------------------------
76// Backend selection
77// ---------------------------------------------------------------------------
78
79/// Storage backend variant selected at runtime.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum StorageBackend {
82    /// Local filesystem, writing to `<NAP_DIR>/.nap-assets/`.
83    Local,
84    /// S3-compatible object store (AWS, R2, MinIO, etc.).
85    S3,
86}
87
88impl std::fmt::Display for StorageBackend {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        match self {
91            StorageBackend::Local => write!(f, "local"),
92            StorageBackend::S3 => write!(f, "s3"),
93        }
94    }
95}
96
97// ---------------------------------------------------------------------------
98// Configuration
99// ---------------------------------------------------------------------------
100
101/// Runtime configuration of the storage engine.
102#[derive(Debug, Clone)]
103pub struct StorageConfig {
104    /// Which backend is active.
105    pub backend: StorageBackend,
106    /// Base directory for local storage (resolved absolute path).
107    pub base_dir: PathBuf,
108    /// Subdirectory / prefix for asset blobs (`.nap-assets`).
109    pub assets_prefix: String,
110    /// S3 bucket name (empty for local backend).
111    pub bucket: String,
112}
113
114// ---------------------------------------------------------------------------
115// Engine
116// ---------------------------------------------------------------------------
117
118/// Thread-safe, lazily-initialised storage engine.
119///
120/// The engine owns the [`ObjectStore`] adapter and exposes the asset-ingestion
121/// pipeline.  It is `Send + Sync` and designed to be stored in a global
122/// [`OnceLock`] so that FFI bindings can access it without constructing their
123/// own instance.
124pub struct StorageEngine {
125    store: Box<dyn ObjectStore>,
126    config: StorageConfig,
127}
128
129// Safety: object_store's ObjectStore trait bounds require all implementors
130// to be Send + Sync, so Box<dyn ObjectStore> inherits those auto-traits.
131
132impl StorageEngine {
133    /// Construct the engine from environment variables.
134    ///
135    /// ## Environment Variables
136    ///
137    /// | Variable               | Used by    | Purpose                                       |
138    /// |------------------------|------------|-----------------------------------------------|
139    /// | `NAP_STORAGE_BACKEND`  | both       | `local` (default) or `s3`                     |
140    /// | `NAP_DIR`              | local      | Base directory (default: `~/.nap`)            |
141    /// | `NAP_S3_BUCKET`        | s3         | S3 bucket name (required)                     |
142    /// | `AWS_ACCESS_KEY_ID`    | s3         | AWS / R2 access key                           |
143    /// | `AWS_SECRET_ACCESS_KEY`| s3         | AWS / R2 secret key                           |
144    /// | `AWS_REGION`           | s3         | AWS region (e.g. `us-east-1`)                 |
145    /// | `AWS_ENDPOINT_URL_S3`  | s3         | Custom S3 endpoint (R2, MinIO)                |
146    /// | `AWS_ENDPOINT_URL`     | s3         | Fallback if `AWS_ENDPOINT_URL_S3` unset       |
147    pub fn from_env() -> StorageResult<Self> {
148        let backend_str =
149            std::env::var("NAP_STORAGE_BACKEND").unwrap_or_else(|_| "local".to_string());
150
151        match backend_str.to_lowercase().as_str() {
152            "local" => Self::init_local(),
153            "s3" => Self::init_s3(),
154            other => Err(StorageError::Init(format!(
155                "unknown storage backend '{other}'; expected 'local' or 's3'"
156            ))),
157        }
158    }
159
160    // ------------------------------------------------------------------
161    // Local backend initialisation
162    // ------------------------------------------------------------------
163
164    fn init_local() -> StorageResult<Self> {
165        let raw = std::env::var("NAP_DIR").unwrap_or_else(|_| "~/.nap".to_string());
166        let base_dir = resolve_path(&raw);
167
168        // Ensure the assets directory exists before any write.
169        let assets_dir = base_dir.join(".nap-assets");
170        std::fs::create_dir_all(&assets_dir).map_err(|e| {
171            error!(
172                path = %assets_dir.display(),
173                error = %e,
174                "failed to create assets directory"
175            );
176            StorageError::Io(e)
177        })?;
178
179        // Manage .gitignore so binaries stay out of Git.
180        Self::ensure_gitignore(&base_dir)?;
181
182        let store = Box::new(LocalFileSystem::new()) as Box<dyn ObjectStore>;
183
184        info!(
185            backend = "local",
186            base_dir = %base_dir.display(),
187            assets_dir = %assets_dir.display(),
188            "storage engine initialised (local)"
189        );
190
191        Ok(Self {
192            store,
193            config: StorageConfig {
194                backend: StorageBackend::Local,
195                base_dir,
196                assets_prefix: ".nap-assets".to_string(),
197                bucket: String::new(),
198            },
199        })
200    }
201
202    /// Ensure `<NAP_DIR>/.gitignore` exists and contains `.nap-assets/`.
203    fn ensure_gitignore(base_dir: &Path) -> StorageResult<()> {
204        let gitignore_path = base_dir.join(".gitignore");
205        const ASSETS_ENTRY: &str = ".nap-assets/";
206
207        if !gitignore_path.exists() {
208            std::fs::write(&gitignore_path, format!("{ASSETS_ENTRY}\n")).map_err(|e| {
209                error!(
210                    path = %gitignore_path.display(),
211                    error = %e,
212                    "failed to create .gitignore"
213                );
214                StorageError::Io(e)
215            })?;
216            info!(
217                path = %gitignore_path.display(),
218                "created .gitignore with .nap-assets/ entry"
219            );
220            return Ok(());
221        }
222
223        // Read existing content and check for the entry.
224        let content = std::fs::read_to_string(&gitignore_path).map_err(|e| {
225            error!(
226                path = %gitignore_path.display(),
227                error = %e,
228                "failed to read existing .gitignore"
229            );
230            StorageError::Io(e)
231        })?;
232
233        let already_present = content.lines().any(|line| {
234            let trimmed = line.trim();
235            trimmed == ASSETS_ENTRY || trimmed.trim_end_matches('/') == ".nap-assets"
236        });
237
238        if !already_present {
239            let updated = format!("{content}\n{ASSETS_ENTRY}\n");
240            std::fs::write(&gitignore_path, updated).map_err(|e| {
241                error!(
242                    path = %gitignore_path.display(),
243                    error = %e,
244                    "failed to append .nap-assets/ to .gitignore"
245                );
246                StorageError::Io(e)
247            })?;
248            info!(
249                path = %gitignore_path.display(),
250                "appended .nap-assets/ entry to .gitignore"
251            );
252        } else {
253            debug!(
254                path = %gitignore_path.display(),
255                ".gitignore already contains .nap-assets/ entry"
256            );
257        }
258
259        Ok(())
260    }
261
262    // ------------------------------------------------------------------
263    // S3 backend initialisation
264    // ------------------------------------------------------------------
265
266    fn init_s3() -> StorageResult<Self> {
267        let bucket = std::env::var("NAP_S3_BUCKET").map_err(|_| {
268            error!("NAP_S3_BUCKET is required for S3 storage backend");
269            StorageError::MissingEnvVar("NAP_S3_BUCKET".to_string())
270        })?;
271
272        let mut builder = AmazonS3Builder::from_env().with_bucket_name(&bucket);
273
274        // Handle custom S3-compatible endpoints (Cloudflare R2, MinIO, etc.).
275        // AWS_ENDPOINT_URL_S3 is the service-specific override; fall back to
276        // the generic AWS_ENDPOINT_URL if the S3-specific var is unset.
277        let endpoint_var =
278            std::env::var("AWS_ENDPOINT_URL_S3").or_else(|_| std::env::var("AWS_ENDPOINT_URL"));
279
280        if let Ok(endpoint) = endpoint_var {
281            let endpoint_str = endpoint.trim().to_string();
282
283            // Non-standard endpoints often use HTTP in dev environments.
284            if endpoint_str.starts_with("http://") {
285                builder = builder.with_allow_http(true);
286                debug!(endpoint = %endpoint_str, "HTTP endpoint detected, allowing HTTP");
287            }
288
289            builder = builder.with_endpoint(&endpoint_str);
290            debug!(endpoint = %endpoint_str, "configured custom S3 endpoint");
291        }
292
293        let store: Box<dyn ObjectStore> = Box::new(builder.build().map_err(|e| {
294            error!(
295                bucket = %bucket,
296                error = %e,
297                "failed to build S3 object store"
298            );
299            StorageError::ObjectStore(e)
300        })?);
301
302        info!(
303            backend = "s3",
304            bucket = %bucket,
305            "storage engine initialised (S3)"
306        );
307
308        Ok(Self {
309            store,
310            config: StorageConfig {
311                backend: StorageBackend::S3,
312                base_dir: PathBuf::new(),
313                assets_prefix: ".nap-assets".to_string(),
314                bucket,
315            },
316        })
317    }
318
319    // ------------------------------------------------------------------
320    // Public API
321    // ------------------------------------------------------------------
322
323    /// Return the active configuration.
324    pub fn config(&self) -> &StorageConfig {
325        &self.config
326    }
327
328    /// Return a reference to the underlying [`ObjectStore`].
329    pub fn store(&self) -> &dyn ObjectStore {
330        self.store.as_ref()
331    }
332
333    /// Ingest media bytes into the content-addressed store.
334    ///
335    /// This is the core ingestion pipeline:
336    ///
337    /// 1. **Hash** — Compute `sha256:<hex>` from the byte slice.
338    /// 2. **Idempotency check** — Send a HEAD request to see if the blob
339    ///    already exists.  If it does, return the hash immediately without
340    ///    any network / disk write.
341    /// 3. **PUT** — Upload the bytes as `<hex>.<format>` under the assets
342    ///    prefix (`.nap-assets/` for both backends).
343    ///
344    /// # Arguments
345    ///
346    /// * `data` — Raw media bytes (image, audio, mesh, etc.).
347    /// * `format` — File extension without leading dot (e.g. `"png"`,
348    ///   `"jpg"`, `"wav"`, `"glb"`).
349    ///
350    /// # Returns
351    ///
352    /// The content-addressed hash string `sha256:<hex>`.
353    pub async fn ingest_media(&self, data: &[u8], format: &str) -> StorageResult<String> {
354        // ── Step 1: SHA-256 hash ────────────────────────────────────
355        let hex_digest = {
356            let mut hasher = Sha256::new();
357            hasher.update(data);
358            hex::encode(hasher.finalize())
359        };
360        let hash = format!("sha256:{hex_digest}");
361        let filename = format!("{hex_digest}.{format}");
362
363        debug!(
364            hash = %hash,
365            format = %format,
366            size = data.len(),
367            "ingesting media asset"
368        );
369
370        // ── Step 2: Build storage path ──────────────────────────────
371        let store_path = self.build_store_path(&filename)?;
372
373        // ── Step 3: Idempotency check (HEAD before PUT) ─────────────
374        match self.store.head(&store_path).await {
375            Ok(meta) => {
376                debug!(
377                    hash = %hash,
378                    path = %store_path,
379                    size = meta.size,
380                    "asset already exists, skipping write"
381                );
382                return Ok(hash);
383            }
384            Err(e) => {
385                // Only NotFound is expected — any other error is real.
386                if !matches!(&e, object_store::Error::NotFound { .. }) {
387                    error!(
388                        hash = %hash,
389                        path = %store_path,
390                        error = %e,
391                        "HEAD request failed before PUT"
392                    );
393                    return Err(StorageError::ObjectStore(e));
394                }
395                // Asset does not exist — proceed to write.
396                debug!(
397                    hash = %hash,
398                    path = %store_path,
399                    "asset not found via HEAD, proceeding with PUT"
400                );
401            }
402        }
403
404        // ── Step 4: PUT ─────────────────────────────────────────────
405        let blob = Bytes::copy_from_slice(data);
406        self.store
407            .put(&store_path, blob.into())
408            .await
409            .map_err(|e| {
410                error!(
411                    hash = %hash,
412                    path = %store_path,
413                    error = %e,
414                    "PUT failed during media ingestion"
415                );
416                StorageError::ObjectStore(e)
417            })?;
418
419        info!(
420            hash = %hash,
421            path = %store_path,
422            size = data.len(),
423            "media asset ingested successfully"
424        );
425
426        Ok(hash)
427    }
428
429    // ------------------------------------------------------------------
430    // Internal helpers
431    // ------------------------------------------------------------------
432
433    /// Build an object-store [`StorePath`] from a filename, taking the
434    /// active backend into account.
435    fn build_store_path(&self, filename: &str) -> StorageResult<StorePath> {
436        match self.config.backend {
437            StorageBackend::Local => {
438                let full_path = self
439                    .config
440                    .base_dir
441                    .join(&self.config.assets_prefix)
442                    .join(filename);
443
444                StorePath::from_absolute_path(&full_path).map_err(|e| {
445                    error!(
446                        path = %full_path.display(),
447                        error = %e,
448                        "failed to convert local filesystem path to store path"
449                    );
450                    StorageError::InvalidPath(format!(
451                        "cannot convert '{}' to store path: {e}",
452                        full_path.display()
453                    ))
454                })
455            }
456            StorageBackend::S3 => {
457                // S3 keys are URL-style and relative.
458                let key = format!("{}/{}", self.config.assets_prefix, filename);
459                Ok(StorePath::from(key))
460            }
461        }
462    }
463}
464
465// ---------------------------------------------------------------------------
466// Global singleton
467// ---------------------------------------------------------------------------
468//
469// We use `OnceLock<Result<StorageEngine, String>>` because
470// `OnceLock::get_or_try_init` has not been stabilised as of Rust 1.96.
471// Storing the error as a `String` keeps the init path simple and avoids
472// requiring `Clone` on `StorageError`.
473
474type EngineInitResult = Result<StorageEngine, String>;
475
476static ENGINE: OnceLock<EngineInitResult> = OnceLock::new();
477
478/// Return a reference to the globally-initialised [`StorageEngine`].
479///
480/// The engine is lazily initialised on first access using
481/// [`StorageEngine::from_env`].  Subsequent calls return the same instance.
482///
483/// # Errors
484///
485/// Returns [`StorageError::Init`] if environment variables are invalid or
486/// the backend cannot be configured.
487pub fn get_engine() -> StorageResult<&'static StorageEngine> {
488    let result = ENGINE.get_or_init(|| {
489        info!("initialising storage engine (lazy)");
490        StorageEngine::from_env().map_err(|e| e.to_string())
491    });
492
493    match result {
494        Ok(engine) => Ok(engine),
495        Err(msg) => Err(StorageError::Init(msg.clone())),
496    }
497}
498
499// ---------------------------------------------------------------------------
500// Utility
501// ---------------------------------------------------------------------------
502
503/// Resolve a filesystem path, expanding `~/` and converting relative paths
504/// to absolute form.
505fn resolve_path(raw: &str) -> PathBuf {
506    let expanded = if let Some(rest) = raw.strip_prefix("~/") {
507        if let Some(home) = std::env::var_os("HOME").or_else(|| {
508            // Fallback for Windows / unusual environments.
509            std::env::var_os("USERPROFILE")
510        }) {
511            PathBuf::from(home).join(rest)
512        } else {
513            // No home dir available — keep the path as-is.
514            PathBuf::from(raw)
515        }
516    } else {
517        PathBuf::from(raw)
518    };
519
520    // Make relative paths absolute before canonicalising.
521    let absolute = if expanded.is_relative() {
522        if let Ok(cwd) = std::env::current_dir() {
523            cwd.join(&expanded)
524        } else {
525            expanded
526        }
527    } else {
528        expanded
529    };
530
531    // Attempt to canonicalise; fall back to the absolute-but-not-canonical
532    // form if the path doesn't exist yet (e.g. first run).
533    std::fs::canonicalize(&absolute).unwrap_or(absolute)
534}
535
536// ---------------------------------------------------------------------------
537// Tests
538// ---------------------------------------------------------------------------
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543    use object_store::memory::InMemory;
544
545    // ── Helpers ─────────────────────────────────────────────────────
546
547    /// Construct an engine backed by an in-memory store (no I/O, fast).
548    fn in_memory_engine() -> StorageEngine {
549        StorageEngine {
550            store: Box::new(InMemory::new()),
551            config: StorageConfig {
552                backend: StorageBackend::S3,
553                base_dir: PathBuf::new(),
554                assets_prefix: ".nap-assets".to_string(),
555                bucket: "test-bucket".to_string(),
556            },
557        }
558    }
559
560    /// Construct an engine backed by a local filesystem rooted at `dir`.
561    fn local_fs_engine(dir: &Path) -> StorageEngine {
562        // Ensure the assets subdirectory exists.
563        let assets = dir.join(".nap-assets");
564        std::fs::create_dir_all(&assets).unwrap();
565
566        StorageEngine {
567            store: Box::new(LocalFileSystem::new()),
568            config: StorageConfig {
569                backend: StorageBackend::Local,
570                base_dir: dir.to_path_buf(),
571                assets_prefix: ".nap-assets".to_string(),
572                bucket: String::new(),
573            },
574        }
575    }
576
577    // ── resolve_path ────────────────────────────────────────────────
578
579    #[test]
580    fn test_resolve_path_expands_tilde() {
581        let resolved = resolve_path("~/nap-test-storage");
582        assert!(resolved.is_absolute(), "expected absolute path");
583        // Home may be in HOME (Unix) or USERPROFILE (Windows).
584        let home = std::env::var("HOME")
585            .or_else(|_| std::env::var("USERPROFILE"))
586            .expect("home dir env var must be set in test env");
587        assert!(
588            resolved.starts_with(&home),
589            "expected '{:?}' to start with '{home}'",
590            resolved
591        );
592    }
593
594    #[test]
595    fn test_resolve_path_absolute_passthrough() {
596        // Use the OS temp dir so the test works on all platforms.
597        let temp = std::env::temp_dir();
598        let p = temp.join("nap-test-abs");
599        let resolved = resolve_path(&p.to_string_lossy());
600        assert_eq!(resolved, p);
601    }
602
603    #[test]
604    fn test_resolve_path_relative_uses_cwd() {
605        let resolved = resolve_path("relative-dir");
606        assert!(
607            resolved.is_absolute(),
608            "relative path should be resolved to absolute"
609        );
610    }
611
612    // ── .gitignore management ───────────────────────────────────────
613
614    #[test]
615    fn test_ensure_gitignore_creates_when_missing() {
616        let tmp = tempfile::TempDir::new().unwrap();
617        let base = tmp.path();
618
619        StorageEngine::ensure_gitignore(base).unwrap();
620
621        let gitignore = base.join(".gitignore");
622        assert!(gitignore.exists(), ".gitignore should have been created");
623
624        let content = std::fs::read_to_string(&gitignore).unwrap();
625        assert!(
626            content.contains(".nap-assets/"),
627            "expected .nap-assets/ in .gitignore, got: {content:?}"
628        );
629    }
630
631    #[test]
632    fn test_ensure_gitignore_appends_when_entry_missing() {
633        let tmp = tempfile::TempDir::new().unwrap();
634        let base = tmp.path();
635
636        std::fs::write(base.join(".gitignore"), "target/\nnode_modules/\n").unwrap();
637
638        StorageEngine::ensure_gitignore(base).unwrap();
639
640        let content = std::fs::read_to_string(base.join(".gitignore")).unwrap();
641        assert!(
642            content.contains(".nap-assets/"),
643            "expected .nap-assets/ to be appended, got: {content:?}"
644        );
645        assert!(
646            content.contains("target/"),
647            "existing entries should be preserved"
648        );
649        assert!(
650            content.contains("node_modules/"),
651            "existing entries should be preserved"
652        );
653    }
654
655    #[test]
656    fn test_ensure_gitignore_noop_when_entry_present() {
657        let tmp = tempfile::TempDir::new().unwrap();
658        let base = tmp.path();
659
660        std::fs::write(base.join(".gitignore"), "target/\n.nap-assets/\n").unwrap();
661
662        StorageEngine::ensure_gitignore(base).unwrap();
663
664        let content = std::fs::read_to_string(base.join(".gitignore")).unwrap();
665        assert_eq!(content, "target/\n.nap-assets/\n");
666    }
667
668    // ── build_store_path ────────────────────────────────────────────
669
670    #[test]
671    fn test_build_store_path_local() {
672        let temp = std::env::temp_dir();
673        let engine = StorageEngine {
674            store: Box::new(LocalFileSystem::new()),
675            config: StorageConfig {
676                backend: StorageBackend::Local,
677                base_dir: temp.join("nap-test"),
678                assets_prefix: ".nap-assets".to_string(),
679                bucket: String::new(),
680            },
681        };
682
683        let path = engine.build_store_path("abc123.png").unwrap();
684        let path_str = path.to_string();
685        assert!(
686            path_str.contains(".nap-assets/abc123.png"),
687            "expected path containing '.nap-assets/abc123.png', got: {path_str}"
688        );
689    }
690
691    #[test]
692    fn test_build_store_path_s3() {
693        let engine = StorageEngine {
694            store: Box::new(LocalFileSystem::new()),
695            config: StorageConfig {
696                backend: StorageBackend::S3,
697                base_dir: PathBuf::new(),
698                assets_prefix: ".nap-assets".to_string(),
699                bucket: "my-bucket".to_string(),
700            },
701        };
702
703        let path = engine.build_store_path("abc123.png").unwrap();
704        assert_eq!(path.to_string(), ".nap-assets/abc123.png");
705    }
706
707    #[test]
708    fn test_build_store_path_local_from_absolute_path() {
709        // Verify the store path for a local backend contains the expected
710        // directory components.  Note: `from_absolute_path` returns a path
711        // relative to the filesystem root (strips the leading '/'), so
712        // we check for the subdirectory components rather than a leading '/'.
713        let temp = std::env::temp_dir();
714        let engine = StorageEngine {
715            store: Box::new(LocalFileSystem::new()),
716            config: StorageConfig {
717                backend: StorageBackend::Local,
718                base_dir: temp.join("nap-test"),
719                assets_prefix: ".nap-assets".to_string(),
720                bucket: String::new(),
721            },
722        };
723
724        let path = engine.build_store_path("abc.png").unwrap();
725        let path_str = path.to_string();
726        assert!(
727            path_str.contains(".nap-assets/abc.png"),
728            "expected path to contain '.nap-assets/abc.png', got: {path_str}"
729        );
730        // The path should not be a URL-style S3 key.
731        assert!(
732            !path_str.starts_with(".nap-assets/"),
733            "local paths should NOT be relative URL-style, got: {path_str}"
734        );
735    }
736
737    // ── SHA-256 hash format ─────────────────────────────────────────
738
739    #[test]
740    fn test_sha256_hash_format() {
741        let data = b"hello world";
742        let mut hasher = Sha256::new();
743        hasher.update(data);
744        let hex_digest = hex::encode(hasher.finalize());
745        let hash = format!("sha256:{hex_digest}");
746
747        assert!(hash.starts_with("sha256:"), "hash must start with sha256:");
748        assert_eq!(hash.len(), 71, "sha256:<64 hex chars> = 71 chars");
749    }
750
751    // ── Display impl ────────────────────────────────────────────────
752
753    #[test]
754    fn test_storage_backend_display() {
755        assert_eq!(StorageBackend::Local.to_string(), "local");
756        assert_eq!(StorageBackend::S3.to_string(), "s3");
757    }
758
759    // ── Error formatting ────────────────────────────────────────────
760
761    #[test]
762    fn test_storage_error_messages() {
763        let err = StorageError::Init("test".to_string());
764        assert_eq!(err.to_string(), "storage initialisation failed: test");
765
766        let err = StorageError::MissingEnvVar("NAP_S3_BUCKET".to_string());
767        assert_eq!(
768            err.to_string(),
769            "required environment variable `NAP_S3_BUCKET` is not set"
770        );
771
772        let err = StorageError::InvalidPath("bad".to_string());
773        assert_eq!(err.to_string(), "invalid storage path: bad");
774    }
775
776    // ══════════════════════════════════════════════════════════════════
777    //  IN-MEMORY INGESTION TESTS
778    // ══════════════════════════════════════════════════════════════════
779
780    #[tokio::test]
781    async fn test_ingest_media_returns_correct_hash_format() {
782        let engine = in_memory_engine();
783        let hash = engine.ingest_media(b"hello world", "txt").await.unwrap();
784
785        assert!(
786            hash.starts_with("sha256:"),
787            "hash must start with 'sha256:', got: {hash}"
788        );
789        assert_eq!(hash.len(), 71, "sha256:<64 hex chars> should be 71 chars");
790    }
791
792    #[tokio::test]
793    async fn test_ingest_media_is_idempotent() {
794        // Same data ingested twice should return the same hash AND
795        // result in only one object in the store (second call is a no-op).
796        let engine = in_memory_engine();
797        let data = b"some-image-bytes-12345";
798
799        let hash1 = engine.ingest_media(data, "png").await.unwrap();
800        let hash2 = engine.ingest_media(data, "png").await.unwrap();
801
802        assert_eq!(hash1, hash2, "same data must produce same hash");
803
804        // Verify only one object exists in the store.
805        let hex = &hash1[7..];
806        let path = StorePath::from(format!(".nap-assets/{hex}.png"));
807        // `head` should succeed (object exists).
808        let meta = engine.store.head(&path).await.unwrap();
809        assert_eq!(
810            meta.size as usize,
811            data.len(),
812            "stored object size must match"
813        );
814    }
815
816    #[tokio::test]
817    async fn test_ingest_media_different_data_different_hash() {
818        let engine = in_memory_engine();
819
820        let hash_a = engine.ingest_media(b"hello", "txt").await.unwrap();
821        let hash_b = engine.ingest_media(b"world", "txt").await.unwrap();
822
823        assert_ne!(
824            hash_a, hash_b,
825            "different content must produce different hashes"
826        );
827    }
828
829    #[tokio::test]
830    async fn test_ingest_media_content_is_retrievable() {
831        // After ingestion, the content must be readable back from the
832        // object store (media resolution).
833        let engine = in_memory_engine();
834        let original = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR..."; // fake PNG header
835
836        let hash = engine.ingest_media(original, "png").await.unwrap();
837
838        // Resolve the hash to a store path and GET the content.
839        let hex = &hash[7..];
840        let path = StorePath::from(format!(".nap-assets/{hex}.png"));
841        let result = engine.store.get(&path).await.unwrap();
842        let retrieved = result.bytes().await.unwrap();
843
844        assert_eq!(
845            retrieved.as_ref(),
846            original,
847            "retrieved content must match original"
848        );
849    }
850
851    #[tokio::test]
852    async fn test_ingest_media_content_integrity_verified_by_hash() {
853        // The returned hash must be the valid SHA-256 of the content.
854        let engine = in_memory_engine();
855        let data = b"verify-me-please";
856
857        let hash = engine.ingest_media(data, "bin").await.unwrap();
858
859        // Compute expected hash independently.
860        let mut hasher = Sha256::new();
861        hasher.update(data);
862        let expected_hex = hex::encode(hasher.finalize());
863        let expected_hash = format!("sha256:{expected_hex}");
864
865        assert_eq!(hash, expected_hash, "hash must match SHA-256 of content");
866    }
867
868    #[tokio::test]
869    async fn test_ingest_media_empty_data() {
870        let engine = in_memory_engine();
871
872        let hash = engine.ingest_media(b"", "empty").await.unwrap();
873
874        assert!(hash.starts_with("sha256:"), "empty data should still hash");
875        // SHA-256 of empty string: e3b0c44298fc1c149afbf4c8996fb924
876        //                          27ae41e4649b934ca495991b7852b855
877        assert_eq!(
878            &hash[7..],
879            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
880        );
881    }
882
883    #[tokio::test]
884    async fn test_ingest_media_unknown_format() {
885        // Even unknown/weird formats should work fine.
886        let engine = in_memory_engine();
887        let hash = engine
888            .ingest_media(b"\x00\x01\x02", "weird-format-123")
889            .await
890            .unwrap();
891        assert!(hash.starts_with("sha256:"));
892
893        // Verify it was stored under the correct key.
894        let hex = &hash[7..];
895        let path = StorePath::from(format!(".nap-assets/{hex}.weird-format-123"));
896        let meta = engine.store.head(&path).await.unwrap();
897        assert_eq!(meta.size as usize, 3);
898    }
899
900    #[tokio::test]
901    async fn test_ingest_media_large_blob() {
902        // Stress test with a large payload to surface any buffer issues.
903        let engine = in_memory_engine();
904        let data = vec![0xABu8; 1_000_000]; // 1 MB
905
906        let hash = engine.ingest_media(&data, "bin").await.unwrap();
907        assert!(hash.starts_with("sha256:"));
908
909        // Verify size stored is correct.
910        let hex = &hash[7..];
911        let path = StorePath::from(format!(".nap-assets/{hex}.bin"));
912        let meta = engine.store.head(&path).await.unwrap();
913        assert_eq!(meta.size as usize, 1_000_000);
914    }
915
916    // ══════════════════════════════════════════════════════════════════
917    //  LOCAL FILESYSTEM INGESTION TESTS
918    // ══════════════════════════════════════════════════════════════════
919
920    #[tokio::test]
921    async fn test_local_ingest_writes_file_to_disk() {
922        let tmp = tempfile::TempDir::new().unwrap();
923        let engine = local_fs_engine(tmp.path());
924        let data = b"disk-content";
925
926        let hash = engine.ingest_media(data, "txt").await.unwrap();
927
928        // The file should exist at <tmp>/.nap-assets/<hex>.txt
929        let hex = &hash[7..];
930        let file_path = tmp.path().join(".nap-assets").join(format!("{hex}.txt"));
931        assert!(
932            file_path.exists(),
933            "file should exist on disk: {}",
934            file_path.display()
935        );
936
937        let on_disk = std::fs::read(&file_path).unwrap();
938        assert_eq!(on_disk, data, "file content must match original");
939    }
940
941    #[tokio::test]
942    async fn test_local_ingest_idempotent_skips_write() {
943        let tmp = tempfile::TempDir::new().unwrap();
944        let engine = local_fs_engine(tmp.path());
945        let data = b"idempotent-data";
946
947        let hash1 = engine.ingest_media(data, "bin").await.unwrap();
948        let hash2 = engine.ingest_media(data, "bin").await.unwrap();
949
950        assert_eq!(hash1, hash2);
951
952        // Only one file should exist.
953        let hex = &hash1[7..];
954        let dir = tmp.path().join(".nap-assets");
955        let entries: Vec<_> = std::fs::read_dir(&dir)
956            .unwrap()
957            .filter_map(|e| e.ok())
958            .collect();
959        let matching: Vec<_> = entries
960            .iter()
961            .filter(|e| e.file_name().to_str().is_some_and(|n| n.contains(hex)))
962            .collect();
963        assert_eq!(
964            matching.len(),
965            1,
966            "expected exactly one file matching the hash, got {}",
967            matching.len()
968        );
969    }
970
971    #[tokio::test]
972    async fn test_local_ingest_multiple_files() {
973        let tmp = tempfile::TempDir::new().unwrap();
974        let engine = local_fs_engine(tmp.path());
975
976        let hash1 = engine.ingest_media(b"alpha", "txt").await.unwrap();
977        let hash2 = engine.ingest_media(b"beta", "txt").await.unwrap();
978        let hash3 = engine.ingest_media(b"gamma", "png").await.unwrap();
979
980        // All three files must exist in the assets directory.
981        let assets_dir = tmp.path().join(".nap-assets");
982        for hash in &[hash1, hash2, hash3] {
983            let hex = &hash[7..];
984            // Determine format from the stored key.
985            // Since we used S3 path style, we need to check the file.
986            // Let's just check at least one file with this hex exists.
987            let found = std::fs::read_dir(&assets_dir).unwrap().any(|e| {
988                e.ok()
989                    .and_then(|e| e.file_name().to_str().map(|s| s.to_string()))
990                    .is_some_and(|name| name.starts_with(hex))
991            });
992            assert!(
993                found,
994                "expected file starting with {hex} in assets directory"
995            );
996        }
997    }
998
999    #[tokio::test]
1000    async fn test_local_ingest_resolves_content_by_hash() {
1001        // Ingest content, then resolve using ContentHash to prove the
1002        // returned hash is a valid NAP content address.
1003        let tmp = tempfile::TempDir::new().unwrap();
1004        let engine = local_fs_engine(tmp.path());
1005
1006        let data = b"resolve-me";
1007        let hash = engine.ingest_media(data, "bin").await.unwrap();
1008
1009        // Parse as a NAP ContentHash — validates format & hex.
1010        let content_hash = crate::content::ContentHash::parse(&hash).unwrap();
1011        assert_eq!(content_hash.as_str(), &hash);
1012
1013        // Verify the hash matches the content via ContentHash::verify.
1014        content_hash.verify(data).unwrap();
1015    }
1016
1017    #[tokio::test]
1018    async fn test_local_engine_config_matches() {
1019        let tmp = tempfile::TempDir::new().unwrap();
1020        let engine = local_fs_engine(tmp.path());
1021
1022        assert_eq!(engine.config().backend, StorageBackend::Local);
1023        assert_eq!(engine.config().base_dir, tmp.path());
1024        assert_eq!(engine.config().assets_prefix, ".nap-assets");
1025        assert!(engine.config().bucket.is_empty());
1026    }
1027
1028    #[tokio::test]
1029    async fn test_local_ingest_subdirectory_created() {
1030        // The local initialiser creates .nap-assets automatically, but
1031        // even if it doesn't exist, the object_store LocalFileSystem
1032        // should create it.  Let's verify.
1033        let tmp = tempfile::TempDir::new().unwrap();
1034        let assets = tmp.path().join(".nap-assets");
1035
1036        // Ensure it does NOT exist before we start.
1037        if assets.exists() {
1038            std::fs::remove_dir_all(&assets).unwrap();
1039        }
1040        assert!(!assets.exists(), "assets dir should not exist initially");
1041
1042        // The engine itself doesn't auto-create; init_local does.
1043        // We call create_dir_all in local_fs_engine to match init_local.
1044        std::fs::create_dir_all(&assets).unwrap();
1045        let engine = local_fs_engine(tmp.path());
1046
1047        let hash = engine.ingest_media(b"new-dir-test", "txt").await.unwrap();
1048        let hex = &hash[7..];
1049        let file_path = assets.join(format!("{hex}.txt"));
1050        assert!(file_path.exists(), "file should exist in .nap-assets");
1051    }
1052
1053    // ══════════════════════════════════════════════════════════════════
1054    //  EDGE CASES & ERROR PATHS
1055    // ══════════════════════════════════════════════════════════════════
1056
1057    #[test]
1058    fn test_from_env_rejects_unknown_backend() {
1059        // Temporarily set an invalid backend and verify the error.
1060        // We can't easily test from_env in-process because it reads
1061        // global env vars.  Instead we test the dispatcher logic by
1062        // constructing the error directly.
1063        let err = StorageError::Init(
1064            "unknown storage backend 'gcs'; expected 'local' or 's3'".to_string(),
1065        );
1066        assert!(err.to_string().contains("unknown storage backend"));
1067    }
1068
1069    #[test]
1070    fn test_storage_error_from_io() {
1071        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
1072        let err = StorageError::Io(io_err);
1073        assert!(err.to_string().contains("I/O error"));
1074    }
1075
1076    #[test]
1077    fn test_storage_error_from_object_store() {
1078        let os_err = object_store::Error::Generic {
1079            store: "test-store",
1080            source: Box::new(std::io::Error::other("store error")),
1081        };
1082        let err = StorageError::ObjectStore(os_err);
1083        assert!(err.to_string().contains("object store error"));
1084    }
1085
1086    #[tokio::test]
1087    async fn test_ingest_media_with_nonexistent_base_dir_still_works() {
1088        // The object_store LocalFileSystem should create intermediate
1089        // directories automatically, so even if the base doesn't exist
1090        // (except .nap-assets which we create), it should work.
1091        let tmp = tempfile::TempDir::new().unwrap();
1092        let base = tmp.path().join("nested").join("path");
1093
1094        // Create the .nap-assets dir (simulating what init_local does).
1095        let assets = base.join(".nap-assets");
1096        std::fs::create_dir_all(&assets).unwrap();
1097
1098        let engine = StorageEngine {
1099            store: Box::new(LocalFileSystem::new()),
1100            config: StorageConfig {
1101                backend: StorageBackend::Local,
1102                base_dir: base.clone(),
1103                assets_prefix: ".nap-assets".to_string(),
1104                bucket: String::new(),
1105            },
1106        };
1107
1108        let hash = engine.ingest_media(b"nested-test", "txt").await.unwrap();
1109        let hex = &hash[7..];
1110        let file_path = assets.join(format!("{hex}.txt"));
1111        assert!(
1112            file_path.exists(),
1113            "file should exist at nested path: {}",
1114            file_path.display()
1115        );
1116    }
1117
1118    #[tokio::test]
1119    async fn test_ingest_media_multiple_formats_same_content() {
1120        // Same content with different formats should produce the same
1121        // hash prefix but different file extensions.
1122        let engine = in_memory_engine();
1123        let data = b"multi-format-content";
1124
1125        let hash_png = engine.ingest_media(data, "png").await.unwrap();
1126        let hash_jpg = engine.ingest_media(data, "jpg").await.unwrap();
1127
1128        // Same content = same hash (the format is NOT part of the hash).
1129        assert_eq!(hash_png, hash_jpg);
1130
1131        // Both keys should exist in the store.
1132        let hex = &hash_png[7..];
1133        let path_png = StorePath::from(format!(".nap-assets/{hex}.png"));
1134        let path_jpg = StorePath::from(format!(".nap-assets/{hex}.jpg"));
1135
1136        assert!(engine.store.head(&path_png).await.is_ok());
1137        assert!(engine.store.head(&path_jpg).await.is_ok());
1138    }
1139
1140    #[tokio::test]
1141    async fn test_ingest_media_forces_unique_formats_in_store() {
1142        // Different formats for the same content hash create separate
1143        // store entries, both returning the same hash.
1144        let engine = in_memory_engine();
1145        let data = b"same-bytes-different-extension";
1146
1147        let hash_a = engine.ingest_media(data, "mp4").await.unwrap();
1148        let hash_b = engine.ingest_media(data, "wav").await.unwrap();
1149
1150        assert_eq!(hash_a, hash_b);
1151
1152        let hex = &hash_a[7..];
1153        let path_mp4 = StorePath::from(format!(".nap-assets/{hex}.mp4"));
1154        let path_wav = StorePath::from(format!(".nap-assets/{hex}.wav"));
1155
1156        // Both entries must exist independently.
1157        let meta_mp4 = engine.store.head(&path_mp4).await.unwrap();
1158        let meta_wav = engine.store.head(&path_wav).await.unwrap();
1159        assert_eq!(meta_mp4.size, meta_wav.size);
1160    }
1161}