1use 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#[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 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 let store_path = self.build_store_path(&filename)?;
372
373 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 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 debug!(
397 hash = %hash,
398 path = %store_path,
399 "asset not found via HEAD, proceeding with PUT"
400 );
401 }
402 }
403
404 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 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 let key = format!("{}/{}", self.config.assets_prefix, filename);
459 Ok(StorePath::from(key))
460 }
461 }
462 }
463}
464
465type EngineInitResult = Result<StorageEngine, String>;
475
476static ENGINE: OnceLock<EngineInitResult> = OnceLock::new();
477
478pub 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
499fn 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 std::env::var_os("USERPROFILE")
510 }) {
511 PathBuf::from(home).join(rest)
512 } else {
513 PathBuf::from(raw)
515 }
516 } else {
517 PathBuf::from(raw)
518 };
519
520 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 std::fs::canonicalize(&absolute).unwrap_or(absolute)
534}
535
536#[cfg(test)]
541mod tests {
542 use super::*;
543 use object_store::memory::InMemory;
544
545 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 fn local_fs_engine(dir: &Path) -> StorageEngine {
562 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 #[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 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 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 #[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 #[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 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 assert!(
732 !path_str.starts_with(".nap-assets/"),
733 "local paths should NOT be relative URL-style, got: {path_str}"
734 );
735 }
736
737 #[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 #[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 #[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 #[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 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 let hex = &hash1[7..];
806 let path = StorePath::from(format!(".nap-assets/{hex}.png"));
807 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 let engine = in_memory_engine();
834 let original = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR..."; let hash = engine.ingest_media(original, "png").await.unwrap();
837
838 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 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 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 assert_eq!(
878 &hash[7..],
879 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
880 );
881 }
882
883 #[tokio::test]
884 async fn test_ingest_media_unknown_format() {
885 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 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 let engine = in_memory_engine();
904 let data = vec![0xABu8; 1_000_000]; let hash = engine.ingest_media(&data, "bin").await.unwrap();
907 assert!(hash.starts_with("sha256:"));
908
909 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 #[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 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 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 let assets_dir = tmp.path().join(".nap-assets");
982 for hash in &[hash1, hash2, hash3] {
983 let hex = &hash[7..];
984 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 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 let content_hash = crate::content::ContentHash::parse(&hash).unwrap();
1011 assert_eq!(content_hash.as_str(), &hash);
1012
1013 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 let tmp = tempfile::TempDir::new().unwrap();
1034 let assets = tmp.path().join(".nap-assets");
1035
1036 if assets.exists() {
1038 std::fs::remove_dir_all(&assets).unwrap();
1039 }
1040 assert!(!assets.exists(), "assets dir should not exist initially");
1041
1042 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 #[test]
1058 fn test_from_env_rejects_unknown_backend() {
1059 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 let tmp = tempfile::TempDir::new().unwrap();
1092 let base = tmp.path().join("nested").join("path");
1093
1094 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 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 assert_eq!(hash_png, hash_jpg);
1130
1131 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 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 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}