use std::path::{Path, PathBuf};
use std::time::Duration;
use time::OffsetDateTime;
use parking_lot::RwLock;
use crate::config::{AUTO_COLORS, CategoryDef, VaultConfig};
use crate::error::{CoreError, Result};
use crate::hash;
use crate::lock::{FileLock, LockKind, acquire};
use crate::memo::{Cursor, IndexStats, Memo, MemoFilter, MemoId, MemoSummary, Page, make_preview};
use crate::paths::Paths;
use crate::store::files::FileStore;
use crate::store::index::{IndexRecord, MemoIndex, RedbIndex};
use crate::store::search::{SearchIndex, TantivySearch};
use crate::sync::{FullRecord, ManifestRecord};
use crate::tags::extract_tags;
const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
const INDEX_FORMAT_VERSION: u32 = 3;
pub struct Vault {
paths: Paths,
config: RwLock<VaultConfig>,
files: FileStore,
}
impl Vault {
pub fn open(vault: Option<&Path>) -> Result<Self> {
let paths = Paths::resolve(vault);
let config = VaultConfig::load(&paths);
let files = FileStore::new(paths.clone());
Ok(Self {
paths,
config: RwLock::new(config),
files,
})
}
pub fn with_config<R>(&self, f: impl FnOnce(&VaultConfig) -> R) -> R {
f(&self.config.read())
}
pub fn categories(&self) -> Vec<crate::config::CategoryDef> {
self.config.read().categories.items.clone()
}
pub fn create_category(&self, id: String, color: Option<String>) -> Result<CategoryDef> {
let id = normalize_id(&id);
if id.is_empty() {
return Err(CoreError::other("category id empty"));
}
let mut cfg = self.config.write();
if cfg.categories.items.iter().any(|c| c.id == id) {
return Err(CoreError::other(format!("category '{id}' exists")));
}
let color = color.unwrap_or_else(|| pick_auto_color(&cfg.categories.items));
let def = CategoryDef {
id: id.clone(),
color,
builtin: false,
};
cfg.categories.items.push(def.clone());
cfg.save(&self.paths)?;
Ok(def)
}
pub fn update_category(&self, id: String, color: String) -> Result<()> {
let id = normalize_id(&id);
let mut cfg = self.config.write();
let def = cfg
.categories
.items
.iter_mut()
.find(|c| c.id == id)
.ok_or_else(|| CoreError::other(format!("category '{id}' not found")))?;
def.color = color;
cfg.save(&self.paths)
}
pub fn delete_category(&self, id: String) -> Result<()> {
let id = normalize_id(&id);
if id == crate::memo::DEFAULT_CATEGORY {
return Err(CoreError::other("inbox cannot be deleted"));
}
let mut cfg = self.config.write();
let before = cfg.categories.items.len();
cfg.categories.items.retain(|c| c.id != id);
if cfg.categories.items.len() == before {
return Err(CoreError::other(format!("category '{id}' not found")));
}
cfg.save(&self.paths)
}
pub fn paths(&self) -> &Paths {
&self.paths
}
pub fn ensure_initialized(&self) -> Result<()> {
std::fs::create_dir_all(self.paths.memos_root())?;
std::fs::create_dir_all(self.paths.trash_root())?;
std::fs::create_dir_all(self.paths.assets_root())?;
std::fs::create_dir_all(&self.paths.index_dir)?;
Ok(())
}
pub fn save_asset(&self, bytes: &[u8], ext: &str) -> Result<crate::assets::AssetRef> {
self.ensure_initialized()?;
let ext = crate::assets::normalize_ext(ext)?;
let name = crate::assets::asset_name(bytes, ext);
let path = self.paths.asset_path(&name);
if !path.exists() {
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, &path)?;
}
Ok(crate::assets::AssetRef {
url: format!("oximg://localhost/{name}"),
name,
})
}
pub fn save_asset_from_path(&self, src: &Path) -> Result<crate::assets::AssetRef> {
let ext = src
.extension()
.and_then(|e| e.to_str())
.ok_or_else(|| CoreError::AssetRejected("source has no extension".into()))?;
let bytes = std::fs::read(src)?;
self.save_asset(&bytes, ext)
}
pub fn read_asset(&self, name: &str) -> Option<(Vec<u8>, &'static str)> {
if !crate::assets::valid_name(name) {
return None;
}
let ext = crate::assets::ext_of(name)?;
let path = self.paths.asset_path(name);
std::fs::read(&path)
.ok()
.map(|b| (b, crate::assets::mime_for_ext(ext)))
}
pub fn list_assets(&self) -> Result<Vec<crate::assets::AssetInfo>> {
use crate::assets::{AssetInfo, valid_name};
let root = self.paths.assets_root();
if !root.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in std::fs::read_dir(&root)? {
let entry = entry?;
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
continue;
};
if !valid_name(&name) {
continue;
}
let meta = entry.metadata()?;
let modified = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| {
OffsetDateTime::from_unix_timestamp(d.as_secs() as i64)
.unwrap_or_else(|_| OffsetDateTime::now_utc())
})
.unwrap_or_else(OffsetDateTime::now_utc);
let ext = crate::assets::ext_of(&name).unwrap_or("").to_string();
out.push(AssetInfo {
url: format!("oximg://localhost/{name}"),
name,
ext,
bytes: meta.len(),
modified,
});
}
out.sort_by_key(|b| std::cmp::Reverse(b.modified));
Ok(out)
}
pub fn gc_assets(&self) -> Result<u64> {
let live = self.asset_refs_in_bodies()?;
let root = self.paths.assets_root();
if !root.exists() {
return Ok(0);
}
let mut removed = 0u64;
for entry in std::fs::read_dir(&root)? {
let entry = entry?;
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
continue;
};
if !crate::assets::valid_name(&name) || live.contains(&name) {
continue;
}
if std::fs::remove_file(entry.path()).is_ok() {
removed += 1;
}
}
Ok(removed)
}
fn asset_refs_in_bodies(&self) -> Result<std::collections::HashSet<String>> {
let mut live = std::collections::HashSet::new();
for path in self.files.list_memo_files() {
match self.files.read_memo(&path) {
Ok(Some(parsed)) => {
for name in crate::assets::refs_in_body(&parsed.body) {
live.insert(name);
}
}
Ok(None) => {}
Err(e) => tracing::warn!(
path = %path.display(),
error = %e,
"gc: skipping unparseable memo; its image refs are not counted"
),
}
}
Ok(live)
}
pub fn find_memo_by_asset(&self, name: &str) -> Result<Option<MemoId>> {
for path in self.files.list_memo_files() {
match self.files.read_memo(&path) {
Ok(Some(parsed)) if crate::assets::refs_in_body(&parsed.body).contains(name) => {
return Ok(Some(parsed.id));
}
_ => {}
}
}
Ok(None)
}
fn lock(&self, kind: LockKind) -> Result<FileLock> {
acquire(&self.paths.meta_lock_path(), kind, LOCK_TIMEOUT)
}
fn with_redb<R>(&self, f: impl FnOnce(&RedbIndex) -> Result<R>) -> Result<R> {
let _g = self.lock(LockKind::Shared)?;
let idx = RedbIndex::open(&self.paths.meta_db_path())?;
f(&idx)
}
fn with_redb_and_search<R>(
&self,
f: impl FnOnce(&RedbIndex, &TantivySearch) -> Result<R>,
) -> Result<R> {
let _g = self.lock(LockKind::Exclusive)?;
let idx = RedbIndex::open(&self.paths.meta_db_path())?;
let search = TantivySearch::open(&self.paths.search_dir())?;
f(&idx, &search)
}
pub fn create_memo(&self, body: String, category: Option<String>) -> Result<Memo> {
self.ensure_initialized()?;
let tags = extract_tags(&body);
validate_note_input(&body, &tags)?;
let now = OffsetDateTime::now_utc();
let id = MemoId::now();
let category = category.unwrap_or_else(|| crate::memo::DEFAULT_CATEGORY.to_string());
let note = Memo {
id,
created_at: now,
updated_at: now,
hash: hash::hash_memo(body.as_bytes(), false, &category),
favorite: false,
category,
tags,
body,
deleted_at: None,
};
self.files.write(¬e)?;
self.with_redb_and_search(|idx, search| {
idx.upsert(&record_of(¬e))?;
search.upsert(note.id, ¬e.body, ¬e.tags)
})?;
Ok(note)
}
pub fn get_memo(&self, id: MemoId) -> Result<Memo> {
let created_at = self.with_redb(|idx| idx.get(id))?.map(|r| r.created_at);
if let Some(ca) = created_at {
let live = self.paths.memo_path(id, ca);
if live.exists() {
return self
.files
.read_memo(&live)?
.ok_or_else(|| CoreError::NotFound(id.to_string()));
}
let trash = self.paths.trash_path(id);
if trash.exists() {
return self
.files
.read_memo(&trash)?
.ok_or_else(|| CoreError::NotFound(id.to_string()));
}
}
for path in self
.files
.list_memo_files()
.iter()
.chain(self.files.list_trash_files().iter())
{
if let Ok(Some(n)) = self.files.read_memo(path)
&& n.id == id
{
return Ok(n);
}
}
Err(CoreError::NotFound(id.to_string()))
}
pub fn update_memo(
&self,
id: MemoId,
body: Option<String>,
favorite: Option<bool>,
category: Option<String>,
) -> Result<Memo> {
let mut note = self.get_memo(id)?;
if let Some(b) = body {
note.body = b;
note.tags = extract_tags(¬e.body);
}
if let Some(p) = favorite {
note.favorite = p;
}
if let Some(c) = category {
note.category = c;
}
validate_note_input(¬e.body, ¬e.tags)?;
note.updated_at = OffsetDateTime::now_utc();
note.hash = hash::hash_memo(note.body.as_bytes(), note.favorite, ¬e.category);
self.files.write(¬e)?;
self.with_redb_and_search(|idx, search| {
idx.upsert(&record_of(¬e))?;
search.upsert(note.id, ¬e.body, ¬e.tags)
})?;
Ok(note)
}
pub fn delete_memo(&self, id: MemoId) -> Result<()> {
let mut note = self.get_memo(id)?;
let now = OffsetDateTime::now_utc();
note.deleted_at = Some(now);
note.updated_at = now;
note.hash = hash::hash_memo(note.body.as_bytes(), note.favorite, ¬e.category);
self.files.move_to_trash(¬e)?;
self.files.write(¬e)?;
self.with_redb_and_search(|idx, search| {
idx.upsert(&record_of(¬e))?;
search.remove(note.id)
})?;
Ok(())
}
pub fn restore_memo(&self, id: MemoId) -> Result<Memo> {
let mut note = self.get_memo(id)?;
note.deleted_at = None;
note.updated_at = OffsetDateTime::now_utc();
note.hash = hash::hash_memo(note.body.as_bytes(), note.favorite, ¬e.category);
self.files.restore_from_trash(¬e)?;
self.files.write(¬e)?;
self.with_redb_and_search(|idx, search| {
idx.upsert(&record_of(¬e))?;
search.upsert(note.id, ¬e.body, ¬e.tags)
})?;
Ok(note)
}
pub fn purge(&self, retention: Duration) -> Result<u64> {
let cutoff = OffsetDateTime::now_utc() - retention;
let mut purged = 0u64;
self.with_redb_and_search(|idx, search| {
for path in self.files.list_trash_files() {
let Ok(Some(n)) = self.files.read_memo(&path) else {
continue;
};
if n.deleted_at.is_some_and(|t| t < cutoff) {
self.files.purge(n.id)?;
idx.remove(n.id)?;
search.remove(n.id)?;
purged += 1;
}
}
Ok(())
})?;
Ok(purged)
}
pub fn list_memos(
&self,
after: Option<Cursor>,
limit: u32,
filter: MemoFilter,
) -> Result<Page<MemoSummary>> {
self.with_redb(|idx| {
let recs = idx.list(after, limit, &filter)?;
let items: Vec<MemoSummary> = recs.iter().map(|r| r.to_summary()).collect();
let next_cursor = items.last().and_then(|s| {
serde_json::to_string(&Cursor {
updated_at: s.updated_at,
id: s.id,
})
.ok()
});
Ok(Page { items, next_cursor })
})
}
pub fn search_memos(&self, query: &str, limit: u32) -> Result<Vec<MemoSummary>> {
let _g = self.lock(LockKind::Shared)?;
let search = TantivySearch::open(&self.paths.search_dir())?;
let idx = RedbIndex::open(&self.paths.meta_db_path())?;
let ids = search.search(query, limit)?;
let mut out = Vec::with_capacity(ids.len());
for id in ids {
if let Some(r) = idx.get(id)? {
if r.deleted {
continue;
}
out.push(r.to_summary());
}
}
Ok(out)
}
pub fn get_note_summary(&self, id: MemoId) -> Result<MemoSummary> {
self.with_redb(|idx| match idx.get(id)? {
Some(r) => Ok(r.to_summary()),
None => Err(CoreError::NotFound(id.to_string())),
})
}
pub fn memo_stats(&self) -> Result<crate::memo::MemoStats> {
self.with_redb(|idx| {
let recs = idx.export_since(None)?;
let mut stats = crate::memo::MemoStats::default();
for r in &recs {
if r.deleted {
continue;
}
stats.memos += 1;
if r.favorite {
stats.favorites += 1;
}
}
Ok(stats)
})
}
pub fn list_facets(&self) -> Result<crate::memo::Facets> {
self.with_redb(|idx| {
let recs = idx.export_since(None)?;
let mut tag_map: std::collections::BTreeMap<String, u32> = Default::default();
let mut cat_map: std::collections::BTreeMap<String, u32> = Default::default();
for r in &recs {
if r.deleted {
continue;
}
for t in &r.tags {
*tag_map.entry(t.clone()).or_insert(0) += 1;
}
if !r.category.is_empty() {
*cat_map.entry(r.category.clone()).or_insert(0) += 1;
}
}
Ok(crate::memo::Facets {
tags: tag_map.into_iter().collect(),
categories: cat_map.into_iter().collect(),
})
})
}
pub fn export_manifest(&self, since: Option<OffsetDateTime>) -> Result<Vec<ManifestRecord>> {
self.with_redb(|idx| {
let recs = idx.export_since(since)?;
Ok(recs
.iter()
.map(|r| ManifestRecord {
id: r.id,
hash: r.hash.clone(),
updated_at: r.updated_at,
deleted: r.deleted,
})
.collect())
})
}
pub fn export_full(&self, ids: &[MemoId]) -> Result<Vec<FullRecord>> {
let mut out = Vec::with_capacity(ids.len());
for id in ids {
match self.get_memo(*id) {
Ok(n) => out.push(FullRecord::from_memo(&n)),
Err(CoreError::NotFound(_)) => { }
Err(e) => return Err(e),
}
}
Ok(out)
}
pub fn reindex(&self) -> Result<IndexStats> {
self.ensure_initialized()?;
self.with_redb_and_search(|idx, search| {
let mut stats = IndexStats::default();
let mut search_owned: Vec<(MemoId, String, Vec<String>)> = Vec::new();
for path in self.files.list_memo_files() {
match self.files.read_memo(&path) {
Ok(Some(note)) => {
let rec = record_of(¬e);
match idx.get(note.id)? {
None => {
idx.upsert(&rec)?;
search_owned.push((note.id, note.body, note.tags));
stats.added += 1;
}
Some(prev) if prev.hash == rec.hash && prev.preview == rec.preview => {
stats.unchanged += 1;
}
Some(_) => {
idx.upsert(&rec)?;
search_owned.push((note.id, note.body, note.tags));
stats.updated += 1;
}
}
stats.memos += 1;
}
Ok(None) => {}
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "reindex: parse failed");
stats.failed += 1;
}
}
}
for path in self.files.list_trash_files() {
if let Ok(Some(note)) = self.files.read_memo(&path) {
let rec = record_of(¬e);
idx.upsert(&rec)?;
search_owned.push((note.id, note.body, note.tags));
stats.trashed_memos += 1;
}
}
let batch: Vec<crate::store::search::Upsert<'_>> = search_owned
.iter()
.map(|(id, body, tags)| crate::store::search::Upsert {
id: *id,
body,
tags,
})
.collect();
search.upsert_batch(&batch)?;
Ok(stats)
})
}
pub fn migrate(&self) -> Result<()> {
let old_root = self.paths.vault.join("notes");
let new_root = self.paths.memos_root();
if old_root.exists()
&& old_root != new_root
&& !new_root.exists()
&& std::fs::read_dir(&old_root)?.next().is_some()
{
tracing::info!(from = %old_root.display(), to = %new_root.display(), "renaming vault memos root");
std::fs::rename(&old_root, &new_root)?;
}
self.ensure_initialized()?;
let marker = self.paths.index_fmt_marker_path();
let wants = INDEX_FORMAT_VERSION.to_string();
if std::fs::read_to_string(&marker)
.ok()
.map(|s| s.trim() == wants)
.unwrap_or(false)
{
return Ok(());
}
tracing::info!(
version = INDEX_FORMAT_VERSION,
"migrating index preview format"
);
self.reindex()?;
std::fs::write(&marker, &wants)?;
Ok(())
}
pub fn reindex_path(&self, path: &Path) {
if let Err(e) = self.do_reindex_path(path) {
tracing::warn!(path = %path.display(), error = %e, "watcher reindex failed");
}
}
fn do_reindex_path(&self, path: &Path) -> Result<()> {
if !path.exists() {
if let Some(id) = id_from_path(path) {
self.with_redb_and_search(|idx, search| {
idx.remove(id)?;
search.remove(id)
})?;
}
return Ok(());
}
match self.files.read_memo(path)? {
Some(note) => self.with_redb_and_search(|idx, search| {
idx.upsert(&record_of(¬e))?;
search.upsert(note.id, ¬e.body, ¬e.tags)
}),
None => Ok(()),
}
}
pub fn watch(&self) -> Result<crate::watcher::MemoWatcher> {
let debounce = Duration::from_millis(self.config.read().index.watcher_debounce_ms as u64);
let vault_path = self.paths.vault.clone();
let on_change: crate::watcher::OnChange = std::sync::Arc::new(move |path| {
let Ok(v) = Vault::open(Some(&vault_path)) else {
return;
};
v.reindex_path(&path);
});
crate::watcher::MemoWatcher::spawn(
vec![self.paths.memos_root(), self.paths.trash_root()],
debounce,
on_change,
)
}
pub fn doctor(&self, fix: bool) -> Result<DoctorReport> {
self.ensure_initialized()?;
let mut report = DoctorReport {
index_locked: crate::lock::is_locked(&self.paths.meta_lock_path()),
..DoctorReport::default()
};
let all_recs = self.with_redb(|idx| idx.export_since(None))?;
let indexed: std::collections::HashMap<MemoId, IndexRecord> =
all_recs.iter().map(|r| (r.id, r.clone())).collect();
let mut seen: std::collections::HashSet<MemoId> = std::collections::HashSet::new();
for path in self
.files
.list_memo_files()
.iter()
.chain(self.files.list_trash_files().iter())
{
match self.files.read_memo(path) {
Ok(Some(mut note)) => {
seen.insert(note.id);
let recomputed =
hash::hash_memo(note.body.as_bytes(), note.favorite, ¬e.category);
if recomputed != note.hash {
let repaired = if fix {
note.hash = recomputed;
match self.files.write(¬e) {
Ok(_) => true,
Err(e) => {
report.hash_repair_failed += 1;
tracing::warn!(
id = %note.id,
error = %e,
"doctor: failed to rewrite hash"
);
false
}
}
} else {
false
};
if !repaired {
report.hash_mismatches.push(note.id);
}
}
}
Ok(None) => report.orphan_files.push(path.clone()),
Err(CoreError::Frontmatter { reason, .. }) => {
report.corrupt_frontmatter.push((path.clone(), reason));
}
Err(e) => {
report
.corrupt_frontmatter
.push((path.clone(), e.to_string()));
}
}
}
for id in indexed.keys() {
if !seen.contains(id) {
report.orphan_index_records.push(*id);
}
}
if fix {
let orphans = report.orphan_index_records.clone();
self.with_redb_and_search(|idx, search| {
for id in &orphans {
idx.remove(*id)?;
search.remove(*id)?;
}
Ok(())
})?;
report.orphan_index_records.clear();
}
let cutoff = OffsetDateTime::now_utc()
- Duration::from_secs(86400 * self.config.read().general.trash_retention_days as u64);
for path in self.files.list_trash_files() {
if let Ok(Some(n)) = self.files.read_memo(&path)
&& n.deleted_at.is_some_and(|t| t < cutoff)
{
report.trash_expiring += 1;
}
}
report.vault_ok = self.paths.vault.is_dir();
Ok(report)
}
pub fn rename_category(&self, old: String, new: String) -> Result<u64> {
let old = normalize_id(&old);
let new = normalize_id(&new);
if old == crate::memo::DEFAULT_CATEGORY || new == crate::memo::DEFAULT_CATEGORY {
return Err(CoreError::other("inbox id is immutable"));
}
if old == new {
return Err(CoreError::other("old == new"));
}
{
let cfg = self.config.read();
if !cfg.categories.items.iter().any(|c| c.id == old) {
return Err(CoreError::other(format!("category '{old}' not found")));
}
if cfg.categories.items.iter().any(|c| c.id == new) {
return Err(CoreError::other(format!("category '{new}' exists")));
}
}
let mut migrated = 0u64;
self.with_redb_and_search(|idx, search| {
for rec in idx.export_since(None)? {
if rec.category != old {
continue;
}
let path = self.paths.memo_path(rec.id, rec.created_at);
let mut note = self
.files
.read_memo(&path)?
.ok_or_else(|| CoreError::NotFound(rec.id.to_string()))?;
note.category = new.clone();
note.updated_at = OffsetDateTime::now_utc();
note.hash = hash::hash_memo(note.body.as_bytes(), note.favorite, ¬e.category);
self.files.write(¬e)?;
idx.upsert(&record_of(¬e))?;
search.upsert(note.id, ¬e.body, ¬e.tags)?;
migrated += 1;
}
Ok(())
})?;
{
let mut cfg = self.config.write();
for def in cfg.categories.items.iter_mut() {
if def.id == old {
def.id = new.clone();
}
}
cfg.save(&self.paths)?;
}
Ok(migrated)
}
pub fn reset(&self) -> Result<()> {
self.ensure_initialized()?;
let roots = [self.paths.memos_root(), self.paths.trash_root()];
self.with_redb_and_search(|idx, search| {
for root in roots {
if root.exists() {
for entry in std::fs::read_dir(&root)? {
let path = entry?.path();
if path.is_dir() {
std::fs::remove_dir_all(&path)?;
} else {
std::fs::remove_file(&path)?;
}
}
}
}
idx.clear()?;
search.clear()?;
Ok(())
})
}
}
fn record_of(n: &Memo) -> IndexRecord {
IndexRecord {
id: n.id,
created_at: n.created_at,
updated_at: n.updated_at,
hash: n.hash.clone(),
favorite: n.favorite,
category: n.category.clone(),
tags: n.tags.clone(),
deleted: n.deleted_at.is_some(),
deleted_at: n.deleted_at,
preview: make_preview(&n.body),
}
}
fn id_from_path(path: &Path) -> Option<MemoId> {
let stem = path.file_stem()?.to_str()?;
MemoId::parse(stem).ok()
}
const MAX_BODY_BYTES: usize = 64 * 1024;
const MAX_TAGS: usize = 64;
const MAX_TAG_LEN: usize = 64;
fn validate_note_input(body: &str, tags: &[String]) -> Result<()> {
if body.len() > MAX_BODY_BYTES {
return Err(CoreError::other(format!(
"memo body too large: {} bytes (max {})",
body.len(),
MAX_BODY_BYTES
)));
}
if tags.len() > MAX_TAGS {
return Err(CoreError::other(format!(
"too many tags: {} (max {})",
tags.len(),
MAX_TAGS
)));
}
for t in tags {
if t.chars().count() > MAX_TAG_LEN {
return Err(CoreError::other(format!(
"tag too long: {} chars (max {})",
t.chars().count(),
MAX_TAG_LEN
)));
}
}
Ok(())
}
#[derive(Debug, Default, serde::Serialize)]
pub struct DoctorReport {
pub corrupt_frontmatter: Vec<(PathBuf, String)>,
pub orphan_index_records: Vec<MemoId>,
pub orphan_files: Vec<PathBuf>,
pub hash_mismatches: Vec<MemoId>,
pub hash_repair_failed: u64,
pub index_locked: bool,
pub trash_expiring: u64,
pub vault_ok: bool,
}
fn normalize_id(id: &str) -> String {
id.trim().to_lowercase()
}
fn pick_auto_color(items: &[CategoryDef]) -> String {
AUTO_COLORS
.iter()
.skip(1) .find(|c| !items.iter().any(|item| &item.color == *c))
.map(|s| (*s).to_string())
.unwrap_or_else(|| AUTO_COLORS[1].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn tmp_vault() -> (TempDir, Vault) {
let dir = TempDir::new().unwrap();
let v = Vault::open(Some(dir.path())).unwrap();
(dir, v)
}
#[test]
fn create_get_update_delete_restore() {
let (_t, v) = tmp_vault();
let n = v.create_memo("hello world".into(), None).unwrap();
let got = v.get_memo(n.id).unwrap();
assert_eq!(got.body, "hello world");
let updated = v
.update_memo(n.id, Some("edited".into()), Some(true), None)
.unwrap();
assert!(updated.favorite);
assert_ne!(updated.hash, n.hash);
v.delete_memo(n.id).unwrap();
let trashed = v.get_memo(n.id).unwrap();
assert!(trashed.deleted_at.is_some());
v.restore_memo(n.id).unwrap();
assert!(v.get_memo(n.id).unwrap().deleted_at.is_none());
}
#[test]
fn list_and_search() {
let (_t, v) = tmp_vault();
v.create_memo("rust async runtime".into(), None).unwrap();
v.create_memo("go goroutines".into(), None).unwrap();
let page = v.list_memos(None, 10, MemoFilter::default()).unwrap();
assert_eq!(page.items.len(), 2);
let hits = v.search_memos("rust", 10).unwrap();
assert_eq!(hits.len(), 1);
}
#[test]
fn list_notes_next_cursor_roundtrips_as_string() {
let (_t, v) = tmp_vault();
v.create_memo("first note".into(), None).unwrap();
v.create_memo("second note".into(), None).unwrap();
let page = v.list_memos(None, 1, MemoFilter::default()).unwrap();
assert_eq!(page.items.len(), 1);
let cursor = page.next_cursor.expect("page 1 must carry a next cursor");
assert!(
cursor.starts_with('{'),
"next_cursor must be a JSON object string, got: {cursor}"
);
let parsed = Cursor::parse(&cursor).expect("cursor must round-trip via Cursor::parse");
let page2 = v
.list_memos(Some(parsed), 10, MemoFilter::default())
.unwrap();
assert_eq!(
page2.items.len(),
1,
"page 2 must return the remaining note"
);
let c2 = page2.next_cursor.expect("page 2 carries a cursor");
let page3 = v
.list_memos(Some(Cursor::parse(&c2).unwrap()), 10, MemoFilter::default())
.unwrap();
assert!(page3.items.is_empty(), "page 3 must be empty");
assert!(
page3.next_cursor.is_none(),
"empty page must carry no cursor"
);
}
#[test]
fn export_manifest_and_full_roundtrip() {
let (_t, v) = tmp_vault();
let n = v.create_memo("body text".into(), None).unwrap();
let manifest = v.export_manifest(None).unwrap();
assert_eq!(manifest.len(), 1);
let full = v.export_full(&[n.id]).unwrap();
assert_eq!(full[0].body, "body text");
}
#[test]
fn reindex_is_idempotent() {
let (_t, v) = tmp_vault();
v.create_memo("one".into(), None).unwrap();
let s1 = v.reindex().unwrap();
let s2 = v.reindex().unwrap();
assert_eq!(s2.added, 0);
assert!(s2.unchanged >= 1);
let _ = s1;
}
#[test]
fn derived_tags_from_body_end_to_end() {
let (_t, v) = tmp_vault();
let n = v.create_memo("회의록 #work #urgent".into(), None).unwrap();
let got = v.get_memo(n.id).unwrap();
assert_eq!(got.tags, vec!["work", "urgent"]);
let inc = v
.list_memos(
None,
10,
MemoFilter {
include_tags: vec!["work".into()],
match_all: true,
..Default::default()
},
)
.unwrap();
assert_eq!(inc.items.len(), 1);
let exc = v
.list_memos(
None,
10,
MemoFilter {
include_tags: vec!["work".into()],
exclude_tags: vec!["urgent".into()],
..Default::default()
},
)
.unwrap();
assert!(exc.items.is_empty());
let facets = v.list_facets().unwrap();
assert_eq!(
facets
.tags
.iter()
.find(|(t, _)| t == "work")
.map(|(_, c)| *c),
Some(1)
);
assert_eq!(
facets
.tags
.iter()
.find(|(t, _)| t == "urgent")
.map(|(_, c)| *c),
Some(1)
);
}
#[test]
fn category_crud_persists() {
let dir = tempfile::tempdir().unwrap();
let v = Vault::open(Some(dir.path())).unwrap();
v.ensure_initialized().unwrap();
let c = v.create_category("urgent".into(), None).unwrap();
assert_eq!(c.id, "urgent");
assert!(!c.color.is_empty());
assert!(v.create_category("urgent".into(), None).is_err());
assert!(v.create_category(" ".into(), None).is_err());
assert!(v.create_category("inbox".into(), None).is_err());
v.update_category("urgent".into(), "oklch(0.6 0.2 25)".into())
.unwrap();
assert_eq!(
v.categories()
.iter()
.find(|c| c.id == "urgent")
.unwrap()
.color,
"oklch(0.6 0.2 25)"
);
v.delete_category("urgent".into()).unwrap();
assert!(v.categories().iter().all(|c| c.id != "urgent"));
assert!(v.delete_category("inbox".into()).is_err());
assert!(v.update_category("nope".into(), "x".into()).is_err());
let v2 = Vault::open(Some(dir.path())).unwrap();
assert!(v2.categories().iter().all(|c| c.id != "urgent"));
}
#[test]
fn rename_category_migrates_notes() {
let dir = tempfile::tempdir().unwrap();
let v = Vault::open(Some(dir.path())).unwrap();
v.ensure_initialized().unwrap();
let a = v.create_memo("note A".into(), Some("todo".into())).unwrap();
let b = v.create_memo("note B".into(), Some("todo".into())).unwrap();
let c = v.create_memo("note C".into(), Some("idea".into())).unwrap();
let n = v.rename_category("todo".into(), "tasks".into()).unwrap();
assert_eq!(n, 2);
assert_eq!(v.get_memo(a.id).unwrap().category, "tasks");
assert_eq!(v.get_memo(b.id).unwrap().category, "tasks");
assert_eq!(v.get_memo(c.id).unwrap().category, "idea");
assert!(v.categories().iter().any(|c| c.id == "tasks"));
assert!(v.categories().iter().all(|c| c.id != "todo"));
assert!(v.rename_category("inbox".into(), "x".into()).is_err());
assert!(v.rename_category("tasks".into(), "idea".into()).is_err());
}
#[test]
fn migrate_renames_legacy_notes_root() {
let dir = tempfile::tempdir().unwrap();
let v = Vault::open(Some(dir.path())).unwrap();
v.ensure_initialized().unwrap();
let _ = v.create_memo("hello".into(), None).unwrap();
let live = v.paths.memos_root();
let legacy = v.paths.vault.join("notes");
std::fs::rename(&live, &legacy).unwrap();
assert!(legacy.exists());
assert!(!live.exists());
let v2 = Vault::open(Some(dir.path())).unwrap();
v2.migrate().unwrap();
assert!(live.exists());
assert!(!legacy.exists());
}
#[test]
fn migrate_preserves_existing_memos_root() {
let dir = tempfile::tempdir().unwrap();
let v = Vault::open(Some(dir.path())).unwrap();
v.ensure_initialized().unwrap();
let live = v.paths.memos_root();
let legacy = v.paths.vault.join("notes");
std::fs::create_dir_all(&legacy).unwrap();
std::fs::write(legacy.join("stale.md"), "stale").unwrap();
let new_id = v.create_memo("fresh".into(), None).unwrap();
assert!(live.exists());
assert!(legacy.exists());
v.migrate().unwrap();
assert!(live.exists());
assert!(legacy.exists());
assert!(v.get_memo(new_id.id).is_ok());
}
#[test]
fn reset_clears_memos_and_indexes() {
let (_t, v) = tmp_vault();
v.create_memo("one".into(), None).unwrap();
v.create_memo("two".into(), None).unwrap();
assert_eq!(v.memo_stats().unwrap().memos, 2);
v.reset().unwrap();
assert_eq!(v.memo_stats().unwrap().memos, 0);
v.create_memo("three".into(), None).unwrap();
assert_eq!(v.memo_stats().unwrap().memos, 1);
}
#[test]
fn asset_save_dedup_read_list() {
let (_t, v) = tmp_vault();
let bytes = [1u8, 2, 3, 4, 5];
let r = v.save_asset(&bytes, "PNG").unwrap();
assert_eq!(r.url, format!("oximg://localhost/{}", r.name));
assert!(r.name.ends_with(".png"));
let r2 = v.save_asset(&bytes, "png").unwrap();
assert_eq!(r.name, r2.name);
let (got, mime) = v.read_asset(&r.name).unwrap();
assert_eq!(got, bytes);
assert_eq!(mime, "image/png");
let list = v.list_assets().unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].name, r.name);
}
#[test]
fn asset_gc_removes_orphans_keeps_referenced() {
let (_t, v) = tmp_vault();
let referenced = v.save_asset(&[1, 2, 3], "png").unwrap();
v.create_memo(format!("see ", referenced.url), None)
.unwrap();
let orphan = v.save_asset(&[9, 9, 9], "gif").unwrap();
assert_eq!(v.list_assets().unwrap().len(), 2);
let removed = v.gc_assets().unwrap();
assert_eq!(removed, 1);
assert!(v.read_asset(&referenced.name).is_some());
assert!(v.read_asset(&orphan.name).is_none());
}
#[test]
fn read_asset_rejects_traversal() {
let (_t, v) = tmp_vault();
assert!(v.read_asset("../../etc/passwd").is_none());
assert!(v.read_asset("deadbeefdeadbeef.exe").is_none());
}
}