Skip to main content

oximemo_core/
vault.rs

1//! The [`Vault`] facade: the single entry point both the CLI and the Tauri app
2//! use. It coordinates the file store (source of truth) with the derived redb +
3//! tantivy indexes, guarded by a cross-process advisory lock (§5.7).
4//!
5//! # Concurrency model
6//! redb guarantees a single in-process writer but nothing across processes.
7//! We bracket every operation with an `fs2` flock on `meta.redb.lock`:
8//! - **shared** for reads (multiple readers, including a CLI reading while the
9//!   GUI runs),
10//! - **exclusive** for writes.
11//!
12//! redb and tantivy are opened *transiently* within the lock scope, so no
13//! process holds them open across the boundary — two processes never collide on
14//! redb's or tantivy's own single-writer locks. This keeps the agent path
15//! (`oximemo …` while the GUI is running) correct.
16
17use std::path::{Path, PathBuf};
18use std::time::Duration;
19
20use time::OffsetDateTime;
21
22use parking_lot::RwLock;
23
24use crate::config::{AUTO_COLORS, CategoryDef, VaultConfig};
25use crate::error::{CoreError, Result};
26use crate::hash;
27use crate::lock::{FileLock, LockKind, acquire};
28use crate::memo::{Cursor, IndexStats, Memo, MemoFilter, MemoId, MemoSummary, Page, make_preview};
29use crate::paths::Paths;
30use crate::store::files::FileStore;
31use crate::store::index::{IndexRecord, MemoIndex, RedbIndex};
32use crate::store::search::{SearchIndex, TantivySearch};
33use crate::sync::{FullRecord, ManifestRecord};
34use crate::tags::extract_tags;
35
36/// How long to wait for the cross-process index lock before timing out (§5.7).
37const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
38
39/// Indexed preview format version. Bump when `make_preview`'s output changes;
40/// [`Vault::migrate`] reindexes once per bump so cached card previews are
41/// regenerated. Stored in `<index_dir>/index-fmt`.
42const INDEX_FORMAT_VERSION: u32 = 3;
43
44pub struct Vault {
45    paths: Paths,
46    config: RwLock<VaultConfig>,
47    files: FileStore,
48}
49
50impl Vault {
51    /// Resolve a vault (default location when `vault` is `None`) and load its
52    /// config. Does not create directories — call [`Self::ensure_initialized`]
53    /// for that.
54    pub fn open(vault: Option<&Path>) -> Result<Self> {
55        let paths = Paths::resolve(vault);
56        let config = VaultConfig::load(&paths);
57        let files = FileStore::new(paths.clone());
58        Ok(Self {
59            paths,
60            config: RwLock::new(config),
61            files,
62        })
63    }
64
65    /// Read config under a read guard. Use [`Self::categories`] when you only
66    /// need the list of categories — that helper already takes the guard and
67    /// clones for you.
68    pub fn with_config<R>(&self, f: impl FnOnce(&VaultConfig) -> R) -> R {
69        f(&self.config.read())
70    }
71
72    /// Snapshot of the current category list (cloned under the read guard).
73    pub fn categories(&self) -> Vec<crate::config::CategoryDef> {
74        self.config.read().categories.items.clone()
75    }
76
77    /// Create a user-defined category. `id` is normalized (trimmed + lowercased);
78    /// rejects empty ids and collisions (including the built-in `inbox`).
79    /// `color = None` picks the first unused entry from `AUTO_COLORS`.
80    pub fn create_category(&self, id: String, color: Option<String>) -> Result<CategoryDef> {
81        let id = normalize_id(&id);
82        if id.is_empty() {
83            return Err(CoreError::other("category id empty"));
84        }
85        let mut cfg = self.config.write();
86        if cfg.categories.items.iter().any(|c| c.id == id) {
87            return Err(CoreError::other(format!("category '{id}' exists")));
88        }
89        let color = color.unwrap_or_else(|| pick_auto_color(&cfg.categories.items));
90        let def = CategoryDef {
91            id: id.clone(),
92            color,
93            builtin: false,
94        };
95        cfg.categories.items.push(def.clone());
96        cfg.save(&self.paths)?;
97        Ok(def)
98    }
99
100    /// Update an existing category's color. Errors if the id doesn't match any
101    /// known category. `inbox` (and any other built-in) can be re-colored; only
102    /// deletion is restricted.
103    pub fn update_category(&self, id: String, color: String) -> Result<()> {
104        let id = normalize_id(&id);
105        let mut cfg = self.config.write();
106        let def = cfg
107            .categories
108            .items
109            .iter_mut()
110            .find(|c| c.id == id)
111            .ok_or_else(|| CoreError::other(format!("category '{id}' not found")))?;
112        def.color = color;
113        cfg.save(&self.paths)
114    }
115
116    /// Remove a user-defined category. The built-in `inbox` cannot be deleted.
117    pub fn delete_category(&self, id: String) -> Result<()> {
118        let id = normalize_id(&id);
119        if id == crate::memo::DEFAULT_CATEGORY {
120            return Err(CoreError::other("inbox cannot be deleted"));
121        }
122        let mut cfg = self.config.write();
123        let before = cfg.categories.items.len();
124        cfg.categories.items.retain(|c| c.id != id);
125        if cfg.categories.items.len() == before {
126            return Err(CoreError::other(format!("category '{id}' not found")));
127        }
128        cfg.save(&self.paths)
129    }
130
131    pub fn paths(&self) -> &Paths {
132        &self.paths
133    }
134
135    /// Create the vault + index directories if missing.
136    pub fn ensure_initialized(&self) -> Result<()> {
137        std::fs::create_dir_all(self.paths.memos_root())?;
138        std::fs::create_dir_all(self.paths.trash_root())?;
139        std::fs::create_dir_all(self.paths.assets_root())?;
140        std::fs::create_dir_all(&self.paths.index_dir)?;
141        Ok(())
142    }
143
144    // -- assets ----------------------------------------------------------
145
146    /// Persist raw image bytes as a content-addressed asset and return the
147    /// `oximg://` reference to drop into markdown. Identical bytes dedup to
148    /// the same file (no rewrite). `ext` is normalized + whitelisted.
149    pub fn save_asset(&self, bytes: &[u8], ext: &str) -> Result<crate::assets::AssetRef> {
150        self.ensure_initialized()?;
151        let ext = crate::assets::normalize_ext(ext)?;
152        let name = crate::assets::asset_name(bytes, ext);
153        let path = self.paths.asset_path(&name);
154        if !path.exists() {
155            // Atomic write like memo files: temp + rename.
156            let tmp = path.with_extension("tmp");
157            std::fs::write(&tmp, bytes)?;
158            std::fs::rename(&tmp, &path)?;
159        }
160        Ok(crate::assets::AssetRef {
161            url: format!("oximg://localhost/{name}"),
162            name,
163        })
164    }
165
166    /// Read an image from an arbitrary path (file-picker) and store it. The
167    /// extension is taken from the source filename.
168    pub fn save_asset_from_path(&self, src: &Path) -> Result<crate::assets::AssetRef> {
169        let ext = src
170            .extension()
171            .and_then(|e| e.to_str())
172            .ok_or_else(|| CoreError::AssetRejected("source has no extension".into()))?;
173        let bytes = std::fs::read(src)?;
174        self.save_asset(&bytes, ext)
175    }
176
177    /// Serve an asset's bytes + content-type for the `oximg://` handler.
178    /// Returns `None` if the name is malformed or the file is absent.
179    pub fn read_asset(&self, name: &str) -> Option<(Vec<u8>, &'static str)> {
180        if !crate::assets::valid_name(name) {
181            return None;
182        }
183        let ext = crate::assets::ext_of(name)?;
184        let path = self.paths.asset_path(name);
185        std::fs::read(&path)
186            .ok()
187            .map(|b| (b, crate::assets::mime_for_ext(ext)))
188    }
189
190    /// All assets on disk, newest-first (gallery). Cheap: a single dir read.
191    pub fn list_assets(&self) -> Result<Vec<crate::assets::AssetInfo>> {
192        use crate::assets::{AssetInfo, valid_name};
193        let root = self.paths.assets_root();
194        if !root.exists() {
195            return Ok(Vec::new());
196        }
197        let mut out = Vec::new();
198        for entry in std::fs::read_dir(&root)? {
199            let entry = entry?;
200            let Some(name) = entry.file_name().to_str().map(str::to_string) else {
201                continue;
202            };
203            if !valid_name(&name) {
204                continue;
205            }
206            let meta = entry.metadata()?;
207            let modified = meta
208                .modified()
209                .ok()
210                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
211                .map(|d| {
212                    OffsetDateTime::from_unix_timestamp(d.as_secs() as i64)
213                        .unwrap_or_else(|_| OffsetDateTime::now_utc())
214                })
215                .unwrap_or_else(OffsetDateTime::now_utc);
216            let ext = crate::assets::ext_of(&name).unwrap_or("").to_string();
217            out.push(AssetInfo {
218                url: format!("oximg://localhost/{name}"),
219                name,
220                ext,
221                bytes: meta.len(),
222                modified,
223            });
224        }
225        out.sort_by_key(|b| std::cmp::Reverse(b.modified));
226        Ok(out)
227    }
228
229    /// Delete assets referenced by no live memo. Returns the count removed.
230    /// Scans every memo body, so call from an explicit user action (gallery
231    /// "clean up"), not a hot path.
232    pub fn gc_assets(&self) -> Result<u64> {
233        let live = self.asset_refs_in_bodies()?;
234        let root = self.paths.assets_root();
235        if !root.exists() {
236            return Ok(0);
237        }
238        let mut removed = 0u64;
239        for entry in std::fs::read_dir(&root)? {
240            let entry = entry?;
241            let Some(name) = entry.file_name().to_str().map(str::to_string) else {
242                continue;
243            };
244            if !crate::assets::valid_name(&name) || live.contains(&name) {
245                continue;
246            }
247            if std::fs::remove_file(entry.path()).is_ok() {
248                removed += 1;
249            }
250        }
251        Ok(removed)
252    }
253
254    /// Collect every `oximg://<name>` referenced across all live memo bodies.
255    fn asset_refs_in_bodies(&self) -> Result<std::collections::HashSet<String>> {
256        let mut live = std::collections::HashSet::new();
257        for path in self.files.list_memo_files() {
258            match self.files.read_memo(&path) {
259                Ok(Some(parsed)) => {
260                    for name in crate::assets::refs_in_body(&parsed.body) {
261                        live.insert(name);
262                    }
263                }
264                Ok(None) => {}
265                Err(e) => tracing::warn!(
266                    path = %path.display(),
267                    error = %e,
268                    "gc: skipping unparseable memo; its image refs are not counted"
269                ),
270            }
271        }
272        Ok(live)
273    }
274
275    /// First live memo whose body references asset `name`, or `None`. Powers
276    /// the gallery's "open the memo containing this image".
277    pub fn find_memo_by_asset(&self, name: &str) -> Result<Option<MemoId>> {
278        for path in self.files.list_memo_files() {
279            match self.files.read_memo(&path) {
280                Ok(Some(parsed)) if crate::assets::refs_in_body(&parsed.body).contains(name) => {
281                    return Ok(Some(parsed.id));
282                }
283                _ => {}
284            }
285        }
286        Ok(None)
287    }
288
289    // -- locking helpers --------------------------------------------------
290
291    fn lock(&self, kind: LockKind) -> Result<FileLock> {
292        acquire(&self.paths.meta_lock_path(), kind, LOCK_TIMEOUT)
293    }
294
295    /// Shared lock + transient redb, for a read operation.
296    fn with_redb<R>(&self, f: impl FnOnce(&RedbIndex) -> Result<R>) -> Result<R> {
297        let _g = self.lock(LockKind::Shared)?;
298        let idx = RedbIndex::open(&self.paths.meta_db_path())?;
299        f(&idx)
300    }
301
302    /// Exclusive lock + transient redb & tantivy, for a mutating operation.
303    fn with_redb_and_search<R>(
304        &self,
305        f: impl FnOnce(&RedbIndex, &TantivySearch) -> Result<R>,
306    ) -> Result<R> {
307        let _g = self.lock(LockKind::Exclusive)?;
308        let idx = RedbIndex::open(&self.paths.meta_db_path())?;
309        let search = TantivySearch::open(&self.paths.search_dir())?;
310        f(&idx, &search)
311    }
312
313    // -- CRUD -------------------------------------------------------------
314
315    pub fn create_memo(&self, body: String, category: Option<String>) -> Result<Memo> {
316        self.ensure_initialized()?;
317        let tags = extract_tags(&body);
318        validate_note_input(&body, &tags)?;
319        let now = OffsetDateTime::now_utc();
320        let id = MemoId::now();
321        let category = category.unwrap_or_else(|| crate::memo::DEFAULT_CATEGORY.to_string());
322        let note = Memo {
323            id,
324            created_at: now,
325            updated_at: now,
326            hash: hash::hash_memo(body.as_bytes(), false, &category),
327            favorite: false,
328            category,
329            tags,
330            body,
331            deleted_at: None,
332        };
333        self.files.write(&note)?;
334        self.with_redb_and_search(|idx, search| {
335            idx.upsert(&record_of(&note))?;
336            search.upsert(note.id, &note.body, &note.tags)
337        })?;
338        Ok(note)
339    }
340
341    pub fn get_memo(&self, id: MemoId) -> Result<Memo> {
342        // Use the index to learn created_at, which pins the sharded file path.
343        let created_at = self.with_redb(|idx| idx.get(id))?.map(|r| r.created_at);
344        if let Some(ca) = created_at {
345            let live = self.paths.memo_path(id, ca);
346            if live.exists() {
347                return self
348                    .files
349                    .read_memo(&live)?
350                    .ok_or_else(|| CoreError::NotFound(id.to_string()));
351            }
352            let trash = self.paths.trash_path(id);
353            if trash.exists() {
354                return self
355                    .files
356                    .read_memo(&trash)?
357                    .ok_or_else(|| CoreError::NotFound(id.to_string()));
358            }
359        }
360        // Index miss (not yet indexed): scan the tree.
361        for path in self
362            .files
363            .list_memo_files()
364            .iter()
365            .chain(self.files.list_trash_files().iter())
366        {
367            if let Ok(Some(n)) = self.files.read_memo(path)
368                && n.id == id
369            {
370                return Ok(n);
371            }
372        }
373        Err(CoreError::NotFound(id.to_string()))
374    }
375
376    pub fn update_memo(
377        &self,
378        id: MemoId,
379        body: Option<String>,
380        favorite: Option<bool>,
381        category: Option<String>,
382    ) -> Result<Memo> {
383        let mut note = self.get_memo(id)?;
384        if let Some(b) = body {
385            note.body = b;
386            note.tags = extract_tags(&note.body);
387        }
388        if let Some(p) = favorite {
389            note.favorite = p;
390        }
391        if let Some(c) = category {
392            note.category = c;
393        }
394        validate_note_input(&note.body, &note.tags)?;
395        note.updated_at = OffsetDateTime::now_utc();
396        note.hash = hash::hash_memo(note.body.as_bytes(), note.favorite, &note.category);
397        self.files.write(&note)?;
398        self.with_redb_and_search(|idx, search| {
399            idx.upsert(&record_of(&note))?;
400            search.upsert(note.id, &note.body, &note.tags)
401        })?;
402        Ok(note)
403    }
404
405    /// Soft-delete: move to trash, mark tombstone, drop from search (§5.4).
406    pub fn delete_memo(&self, id: MemoId) -> Result<()> {
407        let mut note = self.get_memo(id)?;
408        let now = OffsetDateTime::now_utc();
409        note.deleted_at = Some(now);
410        note.updated_at = now;
411        note.hash = hash::hash_memo(note.body.as_bytes(), note.favorite, &note.category);
412        self.files.move_to_trash(&note)?;
413        self.files.write(&note)?;
414        self.with_redb_and_search(|idx, search| {
415            idx.upsert(&record_of(&note))?;
416            search.remove(note.id)
417        })?;
418        Ok(())
419    }
420
421    pub fn restore_memo(&self, id: MemoId) -> Result<Memo> {
422        let mut note = self.get_memo(id)?;
423        note.deleted_at = None;
424        note.updated_at = OffsetDateTime::now_utc();
425        note.hash = hash::hash_memo(note.body.as_bytes(), note.favorite, &note.category);
426        self.files.restore_from_trash(&note)?;
427        self.files.write(&note)?;
428        self.with_redb_and_search(|idx, search| {
429            idx.upsert(&record_of(&note))?;
430            search.upsert(note.id, &note.body, &note.tags)
431        })?;
432        Ok(note)
433    }
434
435    /// Hard-delete trashed memos whose `deleted_at` is older than `retention`.
436    /// Returns the number purged.
437    pub fn purge(&self, retention: Duration) -> Result<u64> {
438        let cutoff = OffsetDateTime::now_utc() - retention;
439        let mut purged = 0u64;
440        self.with_redb_and_search(|idx, search| {
441            for path in self.files.list_trash_files() {
442                let Ok(Some(n)) = self.files.read_memo(&path) else {
443                    continue;
444                };
445                if n.deleted_at.is_some_and(|t| t < cutoff) {
446                    self.files.purge(n.id)?;
447                    idx.remove(n.id)?;
448                    search.remove(n.id)?;
449                    purged += 1;
450                }
451            }
452            Ok(())
453        })?;
454        Ok(purged)
455    }
456
457    // -- queries ----------------------------------------------------------
458
459    pub fn list_memos(
460        &self,
461        after: Option<Cursor>,
462        limit: u32,
463        filter: MemoFilter,
464    ) -> Result<Page<MemoSummary>> {
465        self.with_redb(|idx| {
466            let recs = idx.list(after, limit, &filter)?;
467            let items: Vec<MemoSummary> = recs.iter().map(|r| r.to_summary()).collect();
468            let next_cursor = items.last().and_then(|s| {
469                serde_json::to_string(&Cursor {
470                    updated_at: s.updated_at,
471                    id: s.id,
472                })
473                .ok()
474            });
475            Ok(Page { items, next_cursor })
476        })
477    }
478
479    pub fn search_memos(&self, query: &str, limit: u32) -> Result<Vec<MemoSummary>> {
480        let _g = self.lock(LockKind::Shared)?;
481        let search = TantivySearch::open(&self.paths.search_dir())?;
482        let idx = RedbIndex::open(&self.paths.meta_db_path())?;
483        let ids = search.search(query, limit)?;
484        let mut out = Vec::with_capacity(ids.len());
485        for id in ids {
486            if let Some(r) = idx.get(id)? {
487                if r.deleted {
488                    continue;
489                }
490                out.push(r.to_summary());
491            }
492        }
493        Ok(out)
494    }
495
496    pub fn get_note_summary(&self, id: MemoId) -> Result<MemoSummary> {
497        self.with_redb(|idx| match idx.get(id)? {
498            Some(r) => Ok(r.to_summary()),
499            None => Err(CoreError::NotFound(id.to_string())),
500        })
501    }
502
503    /// Live note counts (soft-deleted tombstones excluded).
504    pub fn memo_stats(&self) -> Result<crate::memo::MemoStats> {
505        self.with_redb(|idx| {
506            let recs = idx.export_since(None)?;
507            let mut stats = crate::memo::MemoStats::default();
508            for r in &recs {
509                if r.deleted {
510                    continue;
511                }
512                stats.memos += 1;
513                if r.favorite {
514                    stats.favorites += 1;
515                }
516            }
517            Ok(stats)
518        })
519    }
520
521    /// Tag + color counts over live (non-deleted) notes for the sidebar (§4.2).
522    pub fn list_facets(&self) -> Result<crate::memo::Facets> {
523        self.with_redb(|idx| {
524            let recs = idx.export_since(None)?;
525            let mut tag_map: std::collections::BTreeMap<String, u32> = Default::default();
526            let mut cat_map: std::collections::BTreeMap<String, u32> = Default::default();
527            for r in &recs {
528                if r.deleted {
529                    continue;
530                }
531                for t in &r.tags {
532                    *tag_map.entry(t.clone()).or_insert(0) += 1;
533                }
534                if !r.category.is_empty() {
535                    *cat_map.entry(r.category.clone()).or_insert(0) += 1;
536                }
537            }
538            Ok(crate::memo::Facets {
539                tags: tag_map.into_iter().collect(),
540                categories: cat_map.into_iter().collect(),
541            })
542        })
543    }
544
545    // -- sync / export (§9.2) --------------------------------------------
546
547    pub fn export_manifest(&self, since: Option<OffsetDateTime>) -> Result<Vec<ManifestRecord>> {
548        self.with_redb(|idx| {
549            let recs = idx.export_since(since)?;
550            Ok(recs
551                .iter()
552                .map(|r| ManifestRecord {
553                    id: r.id,
554                    hash: r.hash.clone(),
555                    updated_at: r.updated_at,
556                    deleted: r.deleted,
557                })
558                .collect())
559        })
560    }
561
562    pub fn export_full(&self, ids: &[MemoId]) -> Result<Vec<FullRecord>> {
563        let mut out = Vec::with_capacity(ids.len());
564        for id in ids {
565            match self.get_memo(*id) {
566                Ok(n) => out.push(FullRecord::from_memo(&n)),
567                Err(CoreError::NotFound(_)) => { /* skip missing */ }
568                Err(e) => return Err(e),
569            }
570        }
571        Ok(out)
572    }
573
574    // -- maintenance ------------------------------------------------------
575
576    /// Rebuild the indexes from the source-of-truth files (§5.5, §9.1).
577    pub fn reindex(&self) -> Result<IndexStats> {
578        self.ensure_initialized()?;
579        self.with_redb_and_search(|idx, search| {
580            let mut stats = IndexStats::default();
581            // Collect search upserts to commit once (H1: avoids one tantivy
582            // commit/fsync per note). redb upserts stay per-call; they're cheap.
583            let mut search_owned: Vec<(MemoId, String, Vec<String>)> = Vec::new();
584            for path in self.files.list_memo_files() {
585                match self.files.read_memo(&path) {
586                    Ok(Some(note)) => {
587                        let rec = record_of(&note);
588                        match idx.get(note.id)? {
589                            None => {
590                                idx.upsert(&rec)?;
591                                search_owned.push((note.id, note.body, note.tags));
592                                stats.added += 1;
593                            }
594                            Some(prev) if prev.hash == rec.hash && prev.preview == rec.preview => {
595                                stats.unchanged += 1;
596                            }
597                            Some(_) => {
598                                idx.upsert(&rec)?;
599                                search_owned.push((note.id, note.body, note.tags));
600                                stats.updated += 1;
601                            }
602                        }
603                        stats.memos += 1;
604                    }
605                    Ok(None) => {}
606                    Err(e) => {
607                        tracing::warn!(path = %path.display(), error = %e, "reindex: parse failed");
608                        stats.failed += 1;
609                    }
610                }
611            }
612            for path in self.files.list_trash_files() {
613                if let Ok(Some(note)) = self.files.read_memo(&path) {
614                    let rec = record_of(&note);
615                    idx.upsert(&rec)?;
616                    search_owned.push((note.id, note.body, note.tags));
617                    stats.trashed_memos += 1;
618                }
619            }
620            // One tantivy commit for everything that changed.
621            let batch: Vec<crate::store::search::Upsert<'_>> = search_owned
622                .iter()
623                .map(|(id, body, tags)| crate::store::search::Upsert {
624                    id: *id,
625                    body,
626                    tags,
627                })
628                .collect();
629            search.upsert_batch(&batch)?;
630            Ok(stats)
631        })
632    }
633    /// One-time migrations run on first use:
634    /// 1. Rename the live memo tree from `notes/` to `memos/` if the legacy
635    ///    path is non-empty and the new one is absent.
636    /// 2. Rebuild the index when the cached preview format lags the current
637    ///    `make_preview` (or the marker is absent), so existing memos' card
638    ///    previews pick up line-break preservation.
639    ///
640    /// Idempotent: subsequent calls are no-ops once both markers are current.
641    pub fn migrate(&self) -> Result<()> {
642        // Rename the legacy live tree to its new name BEFORE ensure_initialized,
643        // which would otherwise create an empty `memos/` and make the
644        // `!new_root.exists()` guard permanently false.
645        let old_root = self.paths.vault.join("notes");
646        let new_root = self.paths.memos_root();
647        if old_root.exists()
648            && old_root != new_root
649            && !new_root.exists()
650            && std::fs::read_dir(&old_root)?.next().is_some()
651        {
652            tracing::info!(from = %old_root.display(), to = %new_root.display(), "renaming vault memos root");
653            std::fs::rename(&old_root, &new_root)?;
654        }
655        self.ensure_initialized()?;
656        let marker = self.paths.index_fmt_marker_path();
657        let wants = INDEX_FORMAT_VERSION.to_string();
658        if std::fs::read_to_string(&marker)
659            .ok()
660            .map(|s| s.trim() == wants)
661            .unwrap_or(false)
662        {
663            return Ok(());
664        }
665        tracing::info!(
666            version = INDEX_FORMAT_VERSION,
667            "migrating index preview format"
668        );
669        self.reindex()?;
670        std::fs::write(&marker, &wants)?;
671        Ok(())
672    }
673
674    /// Re-index a single changed file. Called by the watcher (debounced). Handles
675    /// create/modify (upsert) and removal (delete from index + search).
676    pub fn reindex_path(&self, path: &Path) {
677        if let Err(e) = self.do_reindex_path(path) {
678            tracing::warn!(path = %path.display(), error = %e, "watcher reindex failed");
679        }
680    }
681
682    fn do_reindex_path(&self, path: &Path) -> Result<()> {
683        if !path.exists() {
684            if let Some(id) = id_from_path(path) {
685                self.with_redb_and_search(|idx, search| {
686                    idx.remove(id)?;
687                    search.remove(id)
688                })?;
689            }
690            return Ok(());
691        }
692        match self.files.read_memo(path)? {
693            Some(note) => self.with_redb_and_search(|idx, search| {
694                idx.upsert(&record_of(&note))?;
695                search.upsert(note.id, &note.body, &note.tags)
696            }),
697            None => Ok(()),
698        }
699    }
700
701    /// Start the background file watcher (§5.5). The returned handle must be
702    /// kept alive for the lifetime of the watch.
703    pub fn watch(&self) -> Result<crate::watcher::MemoWatcher> {
704        let debounce = Duration::from_millis(self.config.read().index.watcher_debounce_ms as u64);
705        let vault_path = self.paths.vault.clone();
706        // Re-open a Vault per callback: each op takes its own lock, so the
707        // watcher coordinates with concurrent CLI/GUI access naturally.
708        let on_change: crate::watcher::OnChange = std::sync::Arc::new(move |path| {
709            let Ok(v) = Vault::open(Some(&vault_path)) else {
710                return;
711            };
712            v.reindex_path(&path);
713        });
714        crate::watcher::MemoWatcher::spawn(
715            vec![self.paths.memos_root(), self.paths.trash_root()],
716            debounce,
717            on_change,
718        )
719    }
720
721    /// Consistency check (§9.3). When `fix` is true, safe repairs are applied
722    /// (hash recompute + rewrite; orphan index cleanup). Files are never deleted.
723    pub fn doctor(&self, fix: bool) -> Result<DoctorReport> {
724        self.ensure_initialized()?;
725        let mut report = DoctorReport {
726            index_locked: crate::lock::is_locked(&self.paths.meta_lock_path()),
727            ..DoctorReport::default()
728        };
729
730        // Gather indexed ids for orphan detection.
731        let all_recs = self.with_redb(|idx| idx.export_since(None))?;
732        let indexed: std::collections::HashMap<MemoId, IndexRecord> =
733            all_recs.iter().map(|r| (r.id, r.clone())).collect();
734
735        let mut seen: std::collections::HashSet<MemoId> = std::collections::HashSet::new();
736        for path in self
737            .files
738            .list_memo_files()
739            .iter()
740            .chain(self.files.list_trash_files().iter())
741        {
742            match self.files.read_memo(path) {
743                Ok(Some(mut note)) => {
744                    seen.insert(note.id);
745                    // Categories have no format validity — only orphan/index
746                    // consistency is checked here.
747                    let recomputed =
748                        hash::hash_memo(note.body.as_bytes(), note.favorite, &note.category);
749                    if recomputed != note.hash {
750                        // Report only *unresolved* mismatches. When --fix
751                        // rewrites successfully the memo is no longer a
752                        // mismatch; a failed rewrite is counted separately.
753                        let repaired = if fix {
754                            note.hash = recomputed;
755                            match self.files.write(&note) {
756                                Ok(_) => true,
757                                Err(e) => {
758                                    report.hash_repair_failed += 1;
759                                    tracing::warn!(
760                                        id = %note.id,
761                                        error = %e,
762                                        "doctor: failed to rewrite hash"
763                                    );
764                                    false
765                                }
766                            }
767                        } else {
768                            false
769                        };
770                        if !repaired {
771                            report.hash_mismatches.push(note.id);
772                        }
773                    }
774                }
775                Ok(None) => report.orphan_files.push(path.clone()),
776                Err(CoreError::Frontmatter { reason, .. }) => {
777                    report.corrupt_frontmatter.push((path.clone(), reason));
778                }
779                Err(e) => {
780                    report
781                        .corrupt_frontmatter
782                        .push((path.clone(), e.to_string()));
783                }
784            }
785        }
786
787        for id in indexed.keys() {
788            if !seen.contains(id) {
789                report.orphan_index_records.push(*id);
790            }
791        }
792        if fix {
793            // Remove orphan index records + search docs in one locked pass.
794            let orphans = report.orphan_index_records.clone();
795            self.with_redb_and_search(|idx, search| {
796                for id in &orphans {
797                    idx.remove(*id)?;
798                    search.remove(*id)?;
799                }
800                Ok(())
801            })?;
802            report.orphan_index_records.clear();
803        }
804
805        // Trash purge estimate.
806        let cutoff = OffsetDateTime::now_utc()
807            - Duration::from_secs(86400 * self.config.read().general.trash_retention_days as u64);
808        for path in self.files.list_trash_files() {
809            if let Ok(Some(n)) = self.files.read_memo(&path)
810                && n.deleted_at.is_some_and(|t| t < cutoff)
811            {
812                report.trash_expiring += 1;
813            }
814        }
815
816        report.vault_ok = self.paths.vault.is_dir();
817        Ok(report)
818    }
819
820    /// Rename a category, migrating every note whose `category` matches `old`
821    /// to `new`. Each migrated note is rewritten (category, updated_at, hash),
822    /// re-added to the redb index and the tantivy search index, and the
823    /// category registry is updated and persisted. Returns the number of
824    /// notes migrated.
825    ///
826    /// Rules:
827    /// - `old` and `new` are normalized (trimmed + lowercased).
828    /// - Neither side may be the built-in `inbox` (immutable).
829    /// - `old` must exist in the registry; `new` must not collide.
830    /// - `old == new` is rejected.
831    pub fn rename_category(&self, old: String, new: String) -> Result<u64> {
832        let old = normalize_id(&old);
833        let new = normalize_id(&new);
834        if old == crate::memo::DEFAULT_CATEGORY || new == crate::memo::DEFAULT_CATEGORY {
835            return Err(CoreError::other("inbox id is immutable"));
836        }
837        if old == new {
838            return Err(CoreError::other("old == new"));
839        }
840
841        // validate + mutate registry under write lock, but defer save until after migration
842        {
843            let cfg = self.config.read();
844            if !cfg.categories.items.iter().any(|c| c.id == old) {
845                return Err(CoreError::other(format!("category '{old}' not found")));
846            }
847            if cfg.categories.items.iter().any(|c| c.id == new) {
848                return Err(CoreError::other(format!("category '{new}' exists")));
849            }
850        }
851
852        let mut migrated = 0u64;
853        self.with_redb_and_search(|idx, search| {
854            for rec in idx.export_since(None)? {
855                if rec.category != old {
856                    continue;
857                }
858                let path = self.paths.memo_path(rec.id, rec.created_at);
859                let mut note = self
860                    .files
861                    .read_memo(&path)?
862                    .ok_or_else(|| CoreError::NotFound(rec.id.to_string()))?;
863                note.category = new.clone();
864                note.updated_at = OffsetDateTime::now_utc();
865                note.hash = hash::hash_memo(note.body.as_bytes(), note.favorite, &note.category);
866                self.files.write(&note)?;
867                idx.upsert(&record_of(&note))?;
868                search.upsert(note.id, &note.body, &note.tags)?;
869                migrated += 1;
870            }
871            Ok(())
872        })?;
873
874        // update registry + persist
875        {
876            let mut cfg = self.config.write();
877            for def in cfg.categories.items.iter_mut() {
878                if def.id == old {
879                    def.id = new.clone();
880                }
881            }
882            cfg.save(&self.paths)?;
883        }
884        Ok(migrated)
885    }
886
887    /// Wipe all memos, trash, and the derived redb + tantivy indexes,
888    /// returning the vault to an empty state. The source-of-truth `.md` files
889    /// (live + trash) are deleted and both indexes are cleared. Category/color
890    /// config is preserved. Backs the settings "reset" action.
891    pub fn reset(&self) -> Result<()> {
892        self.ensure_initialized()?;
893        // Wipe under the exclusive index lock so a concurrent reader never
894        // observes an empty index pointing at files that still exist (or vice
895        // versa). Source-of-truth files go first; any removal error aborts the
896        // reset (indexes stay consistent with the surviving files) instead of
897        // being swallowed into a half-wiped vault.
898        let roots = [self.paths.memos_root(), self.paths.trash_root()];
899        self.with_redb_and_search(|idx, search| {
900            for root in roots {
901                if root.exists() {
902                    for entry in std::fs::read_dir(&root)? {
903                        let path = entry?.path();
904                        if path.is_dir() {
905                            std::fs::remove_dir_all(&path)?;
906                        } else {
907                            std::fs::remove_file(&path)?;
908                        }
909                    }
910                }
911            }
912            idx.clear()?;
913            search.clear()?;
914            Ok(())
915        })
916    }
917}
918
919/// Build an [`IndexRecord`] from a [`Memo`], deriving the card preview.
920fn record_of(n: &Memo) -> IndexRecord {
921    IndexRecord {
922        id: n.id,
923        created_at: n.created_at,
924        updated_at: n.updated_at,
925        hash: n.hash.clone(),
926        favorite: n.favorite,
927        category: n.category.clone(),
928        tags: n.tags.clone(),
929        deleted: n.deleted_at.is_some(),
930        deleted_at: n.deleted_at,
931        preview: make_preview(&n.body),
932    }
933}
934
935fn id_from_path(path: &Path) -> Option<MemoId> {
936    let stem = path.file_stem()?.to_str()?;
937    MemoId::parse(stem).ok()
938}
939
940/// Soft input bounds (H6). A note is a quick card, not a document; reject
941/// absurdly large bodies or tag spam before they hit the store/index so a
942/// malformed input can't bloat the indexes.
943const MAX_BODY_BYTES: usize = 64 * 1024;
944const MAX_TAGS: usize = 64;
945const MAX_TAG_LEN: usize = 64;
946
947fn validate_note_input(body: &str, tags: &[String]) -> Result<()> {
948    if body.len() > MAX_BODY_BYTES {
949        return Err(CoreError::other(format!(
950            "memo body too large: {} bytes (max {})",
951            body.len(),
952            MAX_BODY_BYTES
953        )));
954    }
955    if tags.len() > MAX_TAGS {
956        return Err(CoreError::other(format!(
957            "too many tags: {} (max {})",
958            tags.len(),
959            MAX_TAGS
960        )));
961    }
962    for t in tags {
963        if t.chars().count() > MAX_TAG_LEN {
964            return Err(CoreError::other(format!(
965                "tag too long: {} chars (max {})",
966                t.chars().count(),
967                MAX_TAG_LEN
968            )));
969        }
970    }
971    Ok(())
972}
973
974/// Output of `oximemo doctor` (§9.3).
975#[derive(Debug, Default, serde::Serialize)]
976pub struct DoctorReport {
977    pub corrupt_frontmatter: Vec<(PathBuf, String)>,
978    pub orphan_index_records: Vec<MemoId>,
979    pub orphan_files: Vec<PathBuf>,
980    pub hash_mismatches: Vec<MemoId>,
981    /// Notes whose hash was rewritten by `doctor --fix` but the write failed.
982    pub hash_repair_failed: u64,
983    pub index_locked: bool,
984    pub trash_expiring: u64,
985    pub vault_ok: bool,
986}
987
988/// Normalize a category id: trim surrounding whitespace and lowercase. ASCII
989/// slugs are the canonical form (brief §Task 3 — "ASCII slugs suffice"). If
990/// non-ASCII ids ever ship, prepend `.nfc().collect::<String>()` from
991/// `unicode-normalization` (already a dep).
992fn normalize_id(id: &str) -> String {
993    id.trim().to_lowercase()
994}
995
996/// Pick the first non-transparent `AUTO_COLORS` entry not already used by an
997/// existing item; falls back to the first real entry when every stop is in
998/// use. The inbox slot (`AUTO_COLORS[0]`, empty/transparent) is deliberately
999/// skipped so a new category always gets a real tint, never the transparent
1000/// default. Used when `Vault::create_category` is called with `color=None`.
1001fn pick_auto_color(items: &[CategoryDef]) -> String {
1002    AUTO_COLORS
1003        .iter()
1004        .skip(1) // skip inbox's transparent slot
1005        .find(|c| !items.iter().any(|item| &item.color == *c))
1006        .map(|s| (*s).to_string())
1007        .unwrap_or_else(|| AUTO_COLORS[1].to_string())
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012    use super::*;
1013    use tempfile::TempDir;
1014
1015    fn tmp_vault() -> (TempDir, Vault) {
1016        let dir = TempDir::new().unwrap();
1017        let v = Vault::open(Some(dir.path())).unwrap();
1018        (dir, v)
1019    }
1020
1021    #[test]
1022    fn create_get_update_delete_restore() {
1023        let (_t, v) = tmp_vault();
1024        let n = v.create_memo("hello world".into(), None).unwrap();
1025        let got = v.get_memo(n.id).unwrap();
1026        assert_eq!(got.body, "hello world");
1027
1028        let updated = v
1029            .update_memo(n.id, Some("edited".into()), Some(true), None)
1030            .unwrap();
1031        assert!(updated.favorite);
1032        assert_ne!(updated.hash, n.hash);
1033
1034        v.delete_memo(n.id).unwrap();
1035        let trashed = v.get_memo(n.id).unwrap();
1036        assert!(trashed.deleted_at.is_some());
1037
1038        v.restore_memo(n.id).unwrap();
1039        assert!(v.get_memo(n.id).unwrap().deleted_at.is_none());
1040    }
1041
1042    #[test]
1043    fn list_and_search() {
1044        let (_t, v) = tmp_vault();
1045        v.create_memo("rust async runtime".into(), None).unwrap();
1046        v.create_memo("go goroutines".into(), None).unwrap();
1047        let page = v.list_memos(None, 10, MemoFilter::default()).unwrap();
1048        assert_eq!(page.items.len(), 2);
1049        let hits = v.search_memos("rust", 10).unwrap();
1050        assert_eq!(hits.len(), 1);
1051    }
1052
1053    #[test]
1054    fn list_notes_next_cursor_roundtrips_as_string() {
1055        // The Tauri `list_memos(after: Option<String>)` command and the
1056        // frontend's cursor pagination both treat `next_cursor` as a JSON
1057        // *string* that `Cursor::parse(&str)` can read back. This locks that
1058        // contract: a non-null cursor must be a string and must round-trip.
1059        let (_t, v) = tmp_vault();
1060        v.create_memo("first note".into(), None).unwrap();
1061        v.create_memo("second note".into(), None).unwrap();
1062
1063        // limit below the count forces a non-null cursor on page 1.
1064        let page = v.list_memos(None, 1, MemoFilter::default()).unwrap();
1065        assert_eq!(page.items.len(), 1);
1066        let cursor = page.next_cursor.expect("page 1 must carry a next cursor");
1067        assert!(
1068            cursor.starts_with('{'),
1069            "next_cursor must be a JSON object string, got: {cursor}"
1070        );
1071
1072        let parsed = Cursor::parse(&cursor).expect("cursor must round-trip via Cursor::parse");
1073        let page2 = v
1074            .list_memos(Some(parsed), 10, MemoFilter::default())
1075            .unwrap();
1076        assert_eq!(
1077            page2.items.len(),
1078            1,
1079            "page 2 must return the remaining note"
1080        );
1081        // The cursor on page 2 still points past its last item; the pagination
1082        // terminator is the NEXT fetch returning an empty page with no cursor.
1083        let c2 = page2.next_cursor.expect("page 2 carries a cursor");
1084        let page3 = v
1085            .list_memos(Some(Cursor::parse(&c2).unwrap()), 10, MemoFilter::default())
1086            .unwrap();
1087        assert!(page3.items.is_empty(), "page 3 must be empty");
1088        assert!(
1089            page3.next_cursor.is_none(),
1090            "empty page must carry no cursor"
1091        );
1092    }
1093
1094    #[test]
1095    fn export_manifest_and_full_roundtrip() {
1096        let (_t, v) = tmp_vault();
1097        let n = v.create_memo("body text".into(), None).unwrap();
1098        let manifest = v.export_manifest(None).unwrap();
1099        assert_eq!(manifest.len(), 1);
1100        let full = v.export_full(&[n.id]).unwrap();
1101        assert_eq!(full[0].body, "body text");
1102    }
1103
1104    #[test]
1105    fn reindex_is_idempotent() {
1106        let (_t, v) = tmp_vault();
1107        v.create_memo("one".into(), None).unwrap();
1108        let s1 = v.reindex().unwrap();
1109        let s2 = v.reindex().unwrap();
1110        assert_eq!(s2.added, 0);
1111        assert!(s2.unchanged >= 1);
1112        let _ = s1;
1113    }
1114    #[test]
1115    fn derived_tags_from_body_end_to_end() {
1116        let (_t, v) = tmp_vault();
1117        let n = v.create_memo("회의록 #work #urgent".into(), None).unwrap();
1118        let got = v.get_memo(n.id).unwrap();
1119        // Tags are derived from the body, normalized + lowercased.
1120        assert_eq!(got.tags, vec!["work", "urgent"]);
1121
1122        // include filter (AND) matches a memo carrying the tag.
1123        let inc = v
1124            .list_memos(
1125                None,
1126                10,
1127                MemoFilter {
1128                    include_tags: vec!["work".into()],
1129                    match_all: true,
1130                    ..Default::default()
1131                },
1132            )
1133            .unwrap();
1134        assert_eq!(inc.items.len(), 1);
1135
1136        // exclude removes the memo that also carries the excluded tag.
1137        let exc = v
1138            .list_memos(
1139                None,
1140                10,
1141                MemoFilter {
1142                    include_tags: vec!["work".into()],
1143                    exclude_tags: vec!["urgent".into()],
1144                    ..Default::default()
1145                },
1146            )
1147            .unwrap();
1148        assert!(exc.items.is_empty());
1149
1150        // facets aggregate derived tags over the vault.
1151        let facets = v.list_facets().unwrap();
1152        assert_eq!(
1153            facets
1154                .tags
1155                .iter()
1156                .find(|(t, _)| t == "work")
1157                .map(|(_, c)| *c),
1158            Some(1)
1159        );
1160        assert_eq!(
1161            facets
1162                .tags
1163                .iter()
1164                .find(|(t, _)| t == "urgent")
1165                .map(|(_, c)| *c),
1166            Some(1)
1167        );
1168    }
1169
1170    #[test]
1171    fn category_crud_persists() {
1172        let dir = tempfile::tempdir().unwrap();
1173        let v = Vault::open(Some(dir.path())).unwrap();
1174        v.ensure_initialized().unwrap();
1175
1176        // create
1177        let c = v.create_category("urgent".into(), None).unwrap();
1178        assert_eq!(c.id, "urgent");
1179        assert!(!c.color.is_empty());
1180
1181        // duplicate rejected
1182        assert!(v.create_category("urgent".into(), None).is_err());
1183        // empty rejected
1184        assert!(v.create_category("  ".into(), None).is_err());
1185        // inbox collision rejected
1186        assert!(v.create_category("inbox".into(), None).is_err());
1187
1188        // update color
1189        v.update_category("urgent".into(), "oklch(0.6 0.2 25)".into())
1190            .unwrap();
1191        assert_eq!(
1192            v.categories()
1193                .iter()
1194                .find(|c| c.id == "urgent")
1195                .unwrap()
1196                .color,
1197            "oklch(0.6 0.2 25)"
1198        );
1199
1200        // delete
1201        v.delete_category("urgent".into()).unwrap();
1202        assert!(v.categories().iter().all(|c| c.id != "urgent"));
1203
1204        // inbox not deletable
1205        assert!(v.delete_category("inbox".into()).is_err());
1206        // unknown update/rename target rejected
1207        assert!(v.update_category("nope".into(), "x".into()).is_err());
1208
1209        // persists across reopen
1210        let v2 = Vault::open(Some(dir.path())).unwrap();
1211        assert!(v2.categories().iter().all(|c| c.id != "urgent"));
1212    }
1213
1214    #[test]
1215    fn rename_category_migrates_notes() {
1216        let dir = tempfile::tempdir().unwrap();
1217        let v = Vault::open(Some(dir.path())).unwrap();
1218        v.ensure_initialized().unwrap();
1219
1220        let a = v.create_memo("note A".into(), Some("todo".into())).unwrap();
1221        let b = v.create_memo("note B".into(), Some("todo".into())).unwrap();
1222        let c = v.create_memo("note C".into(), Some("idea".into())).unwrap();
1223
1224        let n = v.rename_category("todo".into(), "tasks".into()).unwrap();
1225        assert_eq!(n, 2);
1226
1227        // migrated
1228        assert_eq!(v.get_memo(a.id).unwrap().category, "tasks");
1229        assert_eq!(v.get_memo(b.id).unwrap().category, "tasks");
1230        // unaffected
1231        assert_eq!(v.get_memo(c.id).unwrap().category, "idea");
1232        // registry updated
1233        assert!(v.categories().iter().any(|c| c.id == "tasks"));
1234        assert!(v.categories().iter().all(|c| c.id != "todo"));
1235
1236        // inbox not renameable
1237        assert!(v.rename_category("inbox".into(), "x".into()).is_err());
1238        // collision
1239        assert!(v.rename_category("tasks".into(), "idea".into()).is_err());
1240    }
1241
1242    #[test]
1243    fn migrate_renames_legacy_notes_root() {
1244        let dir = tempfile::tempdir().unwrap();
1245        let v = Vault::open(Some(dir.path())).unwrap();
1246        v.ensure_initialized().unwrap();
1247        let _ = v.create_memo("hello".into(), None).unwrap();
1248        let live = v.paths.memos_root();
1249        let legacy = v.paths.vault.join("notes");
1250        std::fs::rename(&live, &legacy).unwrap();
1251        assert!(legacy.exists());
1252        assert!(!live.exists());
1253        // Idempotent on a fresh open: no-op.
1254        let v2 = Vault::open(Some(dir.path())).unwrap();
1255        v2.migrate().unwrap();
1256        assert!(live.exists());
1257        assert!(!legacy.exists());
1258    }
1259
1260    #[test]
1261    fn migrate_preserves_existing_memos_root() {
1262        let dir = tempfile::tempdir().unwrap();
1263        let v = Vault::open(Some(dir.path())).unwrap();
1264        v.ensure_initialized().unwrap();
1265        let live = v.paths.memos_root();
1266        let legacy = v.paths.vault.join("notes");
1267        std::fs::create_dir_all(&legacy).unwrap();
1268        std::fs::write(legacy.join("stale.md"), "stale").unwrap();
1269        let new_id = v.create_memo("fresh".into(), None).unwrap();
1270        assert!(live.exists());
1271        assert!(legacy.exists());
1272
1273        v.migrate().unwrap();
1274        assert!(live.exists());
1275        assert!(legacy.exists());
1276        assert!(v.get_memo(new_id.id).is_ok());
1277    }
1278
1279    #[test]
1280    fn reset_clears_memos_and_indexes() {
1281        let (_t, v) = tmp_vault();
1282        v.create_memo("one".into(), None).unwrap();
1283        v.create_memo("two".into(), None).unwrap();
1284        assert_eq!(v.memo_stats().unwrap().memos, 2);
1285        v.reset().unwrap();
1286        // Live memos + derived indexes are gone; the vault stays usable.
1287        assert_eq!(v.memo_stats().unwrap().memos, 0);
1288        v.create_memo("three".into(), None).unwrap();
1289        assert_eq!(v.memo_stats().unwrap().memos, 1);
1290    }
1291
1292    #[test]
1293    fn asset_save_dedup_read_list() {
1294        let (_t, v) = tmp_vault();
1295        let bytes = [1u8, 2, 3, 4, 5];
1296        let r = v.save_asset(&bytes, "PNG").unwrap();
1297        // Canonical scheme: name in the path, host is localhost.
1298        assert_eq!(r.url, format!("oximg://localhost/{}", r.name));
1299        assert!(r.name.ends_with(".png"));
1300        // Dedup: identical bytes → identical name, no second file.
1301        let r2 = v.save_asset(&bytes, "png").unwrap();
1302        assert_eq!(r.name, r2.name);
1303        // read_asset returns the bytes + mime.
1304        let (got, mime) = v.read_asset(&r.name).unwrap();
1305        assert_eq!(got, bytes);
1306        assert_eq!(mime, "image/png");
1307        // list_assets surfaces it.
1308        let list = v.list_assets().unwrap();
1309        assert_eq!(list.len(), 1);
1310        assert_eq!(list[0].name, r.name);
1311    }
1312
1313    #[test]
1314    fn asset_gc_removes_orphans_keeps_referenced() {
1315        let (_t, v) = tmp_vault();
1316        // Referenced: memo body cites this asset.
1317        let referenced = v.save_asset(&[1, 2, 3], "png").unwrap();
1318        v.create_memo(format!("see ![]({})", referenced.url), None)
1319            .unwrap();
1320        // Orphan: saved but never cited.
1321        let orphan = v.save_asset(&[9, 9, 9], "gif").unwrap();
1322        assert_eq!(v.list_assets().unwrap().len(), 2);
1323
1324        let removed = v.gc_assets().unwrap();
1325        assert_eq!(removed, 1);
1326        assert!(v.read_asset(&referenced.name).is_some());
1327        assert!(v.read_asset(&orphan.name).is_none());
1328    }
1329
1330    #[test]
1331    fn read_asset_rejects_traversal() {
1332        let (_t, v) = tmp_vault();
1333        // Malformed names must never reach the filesystem.
1334        assert!(v.read_asset("../../etc/passwd").is_none());
1335        assert!(v.read_asset("deadbeefdeadbeef.exe").is_none());
1336    }
1337}