Skip to main content

devboy_assets/
manager.rs

1//! High-level orchestrator combining the cache, index, and rotator.
2//!
3//! [`AssetManager`] is the entry point used by the rest of devboy-tools. It
4//! hides the split between the physical cache directory and the in-memory
5//! index, and enforces the rotation policy on every write.
6//!
7//! ## Concurrency and blocking
8//!
9//! The manager wraps its mutable state in a [`std::sync::Mutex`] so it can
10//! be freely cloned via an `Arc` (e.g. to be shared across tokio tasks).
11//! The mutex serializes updates to the in-memory [`AssetIndex`] and
12//! rotation bookkeeping: reads, writes, touches, rotation, and index
13//! persistence all synchronize through the same critical section.
14//!
15//! **Several code paths also perform filesystem I/O while holding the
16//! lock**, including:
17//!
18//! - [`AssetManager::get`] — `is_file()` existence check on the target
19//!   file before returning the metadata
20//! - [`AssetManager::delete`] — `CacheManager::delete` on the target file
21//! - [`AssetManager::store`] — rotation may delete evicted files, and
22//!   `AssetIndex::save` writes `index.json` atomically (temp file + rename)
23//! - [`AssetManager::rotate_now`] — same rotation + persist path
24//!
25//! The only filesystem write that intentionally runs **before** the lock
26//! is acquired is the initial `CacheManager::store` inside
27//! [`AssetManager::store`], which writes the new blob to a path derived
28//! from the caller-supplied `asset_id` and `filename`. Assuming the
29//! caller provides unique `asset_id` values (which is part of the API
30//! contract), two concurrent writes target different paths and cannot
31//! collide. Reusing the same `asset_id` concurrently is unsupported and
32//! may race on the underlying file.
33//!
34//! Because of the above, callers must not assume these methods are free
35//! from blocking filesystem work — if they are invoked from an async
36//! context, wrap them in `tokio::task::spawn_blocking` (or equivalent)
37//! so the executor's worker thread isn't stalled on disk I/O.
38
39use std::path::{Path, PathBuf};
40use std::sync::{Arc, Mutex};
41
42use devboy_core::asset::AssetContext;
43
44use crate::cache::{CacheManager, resolve_under_root};
45use crate::config::{AssetConfig, ResolvedAssetConfig};
46use crate::error::{AssetError, Result};
47use crate::index::{AssetIndex, CachedAsset, INDEX_FILENAME, NewCachedAsset};
48use crate::rotation::{RotationStats, Rotator};
49
50/// Top-level asset cache.
51#[derive(Debug, Clone)]
52pub struct AssetManager {
53    inner: Arc<Inner>,
54}
55
56#[derive(Debug)]
57struct Inner {
58    config: ResolvedAssetConfig,
59    cache: CacheManager,
60    rotator: Rotator,
61    state: Mutex<AssetIndex>,
62}
63
64impl AssetManager {
65    /// Build a new manager from a raw [`AssetConfig`].
66    pub fn from_config(config: AssetConfig) -> Result<Self> {
67        let resolved = config.resolve()?;
68        Self::from_resolved(resolved)
69    }
70
71    /// Build a new manager from a validated [`ResolvedAssetConfig`].
72    ///
73    /// Startup flow:
74    ///
75    /// 1. Load the on-disk index (recovering an empty one if the file is
76    ///    missing or corrupt).
77    /// 2. **Integrity check** — drop entries whose file is gone or whose
78    ///    stored path resolves outside the cache root.
79    /// 3. **Rotate** — enforce `max_file_age` and `max_cache_size` on the
80    ///    loaded state so a cache that was closed over budget comes back
81    ///    up within limits before any new writes.
82    /// 4. Persist the resulting index.
83    pub fn from_resolved(config: ResolvedAssetConfig) -> Result<Self> {
84        let cache = CacheManager::new(config.cache_dir.clone())?;
85        let rotator = Rotator::new(&config);
86        let mut index = AssetIndex::load(&config.cache_dir)?;
87        let pruned = prune_stale_entries(&mut index, &cache);
88        let rotated = rotator.rotate(&mut index, &cache)?;
89        if pruned > 0 || rotated.removed() > 0 {
90            tracing::debug!(
91                pruned,
92                aged_out = rotated.aged_out,
93                size_evicted = rotated.size_evicted,
94                bytes_freed = rotated.bytes_freed,
95                "asset cache startup cleanup",
96            );
97        }
98        index.save(&config.cache_dir)?;
99
100        Ok(Self {
101            inner: Arc::new(Inner {
102                config,
103                cache,
104                rotator,
105                state: Mutex::new(index),
106            }),
107        })
108    }
109
110    /// Convenience constructor for tests, standalone usage, and minimal
111    /// environments (containers, CI) where `dirs::cache_dir()` may return
112    /// `None`.
113    ///
114    /// Unlike [`AssetManager::from_config`], this bypasses OS cache-dir
115    /// discovery entirely and uses sensible defaults for all other
116    /// settings (1 GiB budget, 7-day TTL, LRU eviction).
117    pub fn with_root(root: PathBuf) -> Result<Self> {
118        use crate::config::{
119            DEFAULT_MAX_CACHE_SIZE, DEFAULT_MAX_FILE_AGE, EvictionPolicy, ResolvedAssetConfig,
120            parse_duration, parse_size,
121        };
122        let resolved = ResolvedAssetConfig {
123            cache_dir: root,
124            max_cache_size: parse_size(DEFAULT_MAX_CACHE_SIZE)
125                .expect("default cache size is valid"),
126            max_file_age: parse_duration(DEFAULT_MAX_FILE_AGE).expect("default file age is valid"),
127            eviction_policy: EvictionPolicy::Lru,
128        };
129        Self::from_resolved(resolved)
130    }
131
132    /// Absolute path to the cache directory.
133    pub fn cache_dir(&self) -> &Path {
134        &self.inner.config.cache_dir
135    }
136
137    /// Full resolved configuration.
138    pub fn config(&self) -> &ResolvedAssetConfig {
139        &self.inner.config
140    }
141
142    /// Store a new asset, persisting the updated index and running a
143    /// rotation pass. Returns the newly created [`CachedAsset`].
144    ///
145    /// If the payload is larger than the configured
146    /// [`ResolvedAssetConfig::max_cache_size`] this returns an
147    /// [`AssetError::Config`] error *without* touching the filesystem —
148    /// otherwise rotation would immediately evict the just-written file and
149    /// we'd hand back an asset id that no longer resolves.
150    ///
151    /// A `max_cache_size` of `0` is treated as **unlimited**. Both this
152    /// method and [`Rotator::rotate`] share that convention so the two
153    /// cannot disagree about whether the budget is exhausted.
154    pub fn store(&self, request: StoreRequest<'_>) -> Result<CachedAsset> {
155        let StoreRequest {
156            context,
157            asset_id: asset_id_opt,
158            filename,
159            mime_type,
160            remote_url,
161            data,
162        } = request;
163
164        // Resolve the asset ID: caller-provided or content-addressed.
165        let asset_id_owned: String;
166        let asset_id: &str = match asset_id_opt {
167            Some(id) => {
168                let trimmed = id.trim();
169                if trimmed.is_empty() {
170                    return Err(AssetError::config("asset_id must not be empty"));
171                }
172                if trimmed.len() > MAX_ASSET_ID_LEN {
173                    return Err(AssetError::config(format!(
174                        "asset_id is {} chars, max allowed is {MAX_ASSET_ID_LEN}",
175                        trimmed.len(),
176                    )));
177                }
178                trimmed
179            }
180            None => {
181                asset_id_owned = Self::content_id(data);
182                &asset_id_owned
183            }
184        };
185
186        let size = data.len() as u64;
187        let max = self.inner.config.max_cache_size;
188        if max > 0 && size > max {
189            return Err(AssetError::config(format!(
190                "asset '{asset_id}' is {size} bytes, which exceeds the cache \
191                 budget of {max} bytes; increase `[assets] max_cache_size` or \
192                 split the file",
193            )));
194        }
195
196        let stored = self.inner.cache.store(&context, asset_id, filename, data)?;
197        let rel_path = stored
198            .path
199            .strip_prefix(self.inner.cache.root())
200            .map_err(|e| AssetError::cache_dir(format!("path outside cache root: {e}")))?
201            .to_path_buf();
202
203        let asset = CachedAsset::new(NewCachedAsset {
204            id: asset_id.to_string(),
205            filename: filename.to_string(),
206            mime_type,
207            size: stored.size,
208            local_path: rel_path,
209            context,
210            checksum_sha256: stored.checksum_sha256,
211            remote_url,
212        });
213
214        // Index upsert + rotation + persist. If any step fails we must
215        // restore the in-memory index to its pre-mutation state *and*
216        // remove the orphaned blob from disk so the cache stays
217        // transactional and doesn't leak disk space.
218        let mut deferred_delete: Option<PathBuf> = None;
219        // Track whether a previous entry occupied the same on-disk path.
220        // When true, CacheManager::store already overwrote the old blob
221        // in-place, so the rollback path must NOT delete stored.path —
222        // it's the only copy left and the restored snapshot still points
223        // at it.
224        let mut overwrote_same_path = false;
225        let result: Result<()> = (|| {
226            let mut index = self.state_lock()?;
227
228            if let Some(previous) = index.get(asset.id.as_str()) {
229                if previous.local_path == asset.local_path {
230                    // Same on-disk path — blob was overwritten in-place.
231                    overwrote_same_path = true;
232                } else {
233                    // Different path — record the old blob for deferred
234                    // deletion *after* the commit succeeds.
235                    deferred_delete =
236                        resolve_under_root(&self.inner.config.cache_dir, &previous.local_path);
237                }
238            }
239
240            // Snapshot the index so we can restore on failure.
241            let snapshot = index.clone();
242
243            index.upsert(asset.clone());
244            if let Err(e) = self
245                .inner
246                .rotator
247                .rotate(&mut index, &self.inner.cache)
248                .and_then(|_| {
249                    // Guard: if rotation evicted the asset we just stored,
250                    // roll back rather than returning metadata that
251                    // immediately misses on `get()`.
252                    if index.get(asset.id.as_str()).is_none() {
253                        return Err(AssetError::config(format!(
254                            "asset '{}' was evicted immediately after store — \
255                             the cache budget ({} bytes) is too small for this file \
256                             ({} bytes) alongside existing entries",
257                            asset.id, self.inner.config.max_cache_size, asset.size,
258                        )));
259                    }
260                    index.save(&self.inner.config.cache_dir)
261                })
262            {
263                // Restore the snapshot so list()/total_size() stay
264                // consistent with what's actually on disk.
265                *index = snapshot;
266                deferred_delete = None; // don't delete old blob on rollback
267                return Err(e);
268            }
269            Ok(())
270        })();
271
272        if let Err(e) = result {
273            // Roll back: remove the orphaned blob — but only when it's a
274            // truly new path. If the previous entry occupied the same
275            // on-disk location, CacheManager::store already overwrote the
276            // old bytes in-place, so deleting the file would leave the
277            // restored snapshot pointing at nothing.
278            //
279            // Trade-off: when same-path overwrite is rolled back, the
280            // file on disk contains the *new* bytes, not the original
281            // ones — the pre-existing content is lost. Full byte-level
282            // restoration (backup + restore, or write-to-temp + rename)
283            // is intentionally not implemented: this is an ephemeral
284            // cache and any file can be re-downloaded from the provider.
285            if !overwrote_same_path {
286                let _ = self.inner.cache.delete(&stored.path);
287            }
288            return Err(e);
289        }
290
291        // Commit succeeded — now safe to clean up the replaced blob.
292        if let Some(old_path) = deferred_delete {
293            let _ = self.inner.cache.delete(&old_path);
294        }
295
296        Ok(asset)
297    }
298
299    /// Look up an asset by id and return the absolute path on disk if it
300    /// is still cached. Also touches `last_accessed` and persists the
301    /// index if the asset was found.
302    ///
303    /// The returned [`ResolvedAsset::asset`] reflects the *post-touch*
304    /// state, so `asset.last_accessed_ms` is the timestamp just written
305    /// to the index rather than the stale pre-touch value.
306    pub fn get(&self, asset_id: &str) -> Result<Option<ResolvedAsset>> {
307        let mut index = self.state_lock()?;
308        // Resolve the path using a borrowed lookup first — no clone yet.
309        let (abs_path, remove_stale) = match index.get(asset_id) {
310            Some(asset) => {
311                match resolve_under_root(&self.inner.config.cache_dir, &asset.local_path) {
312                    Some(abs) => (Some(abs), false),
313                    None => {
314                        tracing::warn!(
315                            asset_id,
316                            path = ?asset.local_path,
317                            "dropping index entry with unsafe local_path",
318                        );
319                        (None, true)
320                    }
321                }
322            }
323            None => return Ok(None),
324        };
325
326        if remove_stale {
327            index.remove(asset_id);
328            index.save(&self.inner.config.cache_dir)?;
329            return Ok(None);
330        }
331        let abs_path = abs_path.expect("abs_path set when remove_stale is false");
332
333        if !abs_path.is_file() {
334            // File vanished — drop the stale entry.
335            index.remove(asset_id);
336            index.save(&self.inner.config.cache_dir)?;
337            return Ok(None);
338        }
339
340        // Touch first, then clone so the returned metadata carries the
341        // freshly-written `last_accessed_ms`.
342        index.touch(asset_id);
343        index.save(&self.inner.config.cache_dir)?;
344        let asset = index
345            .get(asset_id)
346            .cloned()
347            .expect("asset still present after touch");
348        Ok(Some(ResolvedAsset {
349            asset,
350            absolute_path: abs_path,
351        }))
352    }
353
354    /// Delete an asset from the cache (both the index entry and the file).
355    /// Returns `true` if the asset was present.
356    pub fn delete(&self, asset_id: &str) -> Result<bool> {
357        let mut index = self.state_lock()?;
358        let Some(asset) = index.remove(asset_id) else {
359            return Ok(false);
360        };
361        if let Some(abs_path) = resolve_under_root(&self.inner.config.cache_dir, &asset.local_path)
362        {
363            self.inner.cache.delete(&abs_path)?;
364        } else {
365            tracing::warn!(
366                asset_id,
367                path = ?asset.local_path,
368                "skipping filesystem delete for unsafe local_path",
369            );
370        }
371        index.save(&self.inner.config.cache_dir)?;
372        Ok(true)
373    }
374
375    /// List all assets currently tracked.
376    ///
377    /// Returns [`AssetError::Poisoned`] if the in-memory index mutex is
378    /// poisoned; callers that want a best-effort snapshot can
379    /// `.unwrap_or_default()` on the result.
380    pub fn list(&self) -> Result<Vec<CachedAsset>> {
381        Ok(self.state_lock()?.assets.values().cloned().collect())
382    }
383
384    /// Total tracked bytes in the cache.
385    ///
386    /// Returns [`AssetError::Poisoned`] if the in-memory index mutex is
387    /// poisoned.
388    pub fn total_size(&self) -> Result<u64> {
389        Ok(self.state_lock()?.total_size())
390    }
391
392    /// Force a rotation pass immediately.
393    pub fn rotate_now(&self) -> Result<RotationStats> {
394        let mut index = self.state_lock()?;
395        let stats = self.inner.rotator.rotate(&mut index, &self.inner.cache)?;
396        index.save(&self.inner.config.cache_dir)?;
397        Ok(stats)
398    }
399
400    /// Re-check index vs filesystem. Returns the number of dropped entries.
401    pub fn integrity_check(&self) -> Result<usize> {
402        let mut index = self.state_lock()?;
403        let removed = prune_stale_entries(&mut index, &self.inner.cache);
404        if removed > 0 {
405            index.save(&self.inner.config.cache_dir)?;
406        }
407        Ok(removed)
408    }
409
410    /// Path to the on-disk index file. Useful for diagnostics and tests.
411    pub fn index_path(&self) -> PathBuf {
412        self.inner.config.cache_dir.join(INDEX_FILENAME)
413    }
414
415    /// Compute a content-addressed asset ID from raw bytes.
416    ///
417    /// The returned string has the form `sha256:{16 hex chars}` (64-bit
418    /// prefix of the SHA-256 digest). This is the same ID that
419    /// [`AssetManager::store`] generates when `asset_id` is `None`.
420    ///
421    /// Use this to check whether a file is already cached before
422    /// downloading it:
423    ///
424    /// ```ignore
425    /// let id = AssetManager::content_id(data);
426    /// if manager.get(&id)?.is_some() { /* already cached */ }
427    /// ```
428    pub fn content_id(data: &[u8]) -> String {
429        let hash = crate::cache::sha256_hex(data);
430        format!("sha256:{}", &hash[..16])
431    }
432
433    /// Acquire the in-memory index lock.
434    ///
435    /// Returns [`AssetError::Poisoned`] if the mutex was poisoned by a
436    /// panic in another thread, giving callers a chance to recover or
437    /// propagate the error gracefully instead of crashing.
438    fn state_lock(&self) -> Result<std::sync::MutexGuard<'_, AssetIndex>> {
439        self.inner
440            .state
441            .lock()
442            .map_err(|e| AssetError::poisoned(e.to_string()))
443    }
444}
445
446/// Resolved lookup result — both the cached metadata and the absolute
447/// path of the file on disk.
448#[derive(Debug, Clone)]
449pub struct ResolvedAsset {
450    /// Metadata from the index.
451    pub asset: CachedAsset,
452    /// Absolute path to the file on disk.
453    pub absolute_path: PathBuf,
454}
455
456/// Parameters for [`AssetManager::store`].
457#[derive(Debug)]
458pub struct StoreRequest<'a> {
459    /// Context the asset is attached to.
460    pub context: AssetContext,
461    /// Stable identifier for the asset.
462    ///
463    /// **Dual-mode:**
464    /// - `Some("att-42")` — caller-provided ID, typically the provider's
465    ///   native attachment identifier (ClickUp attachment id, Jira
466    ///   attachment id, GitLab upload URL, etc.). This enables cache-hit
467    ///   lookups when the same attachment is requested again.
468    /// - `None` — auto-generate a content-addressed ID from the SHA-256
469    ///   of `data`. The generated ID has the form `sha256:{16 hex chars}`
470    ///   and provides natural deduplication: storing the same bytes twice
471    ///   hits the same cache entry.
472    ///
473    /// Callers can also pre-compute a content ID via
474    /// [`AssetManager::content_id`] if they want to check for existence
475    /// before downloading.
476    pub asset_id: Option<&'a str>,
477    pub filename: &'a str,
478    /// MIME type if known.
479    pub mime_type: Option<String>,
480    /// Remote URL at the provider if known.
481    pub remote_url: Option<String>,
482    /// Raw bytes to cache.
483    pub data: &'a [u8],
484}
485
486/// Maximum allowed length for an asset ID (after sanitization the
487/// on-disk component will be shorter, but we reject clearly absurd
488/// inputs early).
489const MAX_ASSET_ID_LEN: usize = 200;
490
491/// Drop index entries whose underlying file is missing (or whose stored
492/// path is unsafe). Returns the count of removed entries.
493///
494/// Used by both `from_resolved` (startup check) and
495/// [`AssetManager::integrity_check`].
496fn prune_stale_entries(index: &mut AssetIndex, cache: &CacheManager) -> usize {
497    let stale: Vec<String> = index
498        .assets
499        .iter()
500        .filter_map(
501            |(id, asset)| match resolve_under_root(cache.root(), &asset.local_path) {
502                Some(abs) if cache.exists(&abs) => None,
503                Some(_) => Some(id.clone()),
504                None => {
505                    tracing::warn!(
506                        asset_id = id.as_str(),
507                        path = ?asset.local_path,
508                        "dropping index entry with unsafe local_path",
509                    );
510                    Some(id.clone())
511                }
512            },
513        )
514        .collect();
515    let count = stale.len();
516    for id in stale {
517        index.remove(&id);
518    }
519    count
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use crate::config::EvictionPolicy;
526    use devboy_core::asset::AssetContext;
527    use std::time::Duration;
528    use tempfile::tempdir;
529
530    fn manager(root: PathBuf) -> AssetManager {
531        let cfg = ResolvedAssetConfig {
532            cache_dir: root,
533            max_cache_size: 10_000,
534            max_file_age: Duration::from_secs(100 * 86_400),
535            eviction_policy: EvictionPolicy::Lru,
536        };
537        AssetManager::from_resolved(cfg).unwrap()
538    }
539
540    fn store_simple<'a>(
541        context: AssetContext,
542        asset_id: &'a str,
543        filename: &'a str,
544        data: &'a [u8],
545    ) -> StoreRequest<'a> {
546        StoreRequest {
547            context,
548            asset_id: Some(asset_id),
549            filename,
550            mime_type: None,
551            remote_url: None,
552            data,
553        }
554    }
555
556    #[test]
557    fn store_get_delete_roundtrip() {
558        let tmp = tempdir().unwrap();
559        let mgr = manager(tmp.path().to_path_buf());
560        let ctx = AssetContext::Issue {
561            key: "DEV-1".into(),
562        };
563
564        let asset = mgr
565            .store(StoreRequest {
566                context: ctx.clone(),
567                asset_id: Some("a1"),
568                filename: "file.txt",
569                mime_type: Some("text/plain".into()),
570                remote_url: None,
571                data: b"hello",
572            })
573            .unwrap();
574        assert_eq!(asset.size, 5);
575        assert_eq!(mgr.total_size().unwrap(), 5);
576
577        let resolved = mgr.get("a1").unwrap().expect("asset present");
578        assert_eq!(resolved.asset.id, "a1");
579        assert!(resolved.absolute_path.is_file());
580        assert_eq!(std::fs::read(&resolved.absolute_path).unwrap(), b"hello");
581
582        assert!(mgr.delete("a1").unwrap());
583        assert!(mgr.get("a1").unwrap().is_none());
584        assert!(!mgr.delete("a1").unwrap(), "second delete is a no-op");
585        assert_eq!(mgr.total_size().unwrap(), 0);
586    }
587
588    #[test]
589    fn store_persists_across_reopen() {
590        let tmp = tempdir().unwrap();
591        {
592            let mgr = manager(tmp.path().to_path_buf());
593            let ctx = AssetContext::Issue {
594                key: "DEV-1".into(),
595            };
596            mgr.store(store_simple(ctx, "a1", "x.bin", b"xyz")).unwrap();
597        }
598
599        let mgr = manager(tmp.path().to_path_buf());
600        let list = mgr.list().unwrap();
601        assert_eq!(list.len(), 1);
602        assert_eq!(list[0].id, "a1");
603        let resolved = mgr.get("a1").unwrap().unwrap();
604        assert_eq!(std::fs::read(&resolved.absolute_path).unwrap(), b"xyz");
605    }
606
607    #[test]
608    fn integrity_check_removes_missing_files() {
609        let tmp = tempdir().unwrap();
610        let mgr = manager(tmp.path().to_path_buf());
611        let ctx = AssetContext::Issue {
612            key: "DEV-1".into(),
613        };
614        let asset = mgr.store(store_simple(ctx, "a1", "x.bin", b"xyz")).unwrap();
615
616        // Remove the file out-of-band.
617        let abs = tmp.path().join(&asset.local_path);
618        std::fs::remove_file(&abs).unwrap();
619
620        let removed = mgr.integrity_check().unwrap();
621        assert_eq!(removed, 1);
622        assert!(mgr.list().unwrap().is_empty());
623    }
624
625    #[test]
626    fn get_drops_stale_entry_and_returns_none() {
627        let tmp = tempdir().unwrap();
628        let mgr = manager(tmp.path().to_path_buf());
629        let ctx = AssetContext::Issue {
630            key: "DEV-1".into(),
631        };
632        let asset = mgr.store(store_simple(ctx, "a1", "x.bin", b"xyz")).unwrap();
633
634        std::fs::remove_file(tmp.path().join(&asset.local_path)).unwrap();
635
636        assert!(mgr.get("a1").unwrap().is_none());
637        assert!(mgr.list().unwrap().is_empty());
638    }
639
640    #[test]
641    fn rotate_now_enforces_budget() {
642        let tmp = tempdir().unwrap();
643        let cfg = ResolvedAssetConfig {
644            cache_dir: tmp.path().to_path_buf(),
645            max_cache_size: 100,
646            max_file_age: Duration::from_secs(100 * 86_400),
647            eviction_policy: EvictionPolicy::Lru,
648        };
649        let mgr = AssetManager::from_resolved(cfg).unwrap();
650        let ctx = AssetContext::Issue {
651            key: "DEV-1".into(),
652        };
653
654        mgr.store(store_simple(ctx.clone(), "a", "a.bin", &[0u8; 60]))
655            .unwrap();
656        // Second store triggers rotation automatically. Both fit (60 + 60 > 100)
657        // so one of them should be evicted.
658        mgr.store(store_simple(ctx, "b", "b.bin", &[0u8; 60]))
659            .unwrap();
660        assert!(mgr.total_size().unwrap() <= 100);
661        assert_eq!(mgr.list().unwrap().len(), 1);
662
663        // Explicit rotate_now is a no-op now that we are within budget.
664        let stats = mgr.rotate_now().unwrap();
665        assert_eq!(stats.removed(), 0);
666    }
667
668    #[test]
669    fn index_path_points_at_cache_dir() {
670        let tmp = tempdir().unwrap();
671        let mgr = manager(tmp.path().to_path_buf());
672        assert_eq!(mgr.index_path(), tmp.path().join(INDEX_FILENAME));
673        assert_eq!(mgr.cache_dir(), tmp.path());
674    }
675
676    #[test]
677    fn store_treats_zero_max_cache_size_as_unlimited() {
678        let tmp = tempdir().unwrap();
679        let cfg = ResolvedAssetConfig {
680            cache_dir: tmp.path().to_path_buf(),
681            max_cache_size: 0, // "unlimited"
682            max_file_age: Duration::from_secs(100 * 86_400),
683            eviction_policy: EvictionPolicy::Lru,
684        };
685        let mgr = AssetManager::from_resolved(cfg).unwrap();
686        let ctx = AssetContext::Issue {
687            key: "DEV-1".into(),
688        };
689
690        // Storing a multi-megabyte blob under a zero budget must succeed
691        // and stay in the cache (no rotation eviction).
692        let big = vec![0u8; 2_000_000];
693        mgr.store(store_simple(ctx, "big", "big.bin", &big))
694            .unwrap();
695        assert_eq!(mgr.total_size().unwrap(), big.len() as u64);
696        assert_eq!(mgr.list().unwrap().len(), 1);
697
698        // Explicit rotate_now is a no-op under the zero budget.
699        let stats = mgr.rotate_now().unwrap();
700        assert_eq!(stats.removed(), 0);
701    }
702
703    #[test]
704    fn store_rejects_oversized_payload() {
705        let tmp = tempdir().unwrap();
706        let cfg = ResolvedAssetConfig {
707            cache_dir: tmp.path().to_path_buf(),
708            max_cache_size: 10,
709            max_file_age: Duration::from_secs(100 * 86_400),
710            eviction_policy: EvictionPolicy::Lru,
711        };
712        let mgr = AssetManager::from_resolved(cfg).unwrap();
713        let ctx = AssetContext::Issue {
714            key: "DEV-1".into(),
715        };
716
717        let err = mgr
718            .store(store_simple(ctx, "a1", "big.bin", &[0u8; 100]))
719            .unwrap_err();
720        let msg = err.to_string();
721        assert!(msg.contains("exceeds the cache"), "unexpected msg: {msg}");
722
723        // Nothing should have been written or tracked.
724        assert!(mgr.list().unwrap().is_empty());
725        assert_eq!(mgr.total_size().unwrap(), 0);
726    }
727
728    #[test]
729    fn get_returns_fresh_last_accessed() {
730        let tmp = tempdir().unwrap();
731        let mgr = manager(tmp.path().to_path_buf());
732        let ctx = AssetContext::Issue {
733            key: "DEV-1".into(),
734        };
735        let stored = mgr.store(store_simple(ctx, "a1", "a.bin", b"xyz")).unwrap();
736        let stored_at = stored.last_accessed_ms;
737
738        // Wait a few ms so the touch produces a strictly newer timestamp.
739        std::thread::sleep(std::time::Duration::from_millis(5));
740
741        let resolved = mgr.get("a1").unwrap().expect("asset present");
742        assert!(
743            resolved.asset.last_accessed_ms > stored_at,
744            "expected ResolvedAsset to reflect the post-touch timestamp: \
745             stored={stored_at}, returned={}",
746            resolved.asset.last_accessed_ms,
747        );
748
749        // And the index value matches what `get` returned.
750        let from_list = mgr
751            .list()
752            .unwrap()
753            .into_iter()
754            .find(|a| a.id == "a1")
755            .unwrap();
756        assert_eq!(from_list.last_accessed_ms, resolved.asset.last_accessed_ms);
757    }
758
759    #[test]
760    fn from_resolved_rotates_on_startup() {
761        let tmp = tempdir().unwrap();
762
763        // Seed the cache with two entries under a generous budget.
764        {
765            let mgr = manager(tmp.path().to_path_buf());
766            let ctx = AssetContext::Issue {
767                key: "DEV-1".into(),
768            };
769            mgr.store(store_simple(ctx.clone(), "a", "a.bin", &[0u8; 60]))
770                .unwrap();
771            mgr.store(store_simple(ctx, "b", "b.bin", &[0u8; 60]))
772                .unwrap();
773            assert_eq!(mgr.total_size().unwrap(), 120);
774        }
775
776        // Re-open with a tight budget — startup rotation should trim the
777        // cache back under the limit *before* we hand it to the caller.
778        let tight = ResolvedAssetConfig {
779            cache_dir: tmp.path().to_path_buf(),
780            max_cache_size: 100,
781            max_file_age: Duration::from_secs(100 * 86_400),
782            eviction_policy: EvictionPolicy::Lru,
783        };
784        let mgr = AssetManager::from_resolved(tight).unwrap();
785        assert!(
786            mgr.total_size().unwrap() <= 100,
787            "cache still over budget on open"
788        );
789        assert_eq!(mgr.list().unwrap().len(), 1);
790    }
791
792    #[test]
793    fn with_root_uses_defaults() {
794        let tmp = tempdir().unwrap();
795        let mgr = AssetManager::with_root(tmp.path().to_path_buf()).unwrap();
796        assert_eq!(mgr.cache_dir(), tmp.path());
797        assert!(mgr.config().max_cache_size > 0);
798    }
799
800    // =================================================================
801    // Dual-mode asset_id tests
802    // =================================================================
803
804    #[test]
805    fn store_auto_generates_content_addressed_id() {
806        let tmp = tempdir().unwrap();
807        let mgr = manager(tmp.path().to_path_buf());
808        let ctx = AssetContext::Issue {
809            key: "DEV-1".into(),
810        };
811
812        let asset = mgr
813            .store(StoreRequest {
814                context: ctx,
815                asset_id: None, // auto-generate
816                filename: "trace.log",
817                mime_type: None,
818                remote_url: None,
819                data: b"stack trace here",
820            })
821            .unwrap();
822
823        assert!(
824            asset.id.starts_with("sha256:"),
825            "auto-generated id should have sha256: prefix, got: {}",
826            asset.id,
827        );
828        assert_eq!(asset.id.len(), 7 + 16); // "sha256:" + 16 hex chars
829
830        // Same content → same id (dedup).
831        let expected = AssetManager::content_id(b"stack trace here");
832        assert_eq!(asset.id, expected);
833
834        // Retrievable by the generated id.
835        let resolved = mgr.get(&asset.id).unwrap().expect("should be cached");
836        assert_eq!(
837            std::fs::read(&resolved.absolute_path).unwrap(),
838            b"stack trace here",
839        );
840    }
841
842    #[test]
843    fn store_deduplicates_by_content_id() {
844        let tmp = tempdir().unwrap();
845        let mgr = manager(tmp.path().to_path_buf());
846        let ctx = AssetContext::Issue {
847            key: "DEV-1".into(),
848        };
849
850        let a = mgr
851            .store(StoreRequest {
852                context: ctx.clone(),
853                asset_id: None,
854                filename: "a.log",
855                mime_type: None,
856                remote_url: None,
857                data: b"same content",
858            })
859            .unwrap();
860
861        let b = mgr
862            .store(StoreRequest {
863                context: ctx,
864                asset_id: None,
865                filename: "b.log",
866                mime_type: None,
867                remote_url: None,
868                data: b"same content",
869            })
870            .unwrap();
871
872        // Same content → same id, single cache entry.
873        assert_eq!(a.id, b.id);
874        assert_eq!(mgr.list().unwrap().len(), 1);
875    }
876
877    #[test]
878    fn store_rejects_empty_asset_id() {
879        let tmp = tempdir().unwrap();
880        let mgr = manager(tmp.path().to_path_buf());
881        let ctx = AssetContext::Issue {
882            key: "DEV-1".into(),
883        };
884
885        let err = mgr
886            .store(StoreRequest {
887                context: ctx,
888                asset_id: Some(""),
889                filename: "x.txt",
890                mime_type: None,
891                remote_url: None,
892                data: b"x",
893            })
894            .unwrap_err();
895        assert!(err.to_string().contains("empty"), "unexpected: {err}");
896    }
897
898    #[test]
899    fn store_rejects_overly_long_asset_id() {
900        let tmp = tempdir().unwrap();
901        let mgr = manager(tmp.path().to_path_buf());
902        let ctx = AssetContext::Issue {
903            key: "DEV-1".into(),
904        };
905
906        let long_id = "x".repeat(MAX_ASSET_ID_LEN + 1);
907        let err = mgr
908            .store(StoreRequest {
909                context: ctx,
910                asset_id: Some(&long_id),
911                filename: "x.txt",
912                mime_type: None,
913                remote_url: None,
914                data: b"x",
915            })
916            .unwrap_err();
917        assert!(err.to_string().contains("200"), "unexpected: {err}");
918    }
919
920    #[test]
921    fn content_id_is_deterministic() {
922        let a = AssetManager::content_id(b"hello");
923        let b = AssetManager::content_id(b"hello");
924        assert_eq!(a, b);
925        assert!(a.starts_with("sha256:"));
926
927        let c = AssetManager::content_id(b"world");
928        assert_ne!(a, c);
929    }
930}