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 blake3;
30use bytes::Bytes;
31use object_store::ObjectStore;
32use object_store::aws::AmazonS3Builder;
33use object_store::local::LocalFileSystem;
34use object_store::path::Path as StorePath;
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 `blake3:<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 `blake3:<hex>`.
353    pub async fn ingest_media(&self, data: &[u8], format: &str) -> StorageResult<String> {
354        // ── Step 1: BLAKE3 hash ────────────────────────────────────
355        let hash = blake3::hash(data);
356        let hex_digest = hash.to_hex();
357        let hash = format!("blake3:{hex_digest}");
358        let filename = format!("{hex_digest}.{format}");
359
360        debug!(
361            hash = %hash,
362            format = %format,
363            size = data.len(),
364            "ingesting media asset"
365        );
366
367        // ── Step 2: Build storage path ──────────────────────────────
368        let store_path = self.build_store_path(&filename)?;
369
370        // ── Step 3: Idempotency check (HEAD before PUT) ─────────────
371        match self.store.head(&store_path).await {
372            Ok(meta) => {
373                debug!(
374                    hash = %hash,
375                    path = %store_path,
376                    size = meta.size,
377                    "asset already exists, skipping write"
378                );
379                return Ok(hash);
380            }
381            Err(e) => {
382                // Only NotFound is expected — any other error is real.
383                if !matches!(&e, object_store::Error::NotFound { .. }) {
384                    error!(
385                        hash = %hash,
386                        path = %store_path,
387                        error = %e,
388                        "HEAD request failed before PUT"
389                    );
390                    return Err(StorageError::ObjectStore(e));
391                }
392                // Asset does not exist — proceed to write.
393                debug!(
394                    hash = %hash,
395                    path = %store_path,
396                    "asset not found via HEAD, proceeding with PUT"
397                );
398            }
399        }
400
401        // ── Step 4: PUT ─────────────────────────────────────────────
402        let blob = Bytes::copy_from_slice(data);
403        self.store
404            .put(&store_path, blob.into())
405            .await
406            .map_err(|e| {
407                error!(
408                    hash = %hash,
409                    path = %store_path,
410                    error = %e,
411                    "PUT failed during media ingestion"
412                );
413                StorageError::ObjectStore(e)
414            })?;
415
416        info!(
417            hash = %hash,
418            path = %store_path,
419            size = data.len(),
420            "media asset ingested successfully"
421        );
422
423        Ok(hash)
424    }
425
426    // ------------------------------------------------------------------
427    // Internal helpers
428    // ------------------------------------------------------------------
429
430    /// Build an object-store [`StorePath`] from a filename, taking the
431    /// active backend into account.
432    fn build_store_path(&self, filename: &str) -> StorageResult<StorePath> {
433        match self.config.backend {
434            StorageBackend::Local => {
435                let full_path = self
436                    .config
437                    .base_dir
438                    .join(&self.config.assets_prefix)
439                    .join(filename);
440
441                StorePath::from_absolute_path(&full_path).map_err(|e| {
442                    error!(
443                        path = %full_path.display(),
444                        error = %e,
445                        "failed to convert local filesystem path to store path"
446                    );
447                    StorageError::InvalidPath(format!(
448                        "cannot convert '{}' to store path: {e}",
449                        full_path.display()
450                    ))
451                })
452            }
453            StorageBackend::S3 => {
454                // S3 keys are URL-style and relative.
455                let key = format!("{}/{}", self.config.assets_prefix, filename);
456                Ok(StorePath::from(key))
457            }
458        }
459    }
460}
461
462// ---------------------------------------------------------------------------
463// Global singleton
464// ---------------------------------------------------------------------------
465//
466// We use `OnceLock<Result<StorageEngine, String>>` because
467// `OnceLock::get_or_try_init` has not been stabilised as of Rust 1.96.
468// Storing the error as a `String` keeps the init path simple and avoids
469// requiring `Clone` on `StorageError`.
470
471type EngineInitResult = Result<StorageEngine, String>;
472
473static ENGINE: OnceLock<EngineInitResult> = OnceLock::new();
474
475/// Return a reference to the globally-initialised [`StorageEngine`].
476///
477/// The engine is lazily initialised on first access using
478/// [`StorageEngine::from_env`].  Subsequent calls return the same instance.
479///
480/// # Errors
481///
482/// Returns [`StorageError::Init`] if environment variables are invalid or
483/// the backend cannot be configured.
484pub fn get_engine() -> StorageResult<&'static StorageEngine> {
485    let result = ENGINE.get_or_init(|| {
486        info!("initialising storage engine (lazy)");
487        StorageEngine::from_env().map_err(|e| e.to_string())
488    });
489
490    match result {
491        Ok(engine) => Ok(engine),
492        Err(msg) => Err(StorageError::Init(msg.clone())),
493    }
494}
495
496// ---------------------------------------------------------------------------
497// Utility
498// ---------------------------------------------------------------------------
499
500/// Resolve a filesystem path, expanding `~/` and converting relative paths
501/// to absolute form.
502fn resolve_path(raw: &str) -> PathBuf {
503    let expanded = if let Some(rest) = raw.strip_prefix("~/") {
504        if let Some(home) = std::env::var_os("HOME").or_else(|| {
505            // Fallback for Windows / unusual environments.
506            std::env::var_os("USERPROFILE")
507        }) {
508            PathBuf::from(home).join(rest)
509        } else {
510            // No home dir available — keep the path as-is.
511            PathBuf::from(raw)
512        }
513    } else {
514        PathBuf::from(raw)
515    };
516
517    // Make relative paths absolute before canonicalising.
518    let absolute = if expanded.is_relative() {
519        if let Ok(cwd) = std::env::current_dir() {
520            cwd.join(&expanded)
521        } else {
522            expanded
523        }
524    } else {
525        expanded
526    };
527
528    // Attempt to canonicalise; fall back to the absolute-but-not-canonical
529    // form if the path doesn't exist yet (e.g. first run).
530    std::fs::canonicalize(&absolute).unwrap_or(absolute)
531}
532
533// ---------------------------------------------------------------------------
534// Tests
535// ---------------------------------------------------------------------------
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use object_store::memory::InMemory;
541
542    // ── Helpers ─────────────────────────────────────────────────────
543
544    /// Construct an engine backed by an in-memory store (no I/O, fast).
545    fn in_memory_engine() -> StorageEngine {
546        StorageEngine {
547            store: Box::new(InMemory::new()),
548            config: StorageConfig {
549                backend: StorageBackend::S3,
550                base_dir: PathBuf::new(),
551                assets_prefix: ".nap-assets".to_string(),
552                bucket: "test-bucket".to_string(),
553            },
554        }
555    }
556
557    /// Construct an engine backed by a local filesystem rooted at `dir`.
558    fn local_fs_engine(dir: &Path) -> StorageEngine {
559        // Ensure the assets subdirectory exists.
560        let assets = dir.join(".nap-assets");
561        std::fs::create_dir_all(&assets).unwrap();
562
563        StorageEngine {
564            store: Box::new(LocalFileSystem::new()),
565            config: StorageConfig {
566                backend: StorageBackend::Local,
567                base_dir: dir.to_path_buf(),
568                assets_prefix: ".nap-assets".to_string(),
569                bucket: String::new(),
570            },
571        }
572    }
573
574    // ── resolve_path ────────────────────────────────────────────────
575
576    #[test]
577    fn test_resolve_path_expands_tilde() {
578        let resolved = resolve_path("~/nap-test-storage");
579        assert!(resolved.is_absolute(), "expected absolute path");
580        // Home may be in HOME (Unix) or USERPROFILE (Windows).
581        let home = std::env::var("HOME")
582            .or_else(|_| std::env::var("USERPROFILE"))
583            .expect("home dir env var must be set in test env");
584        assert!(
585            resolved.starts_with(&home),
586            "expected '{:?}' to start with '{home}'",
587            resolved
588        );
589    }
590
591    #[test]
592    fn test_resolve_path_absolute_passthrough() {
593        // Use the OS temp dir so the test works on all platforms.
594        let temp = std::env::temp_dir();
595        let p = temp.join("nap-test-abs");
596        let resolved = resolve_path(&p.to_string_lossy());
597        assert_eq!(resolved, p);
598    }
599
600    #[test]
601    fn test_resolve_path_relative_uses_cwd() {
602        let resolved = resolve_path("relative-dir");
603        assert!(
604            resolved.is_absolute(),
605            "relative path should be resolved to absolute"
606        );
607    }
608
609    // ── .gitignore management ───────────────────────────────────────
610
611    #[test]
612    fn test_ensure_gitignore_creates_when_missing() {
613        let tmp = tempfile::TempDir::new().unwrap();
614        let base = tmp.path();
615
616        StorageEngine::ensure_gitignore(base).unwrap();
617
618        let gitignore = base.join(".gitignore");
619        assert!(gitignore.exists(), ".gitignore should have been created");
620
621        let content = std::fs::read_to_string(&gitignore).unwrap();
622        assert!(
623            content.contains(".nap-assets/"),
624            "expected .nap-assets/ in .gitignore, got: {content:?}"
625        );
626    }
627
628    #[test]
629    fn test_ensure_gitignore_appends_when_entry_missing() {
630        let tmp = tempfile::TempDir::new().unwrap();
631        let base = tmp.path();
632
633        std::fs::write(base.join(".gitignore"), "target/\nnode_modules/\n").unwrap();
634
635        StorageEngine::ensure_gitignore(base).unwrap();
636
637        let content = std::fs::read_to_string(base.join(".gitignore")).unwrap();
638        assert!(
639            content.contains(".nap-assets/"),
640            "expected .nap-assets/ to be appended, got: {content:?}"
641        );
642        assert!(
643            content.contains("target/"),
644            "existing entries should be preserved"
645        );
646        assert!(
647            content.contains("node_modules/"),
648            "existing entries should be preserved"
649        );
650    }
651
652    #[test]
653    fn test_ensure_gitignore_noop_when_entry_present() {
654        let tmp = tempfile::TempDir::new().unwrap();
655        let base = tmp.path();
656
657        std::fs::write(base.join(".gitignore"), "target/\n.nap-assets/\n").unwrap();
658
659        StorageEngine::ensure_gitignore(base).unwrap();
660
661        let content = std::fs::read_to_string(base.join(".gitignore")).unwrap();
662        assert_eq!(content, "target/\n.nap-assets/\n");
663    }
664
665    // ── build_store_path ────────────────────────────────────────────
666
667    #[test]
668    fn test_build_store_path_local() {
669        let temp = std::env::temp_dir();
670        let engine = StorageEngine {
671            store: Box::new(LocalFileSystem::new()),
672            config: StorageConfig {
673                backend: StorageBackend::Local,
674                base_dir: temp.join("nap-test"),
675                assets_prefix: ".nap-assets".to_string(),
676                bucket: String::new(),
677            },
678        };
679
680        let path = engine.build_store_path("abc123.png").unwrap();
681        let path_str = path.to_string();
682        assert!(
683            path_str.contains(".nap-assets/abc123.png"),
684            "expected path containing '.nap-assets/abc123.png', got: {path_str}"
685        );
686    }
687
688    #[test]
689    fn test_build_store_path_s3() {
690        let engine = StorageEngine {
691            store: Box::new(LocalFileSystem::new()),
692            config: StorageConfig {
693                backend: StorageBackend::S3,
694                base_dir: PathBuf::new(),
695                assets_prefix: ".nap-assets".to_string(),
696                bucket: "my-bucket".to_string(),
697            },
698        };
699
700        let path = engine.build_store_path("abc123.png").unwrap();
701        assert_eq!(path.to_string(), ".nap-assets/abc123.png");
702    }
703
704    #[test]
705    fn test_build_store_path_local_from_absolute_path() {
706        // Verify the store path for a local backend contains the expected
707        // directory components.  Note: `from_absolute_path` returns a path
708        // relative to the filesystem root (strips the leading '/'), so
709        // we check for the subdirectory components rather than a leading '/'.
710        let temp = std::env::temp_dir();
711        let engine = StorageEngine {
712            store: Box::new(LocalFileSystem::new()),
713            config: StorageConfig {
714                backend: StorageBackend::Local,
715                base_dir: temp.join("nap-test"),
716                assets_prefix: ".nap-assets".to_string(),
717                bucket: String::new(),
718            },
719        };
720
721        let path = engine.build_store_path("abc.png").unwrap();
722        let path_str = path.to_string();
723        assert!(
724            path_str.contains(".nap-assets/abc.png"),
725            "expected path to contain '.nap-assets/abc.png', got: {path_str}"
726        );
727        // The path should not be a URL-style S3 key.
728        assert!(
729            !path_str.starts_with(".nap-assets/"),
730            "local paths should NOT be relative URL-style, got: {path_str}"
731        );
732    }
733
734    // ── BLAKE3 hash format ──────────────────────────────────────────
735
736    #[test]
737    fn test_blake3_hash_format() {
738        let data = b"hello world";
739        let hash = blake3::hash(data);
740        let hash_str = format!("blake3:{}", hash.to_hex());
741
742        assert!(
743            hash_str.starts_with("blake3:"),
744            "hash must start with blake3:"
745        );
746        assert_eq!(hash_str.len(), 71, "blake3:<64 hex chars> = 71 chars");
747    }
748
749    // ── Display impl ────────────────────────────────────────────────
750
751    #[test]
752    fn test_storage_backend_display() {
753        assert_eq!(StorageBackend::Local.to_string(), "local");
754        assert_eq!(StorageBackend::S3.to_string(), "s3");
755    }
756
757    // ── Error formatting ────────────────────────────────────────────
758
759    #[test]
760    fn test_storage_error_messages() {
761        let err = StorageError::Init("test".to_string());
762        assert_eq!(err.to_string(), "storage initialisation failed: test");
763
764        let err = StorageError::MissingEnvVar("NAP_S3_BUCKET".to_string());
765        assert_eq!(
766            err.to_string(),
767            "required environment variable `NAP_S3_BUCKET` is not set"
768        );
769
770        let err = StorageError::InvalidPath("bad".to_string());
771        assert_eq!(err.to_string(), "invalid storage path: bad");
772    }
773
774    // ══════════════════════════════════════════════════════════════════
775    //  IN-MEMORY INGESTION TESTS
776    // ══════════════════════════════════════════════════════════════════
777
778    #[tokio::test]
779    async fn test_ingest_media_returns_correct_hash_format() {
780        let engine = in_memory_engine();
781        let hash = engine.ingest_media(b"hello world", "txt").await.unwrap();
782
783        assert!(
784            hash.starts_with("blake3:"),
785            "hash must start with 'blake3:', got: {hash}"
786        );
787        assert_eq!(hash.len(), 71, "blake3:<64 hex chars> should be 71 chars");
788    }
789
790    #[tokio::test]
791    async fn test_ingest_media_is_idempotent() {
792        // Same data ingested twice should return the same hash AND
793        // result in only one object in the store (second call is a no-op).
794        let engine = in_memory_engine();
795        let data = b"some-image-bytes-12345";
796
797        let hash1 = engine.ingest_media(data, "png").await.unwrap();
798        let hash2 = engine.ingest_media(data, "png").await.unwrap();
799
800        assert_eq!(hash1, hash2, "same data must produce same hash");
801
802        // Verify only one object exists in the store.
803        let hex = &hash1[7..];
804        let path = StorePath::from(format!(".nap-assets/{hex}.png"));
805        // `head` should succeed (object exists).
806        let meta = engine.store.head(&path).await.unwrap();
807        assert_eq!(
808            meta.size as usize,
809            data.len(),
810            "stored object size must match"
811        );
812    }
813
814    #[tokio::test]
815    async fn test_ingest_media_different_data_different_hash() {
816        let engine = in_memory_engine();
817
818        let hash_a = engine.ingest_media(b"hello", "txt").await.unwrap();
819        let hash_b = engine.ingest_media(b"world", "txt").await.unwrap();
820
821        assert_ne!(
822            hash_a, hash_b,
823            "different content must produce different hashes"
824        );
825    }
826
827    #[tokio::test]
828    async fn test_ingest_media_content_is_retrievable() {
829        // After ingestion, the content must be readable back from the
830        // object store (media resolution).
831        let engine = in_memory_engine();
832        let original = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR..."; // fake PNG header
833
834        let hash = engine.ingest_media(original, "png").await.unwrap();
835
836        // Resolve the hash to a store path and GET the content.
837        let hex = &hash[7..];
838        let path = StorePath::from(format!(".nap-assets/{hex}.png"));
839        let result = engine.store.get(&path).await.unwrap();
840        let retrieved = result.bytes().await.unwrap();
841
842        assert_eq!(
843            retrieved.as_ref(),
844            original,
845            "retrieved content must match original"
846        );
847    }
848
849    #[tokio::test]
850    async fn test_ingest_media_content_integrity_verified_by_hash() {
851        // The returned hash must be the valid BLAKE3 of the content.
852        let engine = in_memory_engine();
853        let data = b"verify-me-please";
854
855        let hash = engine.ingest_media(data, "bin").await.unwrap();
856
857        // Compute expected hash independently.
858        let expected_hash = format!("blake3:{}", blake3::hash(data).to_hex());
859
860        assert_eq!(hash, expected_hash, "hash must match BLAKE3 of content");
861    }
862
863    #[tokio::test]
864    async fn test_ingest_media_empty_data() {
865        let engine = in_memory_engine();
866
867        let hash = engine.ingest_media(b"", "empty").await.unwrap();
868
869        assert!(hash.starts_with("blake3:"), "empty data should still hash");
870        // BLAKE3 of empty string: af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262
871        assert_eq!(
872            &hash[7..],
873            "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
874        );
875    }
876
877    #[tokio::test]
878    async fn test_ingest_media_unknown_format() {
879        // Even unknown/weird formats should work fine.
880        let engine = in_memory_engine();
881        let hash = engine
882            .ingest_media(b"\x00\x01\x02", "weird-format-123")
883            .await
884            .unwrap();
885        assert!(hash.starts_with("blake3:"));
886
887        // Verify it was stored under the correct key.
888        let hex = &hash[7..];
889        let path = StorePath::from(format!(".nap-assets/{hex}.weird-format-123"));
890        let meta = engine.store.head(&path).await.unwrap();
891        assert_eq!(meta.size as usize, 3);
892    }
893
894    #[tokio::test]
895    async fn test_ingest_media_large_blob() {
896        // Stress test with a large payload to surface any buffer issues.
897        let engine = in_memory_engine();
898        let data = vec![0xABu8; 1_000_000]; // 1 MB
899
900        let hash = engine.ingest_media(&data, "bin").await.unwrap();
901        assert!(hash.starts_with("blake3:"));
902
903        // Verify size stored is correct.
904        let hex = &hash[7..];
905        let path = StorePath::from(format!(".nap-assets/{hex}.bin"));
906        let meta = engine.store.head(&path).await.unwrap();
907        assert_eq!(meta.size as usize, 1_000_000);
908    }
909
910    // ══════════════════════════════════════════════════════════════════
911    //  LOCAL FILESYSTEM INGESTION TESTS
912    // ══════════════════════════════════════════════════════════════════
913
914    #[tokio::test]
915    async fn test_local_ingest_writes_file_to_disk() {
916        let tmp = tempfile::TempDir::new().unwrap();
917        let engine = local_fs_engine(tmp.path());
918        let data = b"disk-content";
919
920        let hash = engine.ingest_media(data, "txt").await.unwrap();
921
922        // The file should exist at <tmp>/.nap-assets/<hex>.txt
923        let hex = &hash[7..];
924        let file_path = tmp.path().join(".nap-assets").join(format!("{hex}.txt"));
925        assert!(
926            file_path.exists(),
927            "file should exist on disk: {}",
928            file_path.display()
929        );
930
931        let on_disk = std::fs::read(&file_path).unwrap();
932        assert_eq!(on_disk, data, "file content must match original");
933    }
934
935    #[tokio::test]
936    async fn test_local_ingest_idempotent_skips_write() {
937        let tmp = tempfile::TempDir::new().unwrap();
938        let engine = local_fs_engine(tmp.path());
939        let data = b"idempotent-data";
940
941        let hash1 = engine.ingest_media(data, "bin").await.unwrap();
942        let hash2 = engine.ingest_media(data, "bin").await.unwrap();
943
944        assert_eq!(hash1, hash2);
945
946        // Only one file should exist.
947        let hex = &hash1[7..];
948        let dir = tmp.path().join(".nap-assets");
949        let entries: Vec<_> = std::fs::read_dir(&dir)
950            .unwrap()
951            .filter_map(|e| e.ok())
952            .collect();
953        let matching: Vec<_> = entries
954            .iter()
955            .filter(|e| e.file_name().to_str().is_some_and(|n| n.contains(hex)))
956            .collect();
957        assert_eq!(
958            matching.len(),
959            1,
960            "expected exactly one file matching the hash, got {}",
961            matching.len()
962        );
963    }
964
965    #[tokio::test]
966    async fn test_local_ingest_multiple_files() {
967        let tmp = tempfile::TempDir::new().unwrap();
968        let engine = local_fs_engine(tmp.path());
969
970        let hash1 = engine.ingest_media(b"alpha", "txt").await.unwrap();
971        let hash2 = engine.ingest_media(b"beta", "txt").await.unwrap();
972        let hash3 = engine.ingest_media(b"gamma", "png").await.unwrap();
973
974        // All three files must exist in the assets directory.
975        let assets_dir = tmp.path().join(".nap-assets");
976        for hash in &[hash1, hash2, hash3] {
977            let hex = &hash[7..];
978            // Determine format from the stored key.
979            // Since we used S3 path style, we need to check the file.
980            // Let's just check at least one file with this hex exists.
981            let found = std::fs::read_dir(&assets_dir).unwrap().any(|e| {
982                e.ok()
983                    .and_then(|e| e.file_name().to_str().map(|s| s.to_string()))
984                    .is_some_and(|name| name.starts_with(hex))
985            });
986            assert!(
987                found,
988                "expected file starting with {hex} in assets directory"
989            );
990        }
991    }
992
993    #[tokio::test]
994    async fn test_local_ingest_resolves_content_by_hash() {
995        // Ingest content, then resolve using ContentHash to prove the
996        // returned hash is a valid NAP content address.
997        let tmp = tempfile::TempDir::new().unwrap();
998        let engine = local_fs_engine(tmp.path());
999
1000        let data = b"resolve-me";
1001        let hash = engine.ingest_media(data, "bin").await.unwrap();
1002
1003        // Parse as a NAP ContentHash — validates format & hex.
1004        let content_hash = crate::content::ContentHash::parse(&hash).unwrap();
1005        assert_eq!(content_hash.as_str(), &hash);
1006
1007        // Verify the hash matches the content via ContentHash::verify.
1008        content_hash.verify(data).unwrap();
1009    }
1010
1011    #[tokio::test]
1012    async fn test_local_engine_config_matches() {
1013        let tmp = tempfile::TempDir::new().unwrap();
1014        let engine = local_fs_engine(tmp.path());
1015
1016        assert_eq!(engine.config().backend, StorageBackend::Local);
1017        assert_eq!(engine.config().base_dir, tmp.path());
1018        assert_eq!(engine.config().assets_prefix, ".nap-assets");
1019        assert!(engine.config().bucket.is_empty());
1020    }
1021
1022    #[tokio::test]
1023    async fn test_local_ingest_subdirectory_created() {
1024        // The local initialiser creates .nap-assets automatically, but
1025        // even if it doesn't exist, the object_store LocalFileSystem
1026        // should create it.  Let's verify.
1027        let tmp = tempfile::TempDir::new().unwrap();
1028        let assets = tmp.path().join(".nap-assets");
1029
1030        // Ensure it does NOT exist before we start.
1031        if assets.exists() {
1032            std::fs::remove_dir_all(&assets).unwrap();
1033        }
1034        assert!(!assets.exists(), "assets dir should not exist initially");
1035
1036        // The engine itself doesn't auto-create; init_local does.
1037        // We call create_dir_all in local_fs_engine to match init_local.
1038        std::fs::create_dir_all(&assets).unwrap();
1039        let engine = local_fs_engine(tmp.path());
1040
1041        let hash = engine.ingest_media(b"new-dir-test", "txt").await.unwrap();
1042        let hex = &hash[7..];
1043        let file_path = assets.join(format!("{hex}.txt"));
1044        assert!(file_path.exists(), "file should exist in .nap-assets");
1045    }
1046
1047    // ══════════════════════════════════════════════════════════════════
1048    //  EDGE CASES & ERROR PATHS
1049    // ══════════════════════════════════════════════════════════════════
1050
1051    #[test]
1052    fn test_from_env_rejects_unknown_backend() {
1053        // Temporarily set an invalid backend and verify the error.
1054        // We can't easily test from_env in-process because it reads
1055        // global env vars.  Instead we test the dispatcher logic by
1056        // constructing the error directly.
1057        let err = StorageError::Init(
1058            "unknown storage backend 'gcs'; expected 'local' or 's3'".to_string(),
1059        );
1060        assert!(err.to_string().contains("unknown storage backend"));
1061    }
1062
1063    #[test]
1064    fn test_storage_error_from_io() {
1065        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
1066        let err = StorageError::Io(io_err);
1067        assert!(err.to_string().contains("I/O error"));
1068    }
1069
1070    #[test]
1071    fn test_storage_error_from_object_store() {
1072        let os_err = object_store::Error::Generic {
1073            store: "test-store",
1074            source: Box::new(std::io::Error::other("store error")),
1075        };
1076        let err = StorageError::ObjectStore(os_err);
1077        assert!(err.to_string().contains("object store error"));
1078    }
1079
1080    #[tokio::test]
1081    async fn test_ingest_media_with_nonexistent_base_dir_still_works() {
1082        // The object_store LocalFileSystem should create intermediate
1083        // directories automatically, so even if the base doesn't exist
1084        // (except .nap-assets which we create), it should work.
1085        let tmp = tempfile::TempDir::new().unwrap();
1086        let base = tmp.path().join("nested").join("path");
1087
1088        // Create the .nap-assets dir (simulating what init_local does).
1089        let assets = base.join(".nap-assets");
1090        std::fs::create_dir_all(&assets).unwrap();
1091
1092        let engine = StorageEngine {
1093            store: Box::new(LocalFileSystem::new()),
1094            config: StorageConfig {
1095                backend: StorageBackend::Local,
1096                base_dir: base.clone(),
1097                assets_prefix: ".nap-assets".to_string(),
1098                bucket: String::new(),
1099            },
1100        };
1101
1102        let hash = engine.ingest_media(b"nested-test", "txt").await.unwrap();
1103        let hex = &hash[7..];
1104        let file_path = assets.join(format!("{hex}.txt"));
1105        assert!(
1106            file_path.exists(),
1107            "file should exist at nested path: {}",
1108            file_path.display()
1109        );
1110    }
1111
1112    #[tokio::test]
1113    async fn test_ingest_media_multiple_formats_same_content() {
1114        // Same content with different formats should produce the same
1115        // hash prefix but different file extensions.
1116        let engine = in_memory_engine();
1117        let data = b"multi-format-content";
1118
1119        let hash_png = engine.ingest_media(data, "png").await.unwrap();
1120        let hash_jpg = engine.ingest_media(data, "jpg").await.unwrap();
1121
1122        // Same content = same hash (the format is NOT part of the hash).
1123        assert_eq!(hash_png, hash_jpg);
1124
1125        // Both keys should exist in the store.
1126        let hex = &hash_png[7..];
1127        let path_png = StorePath::from(format!(".nap-assets/{hex}.png"));
1128        let path_jpg = StorePath::from(format!(".nap-assets/{hex}.jpg"));
1129
1130        assert!(engine.store.head(&path_png).await.is_ok());
1131        assert!(engine.store.head(&path_jpg).await.is_ok());
1132    }
1133
1134    #[tokio::test]
1135    async fn test_ingest_media_forces_unique_formats_in_store() {
1136        // Different formats for the same content hash create separate
1137        // store entries, both returning the same hash.
1138        let engine = in_memory_engine();
1139        let data = b"same-bytes-different-extension";
1140
1141        let hash_a = engine.ingest_media(data, "mp4").await.unwrap();
1142        let hash_b = engine.ingest_media(data, "wav").await.unwrap();
1143
1144        assert_eq!(hash_a, hash_b);
1145
1146        let hex = &hash_a[7..];
1147        let path_mp4 = StorePath::from(format!(".nap-assets/{hex}.mp4"));
1148        let path_wav = StorePath::from(format!(".nap-assets/{hex}.wav"));
1149
1150        // Both entries must exist independently.
1151        let meta_mp4 = engine.store.head(&path_mp4).await.unwrap();
1152        let meta_wav = engine.store.head(&path_wav).await.unwrap();
1153        assert_eq!(meta_mp4.size, meta_wav.size);
1154    }
1155}