use std::collections::HashMap; use std::sync::Arc; use moka::future::Cache; use uuid::Uuid; use crate::common::errors::{DomainError, ErrorKind, Result}; use crate::infrastructure::repositories::pg::NextcloudObjectIdRepository; /// Capacity of the in-memory UUID→numeric-id cache. The mapping is immutable /// once created, so a warm entry never goes stale and eviction only costs a /// re-query; ~100k entries is a few MB. const ID_CACHE_CAPACITY: u64 = 100_000; #[derive(Clone)] pub struct NextcloudFileIdService { repo: Option>, instance_id: String, /// Object-UUID → stable numeric id. `moka` caches are `Arc`-backed, so all /// clones of the service share one cache and the per-child resolution in a /// listing costs zero queries once warm. cache: Cache, } impl NextcloudFileIdService { pub fn new(repo: Arc, instance_id: String) -> Self { Self { repo: Some(repo), instance_id, cache: Cache::new(ID_CACHE_CAPACITY), } } pub fn new_stub() -> Self { Self { repo: None, instance_id: "ocnca".to_string(), cache: Cache::new(ID_CACHE_CAPACITY), } } /// Resolve — creating when absent — stable numeric file IDs for many /// UUIDs at once. Cache hits cost nothing; the misses are resolved with a /// single backing query. The returned map is keyed by parsed UUID; /// unparseable/unresolvable inputs are simply absent (mirroring the /// `.ok()` behaviour the callers relied on). pub async fn get_or_create_file_ids(&self, file_ids: &[&str]) -> Result> { self.get_or_create_many("file", file_ids).await } /// Folder counterpart of [`Self::get_or_create_file_ids`]. pub async fn get_or_create_folder_ids( &self, folder_ids: &[&str], ) -> Result> { self.get_or_create_many("folder", folder_ids).await } async fn get_or_create_many( &self, object_type: &str, raw_ids: &[&str], ) -> Result> { let mut result = HashMap::with_capacity(raw_ids.len()); let mut misses: Vec = Vec::new(); for raw in raw_ids { let Ok(uuid) = Uuid::parse_str(raw) else { continue; // Unparseable ids never had a mapping — skip silently. }; if let Some(id) = self.cache.get(&uuid).await { result.insert(uuid, id); } else { misses.push(uuid); } } if !misses.is_empty() { misses.sort_unstable(); misses.dedup(); let resolved = self .repo()? .get_or_create_many(object_type, &misses) .await?; for (uuid, id) in resolved { self.cache.insert(uuid, id).await; result.insert(uuid, id); } } Ok(result) } fn repo(&self) -> Result<&Arc> { self.repo.as_ref().ok_or_else(|| { DomainError::internal_error("NextcloudFileId", "Repository not initialized") }) } /// Get the OxiCloud file UUID from a Nextcloud numeric ID. pub async fn get_oxicloud_id(&self, nc_file_id: i64) -> Result { self.repo()?.get_object_id(nc_file_id, "file").await } pub fn format_oc_id(&self, id: i64) -> String { format!("{:08}{}", id, self.instance_id) } pub fn instance_id(&self) -> &str { &self.instance_id } #[cfg(test)] pub fn new_test(instance_id: &str) -> Self { Self { repo: None, instance_id: instance_id.to_string(), cache: Cache::new(ID_CACHE_CAPACITY), } } pub fn ensure_ready(&self) -> Result<()> { if self.repo.is_none() { return Err(DomainError::new( ErrorKind::InternalError, "NextcloudFileId", "Repository not initialized", )); } Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_format_oc_id_default_instance() { let svc = NextcloudFileIdService::new_stub(); assert_eq!(svc.format_oc_id(42), "00000042ocnca"); } #[test] fn test_format_oc_id_custom_instance() { let svc = NextcloudFileIdService::new_test("myinst"); assert_eq!(svc.format_oc_id(1), "00000001myinst"); } #[test] fn test_format_oc_id_large_number() { let svc = NextcloudFileIdService::new_stub(); assert_eq!(svc.format_oc_id(123456789), "123456789ocnca"); } #[test] fn test_instance_id() { let svc = NextcloudFileIdService::new_stub(); assert_eq!(svc.instance_id(), "ocnca"); } #[test] fn test_ensure_ready_fails_on_stub() { let svc = NextcloudFileIdService::new_stub(); assert!(svc.ensure_ready().is_err()); } // Empty input resolves to an empty map without ever touching the repo, so // it succeeds even on the repo-less stub. #[tokio::test] async fn test_get_or_create_file_ids_empty_is_noop() { let svc = NextcloudFileIdService::new_stub(); let map = svc.get_or_create_file_ids(&[]).await.unwrap(); assert!(map.is_empty()); } // Unparseable ids never had a mapping, so they are skipped before any repo // call — the stub (no repo) must not error on them. #[tokio::test] async fn test_get_or_create_file_ids_skips_unparseable() { let svc = NextcloudFileIdService::new_stub(); let map = svc.get_or_create_file_ids(&["not-a-uuid"]).await.unwrap(); assert!(map.is_empty()); } }