1use 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#[derive(Error, Debug)]
46pub enum StorageError {
47 #[error("storage initialisation failed: {0}")]
49 Init(String),
50
51 #[error("I/O error: {0}")]
53 Io(#[from] std::io::Error),
54
55 #[error("object store error: {0}")]
57 ObjectStore(#[from] object_store::Error),
58
59 #[error("invalid content hash: {0}")]
61 InvalidHash(String),
62
63 #[error("required environment variable `{0}` is not set")]
65 MissingEnvVar(String),
66
67 #[error("invalid storage path: {0}")]
69 InvalidPath(String),
70}
71
72pub type StorageResult<T> = Result<T, StorageError>;
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum StorageBackend {
82 Local,
84 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#[derive(Debug, Clone)]
103pub struct StorageConfig {
104 pub backend: StorageBackend,
106 pub base_dir: PathBuf,
108 pub assets_prefix: String,
110 pub bucket: String,
112}
113
114pub struct StorageEngine {
125 store: Box<dyn ObjectStore>,
126 config: StorageConfig,
127}
128
129impl StorageEngine {
133 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 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 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 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 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 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 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 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 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 pub fn config(&self) -> &StorageConfig {
325 &self.config
326 }
327
328 pub fn store(&self) -> &dyn ObjectStore {
330 self.store.as_ref()
331 }
332
333 pub async fn ingest_media(&self, data: &[u8], format: &str) -> StorageResult<String> {
354 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 let store_path = self.build_store_path(&filename)?;
369
370 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 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 debug!(
394 hash = %hash,
395 path = %store_path,
396 "asset not found via HEAD, proceeding with PUT"
397 );
398 }
399 }
400
401 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 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 let key = format!("{}/{}", self.config.assets_prefix, filename);
456 Ok(StorePath::from(key))
457 }
458 }
459 }
460}
461
462type EngineInitResult = Result<StorageEngine, String>;
472
473static ENGINE: OnceLock<EngineInitResult> = OnceLock::new();
474
475pub 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
496fn 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 std::env::var_os("USERPROFILE")
507 }) {
508 PathBuf::from(home).join(rest)
509 } else {
510 PathBuf::from(raw)
512 }
513 } else {
514 PathBuf::from(raw)
515 };
516
517 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 std::fs::canonicalize(&absolute).unwrap_or(absolute)
531}
532
533#[cfg(test)]
538mod tests {
539 use super::*;
540 use object_store::memory::InMemory;
541
542 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 fn local_fs_engine(dir: &Path) -> StorageEngine {
559 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 #[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 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 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 #[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 #[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 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 assert!(
729 !path_str.starts_with(".nap-assets/"),
730 "local paths should NOT be relative URL-style, got: {path_str}"
731 );
732 }
733
734 #[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 #[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 #[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 #[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 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 let hex = &hash1[7..];
804 let path = StorePath::from(format!(".nap-assets/{hex}.png"));
805 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 let engine = in_memory_engine();
832 let original = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR..."; let hash = engine.ingest_media(original, "png").await.unwrap();
835
836 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 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 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 assert_eq!(
872 &hash[7..],
873 "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
874 );
875 }
876
877 #[tokio::test]
878 async fn test_ingest_media_unknown_format() {
879 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 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 let engine = in_memory_engine();
898 let data = vec![0xABu8; 1_000_000]; let hash = engine.ingest_media(&data, "bin").await.unwrap();
901 assert!(hash.starts_with("blake3:"));
902
903 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 #[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 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 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 let assets_dir = tmp.path().join(".nap-assets");
976 for hash in &[hash1, hash2, hash3] {
977 let hex = &hash[7..];
978 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 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 let content_hash = crate::content::ContentHash::parse(&hash).unwrap();
1005 assert_eq!(content_hash.as_str(), &hash);
1006
1007 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 let tmp = tempfile::TempDir::new().unwrap();
1028 let assets = tmp.path().join(".nap-assets");
1029
1030 if assets.exists() {
1032 std::fs::remove_dir_all(&assets).unwrap();
1033 }
1034 assert!(!assets.exists(), "assets dir should not exist initially");
1035
1036 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 #[test]
1052 fn test_from_env_rejects_unknown_backend() {
1053 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 let tmp = tempfile::TempDir::new().unwrap();
1086 let base = tmp.path().join("nested").join("path");
1087
1088 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 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 assert_eq!(hash_png, hash_jpg);
1124
1125 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 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 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}