pub mod error;
pub(crate) mod index;
pub(crate) mod link_rewrite;
pub mod nfs;
pub mod note;
pub(crate) mod sync;
pub mod utilities;
pub use index::search_terms::{
expand_bare_note_prefixes, query_has_unterminated_quote, query_token_spans, quote_query_term,
strip_order_directive, with_order_directive, OrderBy, OrderField, QueryTokenClass,
QueryTokenSpan, SearchTerms,
};
pub use index::{IndexDiff, NoteSuggestion, TagSuggestion};
pub use nfs::saved_searches::{saved_search_name_matches, SavedSearch};
pub use utilities::{app_log_dir, ensure_dir_exists};
use std::{
collections::HashMap,
fmt::Display,
path::{Path, PathBuf},
sync::{
mpsc::{Receiver, Sender},
Arc,
},
time::{Duration, SystemTime},
};
use chrono::{NaiveDate, Utc};
use error::{FSError, VaultError};
use index::NoteIndex;
use link_rewrite::LinkRewrite;
use log::debug;
use nfs::{NoteEntryData, VaultPath};
use note::{ContentChunk, NoteContentData, NoteDetails};
use sync::VaultSync;
use utilities::path_to_string;
use crate::nfs::saved_searches;
use crate::nfs::DirectoryEntryData;
pub const DEFAULT_JOURNAL_PATH: &str = "/journal";
pub const DEFAULT_INBOX_PATH: &str = "/inbox";
pub const DEFAULT_ASSETS_PATH: &str = "/assets";
pub struct IndexReport {
pub start: SystemTime,
pub duration: Duration,
}
impl IndexReport {
fn new() -> Self {
let start = SystemTime::now();
Self {
start,
duration: Duration::default(),
}
}
fn finish(&mut self) {
let time = SystemTime::now();
let duration = time.duration_since(self.start).unwrap_or_default();
self.duration = duration;
}
}
#[derive(Debug, Clone)]
pub struct VaultConfig {
pub workspace_path: std::path::PathBuf,
pub db_path: Option<std::path::PathBuf>,
pub backup: bool,
}
impl VaultConfig {
pub fn new(workspace_path: impl Into<std::path::PathBuf>) -> Self {
Self {
workspace_path: workspace_path.into(),
db_path: None,
backup: false,
}
}
pub fn with_db_path(mut self, db_path: impl Into<std::path::PathBuf>) -> Self {
self.db_path = Some(db_path.into());
self
}
pub fn with_backup(mut self, backup: bool) -> Self {
self.backup = backup;
self
}
}
#[derive(Debug, Clone)]
pub struct ReplacePreview {
pub count: usize,
pub content: String,
}
#[derive(Debug, Clone)]
pub struct NoteVault {
workspace_path: Arc<Path>,
journal_path: VaultPath,
inbox_path: VaultPath,
pub(crate) index: NoteIndex,
backup: bool,
note_locks: Arc<std::sync::Mutex<HashMap<VaultPath, Arc<tokio::sync::Mutex<()>>>>>,
}
impl PartialEq for NoteVault {
fn eq(&self, other: &Self) -> bool {
self.workspace_path == other.workspace_path
}
}
impl NoteVault {
pub async fn new(config: VaultConfig) -> Result<Self, VaultError> {
debug!("Creating new vault Instance");
let backup = config.backup;
let workspace_path = config.workspace_path;
if !workspace_path.exists() {
return Err(VaultError::VaultPathNotFound {
path: path_to_string(&workspace_path),
})?;
}
if !workspace_path.is_dir() {
return Err(VaultError::FSError(FSError::InvalidPath {
path: path_to_string(&workspace_path),
message: "Path provided is not a directory".to_string(),
}))?;
};
let db_path = config
.db_path
.unwrap_or_else(|| workspace_path.join(crate::index::DB_FILE));
let index = NoteIndex::open(&db_path).await?;
let note_vault = Self {
workspace_path: Arc::from(workspace_path.as_path()),
journal_path: VaultPath::new(DEFAULT_JOURNAL_PATH),
inbox_path: VaultPath::new(DEFAULT_INBOX_PATH),
index,
backup,
note_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
};
Ok(note_vault)
}
pub fn workspace_path(&self) -> &Path {
&self.workspace_path
}
pub fn index_ready(&self) -> bool {
self.index.ready()
}
async fn fail_on_case_conflicts(&self) -> Result<(), VaultError> {
let workspace = self.workspace_path.clone();
let conflicts = tokio::task::spawn_blocking(move || nfs::check_case_conflicts(&workspace))
.await
.map_err(|e| VaultError::TaskJoin(format!("case-conflict scan: {}", e)))?;
if !conflicts.is_empty() {
return Err(VaultError::CaseConflict { conflicts });
}
Ok(())
}
pub async fn validate_and_init(&self) -> Result<IndexReport, VaultError> {
self.fail_on_case_conflicts().await?;
if self.index.ready() {
self.index_notes(NotesValidation::None).await
} else {
debug!("Index was healed on open — running a full sync");
self.index_notes(NotesValidation::Full).await
}
}
pub async fn recreate_index(&self) -> Result<IndexReport, VaultError> {
self.fail_on_case_conflicts().await?;
let index_report = IndexReport::new();
debug!("Recreating index from Vault request");
self.index.recreate().await?;
debug!("Tables created, creating index");
self.int_index_notes(index_report, NotesValidation::Full)
.await
}
pub async fn index_notes(
&self,
validation_mode: NotesValidation,
) -> Result<IndexReport, VaultError> {
let index_report = IndexReport::new();
self.int_index_notes(index_report, validation_mode).await
}
async fn int_index_notes(
&self,
mut index_report: IndexReport,
validation_mode: NotesValidation,
) -> Result<IndexReport, VaultError> {
VaultSync::new(&self.index, self.workspace_path())
.run(&VaultPath::root(), true, validation_mode, None)
.await?;
self.index.mark_synced();
index_report.finish();
debug!("TIME: {}", index_report.duration.as_secs());
Ok(index_report)
}
pub async fn exists(&self, path: &VaultPath) -> bool {
nfs::path_exists(self.workspace_path(), path)
.await
.unwrap_or(false)
}
pub fn journal_path(&self) -> &VaultPath {
&self.journal_path
}
pub fn inbox_path(&self) -> &VaultPath {
&self.inbox_path
}
pub fn set_inbox_path(&mut self, path: VaultPath) {
self.inbox_path = path;
}
pub async fn quick_note(&self, text: &str) -> Result<NoteDetails, VaultError> {
let base_name = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();
let candidate = |name: &str| {
self.inbox_path
.append(&VaultPath::note_path_from(name))
.absolute()
};
for attempt in 0..=99 {
let path = if attempt == 0 {
candidate(&base_name)
} else if attempt == 1 {
continue; } else {
candidate(&format!("{}-{}", base_name, attempt))
};
match self.create_note(&path, text).await {
Ok(_) => return Ok(NoteDetails::new(&path, text)),
Err(VaultError::NoteExists { .. }) => continue,
Err(e) => return Err(e),
}
}
let placeholder = candidate(&base_name);
Err(VaultError::FSError(FSError::InvalidPath {
path: placeholder.to_string(),
message: "Could not find a free quick note name".to_string(),
}))
}
pub async fn journal_entry(&self) -> Result<(NoteDetails, String, bool), VaultError> {
let (title, note_path) = self.get_todays_journal();
let (content, created) = self
.load_or_create_note(¬e_path, Some(format!("# {}\n\n", title)))
.await?;
let details = NoteDetails::new(¬e_path, &content);
Ok((details, content, created))
}
fn get_todays_journal(&self) -> (String, VaultPath) {
let today = Utc::now();
let today_string = today.format("%Y-%m-%d").to_string();
(
today_string.clone(),
self.journal_path
.append(&VaultPath::note_path_from(&today_string))
.absolute(),
)
}
pub fn journal_date(&self, note_path: &VaultPath) -> Option<NaiveDate> {
if !note_path.is_note() {
return None;
}
let (parent, _) = note_path.get_parent_path();
if parent.eq(&self.journal_path) {
let name = note_path.get_clean_name();
NaiveDate::parse_from_str(&name, "%Y-%m-%d").ok()
} else {
None
}
}
pub async fn load_or_create_note(
&self,
path: &VaultPath,
default_text: Option<String>,
) -> Result<(String, bool), VaultError> {
match nfs::load_note(self.workspace_path(), path).await {
Ok(text) => Ok((text, false)),
Err(e) if e.is_not_found() => {
let text = default_text.unwrap_or_default();
self.create_note(path, &text).await?;
Ok((text, true))
}
Err(e) => Err(e.into()),
}
}
pub async fn get_note_text(&self, path: &VaultPath) -> Result<String, VaultError> {
let text = nfs::load_note(self.workspace_path(), path).await?;
Ok(text)
}
pub async fn load_note(&self, path: &VaultPath) -> Result<NoteDetails, VaultError> {
let text = self.get_note_text(path).await?;
Ok(NoteDetails::new(path, text))
}
pub async fn get_note_chunks(
&self,
path: &VaultPath,
) -> Result<HashMap<VaultPath, Vec<ContentChunk>>, VaultError> {
let a = self.index.get_notes_sections(path, false).await?;
Ok(a)
}
pub async fn search_notes<S: AsRef<str>>(
&self,
search_query: S,
) -> Result<Vec<(NoteEntryData, NoteContentData)>, VaultError> {
let search_query = search_query.as_ref();
let a = self.index.search(search_query).await?;
Ok(a)
}
pub async fn list_labels(&self) -> Result<Vec<String>, VaultError> {
Ok(self.index.list_labels().await?)
}
pub async fn suggest_notes_by_prefix(
&self,
prefix: &str,
limit: usize,
) -> Result<Vec<NoteSuggestion>, VaultError> {
Ok(self.index.suggest_notes_by_prefix(prefix, limit).await?)
}
pub async fn suggest_tags_by_prefix(
&self,
prefix: &str,
limit: usize,
) -> Result<Vec<TagSuggestion>, VaultError> {
Ok(self.index.suggest_tags_by_prefix(prefix, limit).await?)
}
pub async fn label_counts(&self) -> Result<Vec<(String, usize)>, VaultError> {
let rows = self.index.label_counts().await?;
Ok(rows.into_iter().map(|(n, c)| (n, c as usize)).collect())
}
pub async fn notes_with_label<S: AsRef<str>>(
&self,
name: S,
) -> Result<Vec<VaultPath>, VaultError> {
Ok(self.index.notes_with_label(name.as_ref()).await?)
}
pub async fn get_notes(
&self,
path: &VaultPath,
recursive: bool,
) -> Result<Vec<(NoteEntryData, NoteContentData)>, VaultError> {
let notes = self.index.get_notes(path, recursive).await?;
Ok(notes)
}
pub async fn get_all_notes(&self) -> Result<Vec<(NoteEntryData, NoteContentData)>, VaultError> {
let a = self.index.get_all_notes().await?;
Ok(a)
}
pub fn path_to_pathbuf(&self, path: &VaultPath) -> PathBuf {
path.to_pathbuf(self.workspace_path())
}
pub async fn browse_vault(&self, options: VaultBrowseOptions) -> Result<(), VaultError> {
let start = std::time::SystemTime::now();
debug!("> Start fetching files with Options:\n{}", options);
VaultSync::new(&self.index, self.workspace_path())
.run(
&options.path,
options.recursive,
options.validation,
Some(options.sender.clone()),
)
.await?;
if options.recursive && options.path.is_root_or_empty() {
self.index.mark_synced();
}
let time = std::time::SystemTime::now()
.duration_since(start)
.expect("Something's wrong with the time");
debug!("> Files fetched in {} milliseconds", time.as_millis());
Ok(())
}
pub fn get_directories(
&self,
path: &VaultPath,
recursive: bool,
) -> Result<Vec<DirectoryDetails>, VaultError> {
Ok(nfs::list_directories(
self.workspace_path(),
path,
recursive,
)?)
}
pub async fn get_markdown_and_links(
&self,
path: &VaultPath,
) -> Result<note::MarkdownNote, VaultError> {
let note = self.load_note(path).await?;
let note_parent = if note.path.is_note() {
note.path.get_parent_path().0
} else {
note.path.clone()
};
let (md_text, mut links) = note.get_markdown_and_links();
let (md_text, image_links) = note::process_image_links(&md_text, |alt_text, raw_path| {
let resolved = if note::scan::is_remote_url(raw_path) {
raw_path.to_string()
} else {
let image_vault_path = if raw_path.starts_with('/') {
VaultPath::new(raw_path)
} else {
note_parent.append(&VaultPath::new(raw_path)).flatten()
};
image_vault_path
.to_pathbuf(self.workspace_path())
.display()
.to_string()
};
let link = note::NoteLink::image(&resolved, alt_text, raw_path);
(resolved, link)
});
links.extend(image_links);
Ok(note::MarkdownNote {
text: md_text,
links,
})
}
pub async fn get_backlinks(
&self,
path: &VaultPath,
) -> Result<Vec<(NoteEntryData, NoteContentData)>, VaultError> {
Ok(self.index.get_backlinks(path).await?)
}
pub async fn list_saved_searches(&self) -> Result<Vec<SavedSearch>, VaultError> {
Ok(saved_searches::read_saved_searches(self.workspace_path()).await?)
}
pub async fn suggest_saved_searches_by_prefix(
&self,
prefix: &str,
limit: usize,
) -> Result<Vec<SavedSearch>, VaultError> {
let prefix = prefix.to_ascii_lowercase();
let mut matches = saved_searches::read_saved_searches(self.workspace_path()).await?;
matches.retain(|s| s.name.to_ascii_lowercase().starts_with(&prefix));
matches.truncate(limit);
Ok(matches)
}
pub async fn save_search(&self, name: &str, query: &str) -> Result<(), VaultError> {
let mut all = saved_searches::read_saved_searches(self.workspace_path()).await?;
let entry = SavedSearch {
name: name.to_string(),
query: query.to_string(),
};
match all
.iter_mut()
.find(|s| saved_search_name_matches(&s.name, name))
{
Some(existing) => *existing = entry,
None => all.push(entry),
}
saved_searches::write_saved_searches(self.workspace_path(), &all).await?;
Ok(())
}
pub async fn delete_saved_search(&self, name: &str) -> Result<(), VaultError> {
let mut all = saved_searches::read_saved_searches(self.workspace_path()).await?;
all.retain(|s| !saved_search_name_matches(&s.name, name));
saved_searches::write_saved_searches(self.workspace_path(), &all).await?;
Ok(())
}
pub async fn rename_saved_search(&self, old: &str, new: &str) -> Result<(), VaultError> {
let mut all = saved_searches::read_saved_searches(self.workspace_path()).await?;
if let Some(existing) = all
.iter_mut()
.find(|s| saved_search_name_matches(&s.name, old))
{
existing.name = new.to_string();
}
saved_searches::write_saved_searches(self.workspace_path(), &all).await?;
Ok(())
}
pub async fn create_note<S: AsRef<str>>(
&self,
path: &VaultPath,
text: S,
) -> Result<(NoteEntryData, NoteContentData), VaultError> {
let entry_data = nfs::create_note_exclusive(self.workspace_path(), path, &text)
.await
.map_err(|e| match e {
FSError::AlreadyExists { path } => VaultError::NoteExists { path },
other => VaultError::FSError(other),
})?;
let note_details = NoteDetails::new(path, text);
let content_data = self.index.save_note(&entry_data, ¬e_details).await?;
Ok((entry_data, content_data))
}
pub async fn create_directory(
&self,
path: &VaultPath,
) -> Result<DirectoryEntryData, VaultError> {
nfs::create_directory(self.workspace_path(), path)
.await
.map_err(|e| match e {
FSError::AlreadyExists { path } => VaultError::DirectoryExists { path },
other => VaultError::FSError(other),
})
}
async fn backup_if_enabled(&self, path: &VaultPath) -> Result<(), VaultError> {
if self.backup {
nfs::backup_note(self.workspace_path(), path).await?;
}
Ok(())
}
async fn lock_note(&self, path: &VaultPath) -> tokio::sync::OwnedMutexGuard<()> {
let key = path.flatten();
let lock = {
let mut map = self.note_locks.lock().unwrap();
map.entry(key)
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
};
lock.lock_owned().await
}
async fn lock_notes<'a>(
&self,
paths: impl IntoIterator<Item = &'a VaultPath>,
) -> Vec<tokio::sync::OwnedMutexGuard<()>> {
let mut keys: Vec<VaultPath> = paths.into_iter().map(|p| p.flatten()).collect();
keys.sort();
keys.dedup();
let mut guards = Vec::with_capacity(keys.len());
for key in &keys {
guards.push(self.lock_note(key).await);
}
guards
}
pub async fn append_to_note(
&self,
path: &VaultPath,
text: &str,
default: Option<String>,
) -> Result<(), VaultError> {
let _guard = self.lock_note(path).await;
let (existing, _created) = self.load_or_create_note(path, default).await?;
let combined = if existing.is_empty() {
text.to_string()
} else {
format!("{existing}\n{text}")
};
self.save_note_unlocked(path, combined).await?;
Ok(())
}
pub async fn save_note<S: AsRef<str>>(
&self,
path: &VaultPath,
text: S,
) -> Result<(NoteEntryData, NoteContentData), VaultError> {
let _guard = self.lock_note(path).await;
self.save_note_unlocked(path, text).await
}
async fn save_note_unlocked<S: AsRef<str>>(
&self,
path: &VaultPath,
text: S,
) -> Result<(NoteEntryData, NoteContentData), VaultError> {
self.backup_if_enabled(path).await?;
let entry_data = nfs::save_note(self.workspace_path(), path, &text).await?;
let note_details = NoteDetails::new(path, text);
let content_data = self.index.save_note(&entry_data, ¬e_details).await?;
Ok((entry_data, content_data))
}
pub fn default_attachments_path(&self) -> VaultPath {
VaultPath::new(DEFAULT_ASSETS_PATH)
}
pub fn generate_attachment_path(&self, prefix: &str, ext: &str) -> VaultPath {
let ts = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let filename = format!("{prefix}_{ts}.{ext}");
self.default_attachments_path()
.append(&VaultPath::new(filename))
}
pub async fn save_attachment(&self, path: &VaultPath, bytes: &[u8]) -> Result<(), VaultError> {
nfs::save_attachment(self.workspace_path(), path, bytes).await?;
Ok(())
}
pub async fn open_or_search(
&self,
path: &VaultPath,
) -> Result<Vec<(NoteEntryData, NoteContentData)>, VaultError> {
debug!("PATH: {}", path);
let (_parent, name) = path.get_parent_path();
if path.is_note_file() {
Ok(self.index.search_note_by_name(name).await?)
} else {
Ok(self.index.search_note_by_path(path).await?)
}
}
pub async fn delete_note(&self, path: &VaultPath) -> Result<(), VaultError> {
let path = path.flatten();
path.ensure_note()?;
let _guard = self.lock_note(&path).await;
self.backup_if_enabled(&path).await?;
self.index.delete_notes(std::slice::from_ref(&path)).await?;
nfs::delete_note(self.workspace_path(), &path).await?;
Ok(())
}
fn compute_replacement(
text: &str,
pattern: &str,
replacement: &str,
all: bool,
regex: bool,
path: &VaultPath,
) -> Result<(usize, String), VaultError> {
let count;
let updated;
if regex {
let re = regex::Regex::new(pattern).map_err(|e| VaultError::InvalidRegex {
pattern: pattern.to_string(),
message: e.to_string(),
})?;
count = re.find_iter(text).count();
if count == 0 {
return Err(VaultError::ReplaceTextNotFound {
path: path.flatten(),
});
}
if !all && count > 1 {
return Err(VaultError::ReplaceTextNotUnique {
path: path.flatten(),
});
}
let limit = if all { 0 } else { 1 };
updated = re.replacen(text, limit, replacement).into_owned();
} else {
count = if pattern.is_empty() {
0
} else {
text.matches(pattern).count()
};
if count == 0 {
return Err(VaultError::ReplaceTextNotFound {
path: path.flatten(),
});
}
if !all && count > 1 {
return Err(VaultError::ReplaceTextNotUnique {
path: path.flatten(),
});
}
updated = if all {
text.replace(pattern, replacement)
} else {
text.replacen(pattern, replacement, 1)
};
}
Ok((count, updated))
}
pub async fn replace_in_note(
&self,
path: &VaultPath,
pattern: &str,
replacement: &str,
all: bool,
regex: bool,
) -> Result<usize, VaultError> {
let _guard = self.lock_note(path).await;
let text = self.get_note_text(path).await?;
let (count, updated) =
Self::compute_replacement(&text, pattern, replacement, all, regex, path)?;
self.save_note_unlocked(path, updated).await?;
Ok(count)
}
pub async fn preview_replace(
&self,
path: &VaultPath,
pattern: &str,
replacement: &str,
all: bool,
regex: bool,
) -> Result<ReplacePreview, VaultError> {
let text = self.get_note_text(path).await?;
let (count, content) =
Self::compute_replacement(&text, pattern, replacement, all, regex, path)?;
Ok(ReplacePreview { count, content })
}
pub async fn delete_directory(&self, path: &VaultPath) -> Result<(), VaultError> {
let path = path.flatten();
path.ensure_directory()?;
self.index
.delete_directories(std::slice::from_ref(&path))
.await?;
nfs::delete_directory(self.workspace_path(), &path).await?;
Ok(())
}
pub async fn rename_note(&self, from: &VaultPath, to: &VaultPath) -> Result<(), VaultError> {
let from = from.flatten();
let to = to.flatten();
let scouted = LinkRewrite::new(&self.index, self.workspace_path(), self.backup)
.scout(&from, &to)
.await?;
let _guards = self
.lock_notes(
std::iter::once(&from)
.chain(std::iter::once(&to))
.chain(scouted.victims().iter()),
)
.await;
let prepared = scouted.prepare().await?;
nfs::rename_note(self.workspace_path(), &from, &to)
.await
.map_err(rename_dest_err)?;
let notes_with_text = prepared.commit().await?;
self.index.rename_note(&from, &to, ¬es_with_text).await?;
Ok(())
}
pub async fn rename_directory(
&self,
from: &VaultPath,
to: &VaultPath,
) -> Result<(), VaultError> {
let from = from.flatten();
let to = to.flatten();
nfs::rename_directory(self.workspace_path(), &from, &to)
.await
.map_err(rename_dest_err)?;
self.index.rename_directory(&from, &to).await?;
Ok(())
}
}
fn rename_dest_err(e: FSError) -> VaultError {
match e {
FSError::AlreadyExists { path } => VaultError::FSError(FSError::InvalidPath {
path: path.to_string(),
message: "Destination path already exists".to_string(),
}),
other => VaultError::FSError(other),
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct DirectoryDetails {
pub path: VaultPath,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SearchResult {
pub path: VaultPath,
pub rtype: ResultType,
}
impl SearchResult {
pub fn note(path: &VaultPath, content_data: &NoteContentData) -> Self {
Self {
path: path.to_owned(),
rtype: ResultType::Note(content_data.to_owned()),
}
}
pub fn directory(path: &VaultPath) -> Self {
Self {
path: path.to_owned(),
rtype: ResultType::Directory,
}
}
pub fn attachment(path: &VaultPath) -> Self {
Self {
path: path.to_owned(),
rtype: ResultType::Attachment,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ResultType {
Note(NoteContentData),
Directory,
Attachment,
}
pub struct VaultBrowseOptionsBuilder {
path: VaultPath,
validation: NotesValidation,
recursive: bool,
}
impl VaultBrowseOptionsBuilder {
pub fn new(path: &VaultPath) -> Self {
Self::default().path(path.clone())
}
pub fn build(self) -> (VaultBrowseOptions, Receiver<SearchResult>) {
let (sender, receiver) = std::sync::mpsc::channel();
(
VaultBrowseOptions {
path: self.path,
validation: self.validation,
recursive: self.recursive,
sender,
},
receiver,
)
}
pub fn path(mut self, path: VaultPath) -> Self {
self.path = path;
self
}
pub fn recursive(mut self, recursive: bool) -> Self {
self.recursive = recursive;
self
}
pub fn validation(mut self, validation: NotesValidation) -> Self {
self.validation = validation;
self
}
}
impl Default for VaultBrowseOptionsBuilder {
fn default() -> Self {
Self {
path: VaultPath::root(),
validation: NotesValidation::None,
recursive: false,
}
}
}
#[derive(Debug, Clone)]
pub struct VaultBrowseOptions {
path: VaultPath,
validation: NotesValidation,
recursive: bool,
sender: Sender<SearchResult>,
}
impl Display for VaultBrowseOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Vault Browse Options - [Path: `{}`|Validation Type: `{}`|Recursive: `{}`]",
self.path, self.validation, self.recursive
)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum NotesValidation {
Full,
Fast,
None,
}
impl Display for NotesValidation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
NotesValidation::Full => "Full",
NotesValidation::Fast => "Fast",
NotesValidation::None => "None",
}
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
use std::time::Duration;
use tempfile::TempDir;
async fn make_vault(dir: &std::path::Path) -> NoteVault {
NoteVault::new(VaultConfig::new(dir)).await.unwrap()
}
#[tokio::test]
async fn get_markdown_and_links_resolves_relative_image() {
let dir = TempDir::new().unwrap();
let vault = make_vault(dir.path()).await;
std::fs::create_dir_all(dir.path().join("directory")).unwrap();
std::fs::write(dir.path().join("directory/note.md"), "").unwrap();
let md_note = vault
.get_markdown_and_links(&VaultPath::new("/directory/note.md"))
.await
.unwrap();
let expected_os_path = dir.path().join("photo.png").display().to_string();
assert_eq!(md_note.text, format!("", expected_os_path));
assert_eq!(1, md_note.links.len());
let link = &md_note.links[0];
assert_eq!(link.ltype, note::LinkType::Image(expected_os_path));
assert_eq!(link.text, "alt");
assert_eq!(link.raw_link, "../photo.png");
}
#[tokio::test]
async fn get_markdown_and_links_resolves_absolute_vault_image() {
let dir = TempDir::new().unwrap();
let vault = make_vault(dir.path()).await;
std::fs::create_dir_all(dir.path().join("notes")).unwrap();
std::fs::write(
dir.path().join("notes/note.md"),
"",
)
.unwrap();
let md_note = vault
.get_markdown_and_links(&VaultPath::new("/notes/note.md"))
.await
.unwrap();
let expected_os_path = dir
.path()
.join("assets")
.join("banner.png")
.display()
.to_string();
assert_eq!(md_note.text, format!("", expected_os_path));
assert!(matches!(
&md_note.links[0].ltype,
note::LinkType::Image(p) if *p == expected_os_path
));
}
#[tokio::test]
async fn get_markdown_and_links_keeps_external_image_url() {
let dir = TempDir::new().unwrap();
let vault = make_vault(dir.path()).await;
let url = "https://example.com/img.png";
std::fs::write(dir.path().join("note.md"), format!("", url)).unwrap();
let md_note = vault
.get_markdown_and_links(&VaultPath::new("/note.md"))
.await
.unwrap();
assert_eq!(md_note.text, format!("", url));
assert!(matches!(
&md_note.links[0].ltype,
note::LinkType::Image(p) if p == url
));
assert_eq!(md_note.links[0].raw_link, url);
}
#[tokio::test]
async fn get_markdown_and_links_mixed_content() {
let dir = TempDir::new().unwrap();
let vault = make_vault(dir.path()).await;
std::fs::write(
dir.path().join("note.md"),
"[[Other Note]] [link](other.md)  #tag",
)
.unwrap();
let md_note = vault
.get_markdown_and_links(&VaultPath::new("/note.md"))
.await
.unwrap();
assert_eq!(
1,
md_note
.links
.iter()
.filter(|l| matches!(l.ltype, note::LinkType::Image(_)))
.count()
);
assert_eq!(
2,
md_note
.links
.iter()
.filter(|l| matches!(l.ltype, note::LinkType::Note(_)))
.count()
);
assert_eq!(
1,
md_note
.links
.iter()
.filter(|l| matches!(l.ltype, note::LinkType::Hashtag))
.count()
);
}
async fn setup_vault_with_notes(dir: &std::path::Path) -> NoteVault {
let vault = NoteVault::new(VaultConfig::new(dir)).await.unwrap();
vault.validate_and_init().await.unwrap();
vault
}
#[tokio::test]
async fn rename_note_updates_wikilink_in_backlink() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_notes(dir.path()).await;
vault
.save_note(&VaultPath::new("/target.md"), "# Target note")
.await
.unwrap();
vault
.save_note(
&VaultPath::new("/referrer.md"),
"# Referrer\nSee [[target]].",
)
.await
.unwrap();
vault
.rename_note(
&VaultPath::new("/target.md"),
&VaultPath::new("/renamed.md"),
)
.await
.unwrap();
let updated = nfs::load_note(dir.path(), &VaultPath::new("/referrer.md"))
.await
.unwrap();
assert!(
updated.contains("[[renamed]]"),
"expected [[renamed]] in: {updated}"
);
assert!(
!updated.contains("[[target]]"),
"old wikilink still present in: {updated}"
);
}
#[tokio::test]
async fn rename_note_updates_markdown_link_in_backlink() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_notes(dir.path()).await;
vault
.save_note(&VaultPath::new("/target.md"), "# Target note")
.await
.unwrap();
vault
.save_note(
&VaultPath::new("/referrer.md"),
"# Referrer\n[link](/target.md) end.",
)
.await
.unwrap();
vault
.rename_note(
&VaultPath::new("/target.md"),
&VaultPath::new("/renamed.md"),
)
.await
.unwrap();
let updated = nfs::load_note(dir.path(), &VaultPath::new("/referrer.md"))
.await
.unwrap();
assert!(
updated.contains("[link](/renamed.md)"),
"expected updated link in: {updated}"
);
assert!(
!updated.contains("/target.md"),
"old path still present in: {updated}"
);
}
#[tokio::test]
async fn rename_note_does_not_touch_unrelated_notes() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_notes(dir.path()).await;
vault
.save_note(&VaultPath::new("/target.md"), "# Target")
.await
.unwrap();
vault
.save_note(
&VaultPath::new("/unrelated.md"),
"# Unrelated\nNo links here.",
)
.await
.unwrap();
vault
.rename_note(
&VaultPath::new("/target.md"),
&VaultPath::new("/renamed.md"),
)
.await
.unwrap();
let unrelated = nfs::load_note(dir.path(), &VaultPath::new("/unrelated.md"))
.await
.unwrap();
assert_eq!(unrelated, "# Unrelated\nNo links here.");
}
#[tokio::test]
async fn rename_note_handles_self_link() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_notes(dir.path()).await;
vault
.save_note(
&VaultPath::new("/target.md"),
"# Target\nSee [[target]] here.",
)
.await
.unwrap();
vault
.rename_note(
&VaultPath::new("/target.md"),
&VaultPath::new("/renamed.md"),
)
.await
.unwrap();
assert!(
!dir.path().join("target.md").exists(),
"old file should be gone"
);
let body = nfs::load_note(dir.path(), &VaultPath::new("/renamed.md"))
.await
.unwrap();
assert!(
body.contains("[[renamed]]"),
"expected self-link rewritten in: {body}"
);
assert!(
!body.contains("[[target]]"),
"old self-link still present in: {body}"
);
let all = vault.get_all_notes().await.unwrap();
assert_eq!(all.len(), 1, "expected single DB row, got: {:?}", all);
}
#[test]
fn test_index_report_finish() {
let mut report = IndexReport::new();
std::thread::sleep(Duration::from_millis(10));
report.finish();
assert!(report.duration > Duration::default());
assert!(report.duration.as_millis() >= 10);
}
#[tokio::test]
async fn test_note_vault_new_with_nonexistent_path() {
let nonexistent_path = "/this/path/does/not/exist";
let result = NoteVault::new(VaultConfig::new(nonexistent_path)).await;
assert!(result.is_err());
match result.unwrap_err() {
VaultError::VaultPathNotFound { path } => {
assert_eq!(path, nonexistent_path);
}
_ => panic!("Expected VaultPathNotFound error"),
}
}
#[tokio::test]
async fn test_note_vault_new_with_file_instead_of_directory() {
let temp_file = tempfile::NamedTempFile::new().unwrap();
let file_path = temp_file.path();
let result = NoteVault::new(VaultConfig::new(file_path)).await;
assert!(result.is_err());
match result.unwrap_err() {
VaultError::FSError(FSError::InvalidPath { message, .. }) => {
assert_eq!(message, "Path provided is not a directory");
}
_ => panic!("Expected FSError::InvalidPath"),
}
}
#[tokio::test]
async fn test_note_vault_new_with_valid_directory() {
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path();
let result = NoteVault::new(VaultConfig::new(dir_path)).await;
assert!(result.is_ok());
let vault = result.unwrap();
assert_eq!(vault.workspace_path(), dir_path);
assert_eq!(vault.journal_path, VaultPath::new(DEFAULT_JOURNAL_PATH));
}
#[tokio::test]
async fn test_get_todays_journal() {
let temp_dir = TempDir::new().unwrap();
let vault = NoteVault::new(VaultConfig::new(temp_dir.path()))
.await
.unwrap();
let (title, note_path) = vault.get_todays_journal();
let today = Utc::now();
let expected_title = today.format("%Y-%m-%d").to_string();
assert_eq!(title, expected_title);
let expected_path = vault
.journal_path
.append(&VaultPath::note_path_from(&expected_title))
.absolute();
assert_eq!(note_path, expected_path);
}
#[tokio::test]
async fn journal_entry_reports_creation_then_reuse() {
let temp_dir = TempDir::new().unwrap();
let vault = NoteVault::new(VaultConfig::new(temp_dir.path()))
.await
.unwrap();
let (_, _, created_first) = vault.journal_entry().await.unwrap();
assert!(created_first, "first call must create today's entry");
let (_, _, created_second) = vault.journal_entry().await.unwrap();
assert!(!created_second, "second call must reuse the existing entry");
}
#[tokio::test]
async fn load_or_create_note_reports_creation_then_reuse() {
let temp_dir = TempDir::new().unwrap();
let vault = NoteVault::new(VaultConfig::new(temp_dir.path()))
.await
.unwrap();
let path = VaultPath::note_path_from("notes/fresh.md");
let (_, created_first) = vault.load_or_create_note(&path, None).await.unwrap();
assert!(created_first, "missing note must be created");
let (_, created_second) = vault.load_or_create_note(&path, None).await.unwrap();
assert!(
!created_second,
"existing note must be loaded, not recreated"
);
}
#[tokio::test]
async fn test_journal_date_with_valid_journal_note() {
let temp_dir = TempDir::new().unwrap();
let vault = NoteVault::new(VaultConfig::new(temp_dir.path()))
.await
.unwrap();
let journal_note_path = vault
.journal_path
.append(&VaultPath::note_path_from("2023-12-25"))
.absolute();
let result = vault.journal_date(&journal_note_path);
assert!(result.is_some());
let date = result.unwrap();
assert_eq!(date, NaiveDate::from_ymd_opt(2023, 12, 25).unwrap());
}
#[tokio::test]
async fn test_journal_date_with_invalid_date_format() {
let temp_dir = TempDir::new().unwrap();
let vault = NoteVault::new(VaultConfig::new(temp_dir.path()))
.await
.unwrap();
let invalid_journal_path = vault
.journal_path
.append(&VaultPath::note_path_from("invalid-date"))
.absolute();
let result = vault.journal_date(&invalid_journal_path);
assert!(result.is_none());
}
#[tokio::test]
async fn test_journal_date_with_non_journal_path() {
let temp_dir = TempDir::new().unwrap();
let vault = NoteVault::new(VaultConfig::new(temp_dir.path()))
.await
.unwrap();
let non_journal_path = VaultPath::new("/other/2023-12-25.md");
let result = vault.journal_date(&non_journal_path);
assert!(result.is_none());
}
#[tokio::test]
async fn test_journal_date_with_non_note_path() {
let temp_dir = TempDir::new().unwrap();
let vault = NoteVault::new(VaultConfig::new(temp_dir.path()))
.await
.unwrap();
let directory_path = vault.journal_path.append(&VaultPath::new("2023-12-25"));
let result = vault.journal_date(&directory_path);
assert!(result.is_none());
}
#[tokio::test]
async fn test_path_to_pathbuf() {
let temp_dir = TempDir::new().unwrap();
let vault = NoteVault::new(VaultConfig::new(temp_dir.path()))
.await
.unwrap();
let vault_path = VaultPath::new("/test/note.md");
let result = vault.path_to_pathbuf(&vault_path);
let expected = vault_path.to_pathbuf(&vault.workspace_path);
assert_eq!(result, expected);
}
#[test]
fn test_directory_details() {
let path = VaultPath::new("/test/directory");
let details = DirectoryDetails { path: path.clone() };
assert_eq!(details.path, path);
}
#[test]
fn test_search_result_note() {
let path = VaultPath::new("/test/note.md");
let content_data = NoteContentData::new("Test Note".to_string(), 12345);
let result = SearchResult::note(&path, &content_data);
assert_eq!(result.path, path);
match result.rtype {
ResultType::Note(data) => assert_eq!(data, content_data),
_ => panic!("Expected Note result type"),
}
}
#[test]
fn test_search_result_directory() {
let path = VaultPath::new("/test/directory");
let result = SearchResult::directory(&path);
assert_eq!(result.path, path);
match result.rtype {
ResultType::Directory => (),
_ => panic!("Expected Directory result type"),
}
}
#[test]
fn test_search_result_attachment() {
let path = VaultPath::new("/test/image.png");
let result = SearchResult::attachment(&path);
assert_eq!(result.path, path);
match result.rtype {
ResultType::Attachment => (),
_ => panic!("Expected Attachment result type"),
}
}
#[test]
fn test_result_type_equality() {
let content_data = NoteContentData::new("Test Note".to_string(), 12345);
let note_type1 = ResultType::Note(content_data.clone());
let note_type2 = ResultType::Note(content_data);
let directory_type = ResultType::Directory;
let attachment_type = ResultType::Attachment;
assert_eq!(note_type1, note_type2);
assert_eq!(directory_type, ResultType::Directory);
assert_eq!(attachment_type, ResultType::Attachment);
assert_ne!(directory_type, attachment_type);
}
#[test]
fn test_vault_browse_options_builder_default() {
let builder = VaultBrowseOptionsBuilder::default();
let (options, _receiver) = builder.build();
assert_eq!(options.path, VaultPath::root());
assert_eq!(options.validation, NotesValidation::None);
assert!(!options.recursive);
}
#[test]
fn test_vault_browse_options_builder_new() {
let test_path = VaultPath::new("/test/path");
let builder = VaultBrowseOptionsBuilder::new(&test_path);
let (options, _receiver) = builder.build();
assert_eq!(options.path, test_path);
assert_eq!(options.validation, NotesValidation::None);
assert!(!options.recursive);
}
#[test]
fn test_vault_browse_options_builder_path() {
let initial_path = VaultPath::new("/initial");
let new_path = VaultPath::new("/new/path");
let builder = VaultBrowseOptionsBuilder::new(&initial_path).path(new_path.clone());
let (options, _receiver) = builder.build();
assert_eq!(options.path, new_path);
}
#[test]
fn test_vault_browse_options_builder_recursive() {
let path = VaultPath::new("/test");
let builder = VaultBrowseOptionsBuilder::new(&path).recursive(true);
let (options, _receiver) = builder.build();
assert!(options.recursive);
let builder = VaultBrowseOptionsBuilder::new(&path).recursive(false);
let (options, _receiver) = builder.build();
assert!(!options.recursive);
}
#[test]
fn test_vault_browse_options_builder_validation_modes() {
let path = VaultPath::new("/test");
for v in [
NotesValidation::Full,
NotesValidation::Fast,
NotesValidation::None,
] {
let builder = VaultBrowseOptionsBuilder::new(&path).validation(v);
let (options, _receiver) = builder.build();
assert_eq!(options.validation, v);
}
}
#[test]
fn test_vault_browse_options_builder_chaining() {
let path = VaultPath::new("/test");
let new_path = VaultPath::new("/new");
let builder = VaultBrowseOptionsBuilder::new(&path)
.path(new_path.clone())
.recursive(true)
.validation(NotesValidation::Full);
let (options, _receiver) = builder.build();
assert_eq!(options.path, new_path);
assert!(options.recursive);
assert_eq!(options.validation, NotesValidation::Full);
}
#[test]
fn test_vault_browse_options_build_returns_channel() {
let path = VaultPath::new("/test");
let builder = VaultBrowseOptionsBuilder::new(&path);
let (_options, receiver) = builder.build();
assert!(receiver.try_recv().is_err());
}
#[test]
fn test_notes_validation_display() {
assert_eq!(format!("{}", NotesValidation::Full), "Full");
assert_eq!(format!("{}", NotesValidation::Fast), "Fast");
assert_eq!(format!("{}", NotesValidation::None), "None");
}
#[test]
fn test_vault_browse_options_display() {
let path = VaultPath::new("/test/path");
let builder = VaultBrowseOptionsBuilder::new(&path)
.recursive(true)
.validation(NotesValidation::Full);
let (options, _receiver) = builder.build();
let display_string = format!("{}", options);
assert!(display_string.contains("Path: `/test/path`"));
assert!(display_string.contains("Validation Type: `Full`"));
assert!(display_string.contains("Recursive: `true`"));
}
#[test]
fn test_default_journal_path_constant() {
assert_eq!(DEFAULT_JOURNAL_PATH, "/journal");
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn rejects_vault_with_case_conflicts() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("note.md"), "lowercase").unwrap();
std::fs::write(tmp.path().join("Note.md"), "uppercase").unwrap();
std::fs::create_dir(tmp.path().join("projects")).unwrap();
std::fs::create_dir(tmp.path().join("Projects")).unwrap();
let vault = NoteVault::new(VaultConfig::new(tmp.path())).await.unwrap();
let result = vault.validate_and_init().await;
match result {
Err(VaultError::CaseConflict { conflicts }) => {
assert_eq!(
conflicts.len(),
2,
"expected 2 conflicts, got: {:?}",
conflicts
);
let joined = conflicts.join("\n");
assert!(
joined.contains("note.md") && joined.contains("Note.md"),
"expected note.md conflict in list, got: {}",
joined
);
assert!(
joined.contains("projects") && joined.contains("Projects"),
"expected projects conflict in list, got: {}",
joined
);
}
other => panic!(
"expected CaseConflict, got: {}",
match other {
Ok(_) => "Ok(_)".to_string(),
Err(e) => format!("Err({})", e),
}
),
}
}
#[tokio::test]
async fn quick_note_creates_timestamped_note_in_inbox() {
let dir = tempfile::TempDir::new().unwrap();
let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
vault.validate_and_init().await.unwrap();
let details = vault.quick_note("my quick thought").await.unwrap();
let (parent, _) = details.path.get_parent_path();
assert!(parent.to_string().contains("inbox"));
let text = vault.get_note_text(&details.path).await.unwrap();
assert_eq!(text, "my quick thought");
}
#[tokio::test]
async fn quick_note_resolves_conflicts() {
let dir = tempfile::TempDir::new().unwrap();
let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
vault.validate_and_init().await.unwrap();
let d1 = vault.quick_note("first").await.unwrap();
let d2 = vault.quick_note("second").await.unwrap();
assert_ne!(d1.path, d2.path);
assert_eq!(vault.get_note_text(&d1.path).await.unwrap(), "first");
assert_eq!(vault.get_note_text(&d2.path).await.unwrap(), "second");
}
#[tokio::test]
async fn quick_note_uses_custom_inbox_path() {
let dir = tempfile::TempDir::new().unwrap();
let mut vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
vault.validate_and_init().await.unwrap();
vault.set_inbox_path(VaultPath::new("/capture"));
let details = vault.quick_note("test").await.unwrap();
let (parent, _) = details.path.get_parent_path();
assert!(parent.to_string().contains("capture"));
}
#[tokio::test]
async fn create_note_errors_when_file_exists() {
let dir = TempDir::new().unwrap();
let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
vault.validate_and_init().await.unwrap();
let path = VaultPath::new("/already.md");
vault.create_note(&path, "first").await.unwrap();
match vault.create_note(&path, "second").await {
Err(VaultError::NoteExists { path: p }) => assert_eq!(p, path.flatten()),
other => panic!("expected NoteExists, got {:?}", other.err()),
}
let text = vault.get_note_text(&path).await.unwrap();
assert_eq!(text, "first");
}
#[tokio::test]
async fn create_directory_errors_when_dir_exists() {
let dir = TempDir::new().unwrap();
let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
vault.validate_and_init().await.unwrap();
let path = VaultPath::new("/projects");
vault.create_directory(&path).await.unwrap();
match vault.create_directory(&path).await {
Err(VaultError::DirectoryExists { path: p }) => assert_eq!(p, path),
other => panic!("expected DirectoryExists, got {:?}", other.err()),
}
}
#[tokio::test]
async fn rename_note_errors_when_dest_exists() {
let dir = TempDir::new().unwrap();
let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
vault.validate_and_init().await.unwrap();
let from = VaultPath::new("/source.md");
let to = VaultPath::new("/dest.md");
vault.create_note(&from, "src").await.unwrap();
vault.create_note(&to, "dst").await.unwrap();
match vault.rename_note(&from, &to).await {
Err(VaultError::FSError(FSError::InvalidPath { message, .. })) => {
assert_eq!(message, "Destination path already exists");
}
other => panic!("expected destination-exists error, got {:?}", other.err()),
}
assert_eq!(vault.get_note_text(&from).await.unwrap(), "src");
assert_eq!(vault.get_note_text(&to).await.unwrap(), "dst");
}
#[tokio::test(flavor = "multi_thread")]
async fn validate_and_init_indexes_nested_tree() {
let dir = TempDir::new().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("dir1/sub")).unwrap();
std::fs::write(root.join("a.md"), "# A").unwrap();
std::fs::write(root.join("dir1/b.md"), "# B").unwrap();
std::fs::write(root.join("dir1/sub/c.md"), "# C").unwrap();
let vault = NoteVault::new(VaultConfig::new(root)).await.unwrap();
vault.validate_and_init().await.unwrap();
let all = vault.get_all_notes().await.unwrap();
let names: Vec<String> = all.iter().map(|(e, _)| e.path.to_string()).collect();
assert_eq!(all.len(), 3, "expected 3 notes, got: {:?}", names);
assert!(names.iter().any(|p| p.ends_with("/a.md")), "{:?}", names);
assert!(
names.iter().any(|p| p.ends_with("/dir1/b.md")),
"{:?}",
names
);
assert!(
names.iter().any(|p| p.ends_with("/dir1/sub/c.md")),
"{:?}",
names
);
}
}
#[cfg(test)]
mod vault_config_tests {
use super::VaultConfig;
use std::path::PathBuf;
#[test]
fn new_sets_workspace_and_no_db_path() {
let cfg = VaultConfig::new("/tmp/ws");
assert_eq!(cfg.workspace_path, PathBuf::from("/tmp/ws"));
assert!(cfg.db_path.is_none());
}
#[test]
fn with_db_path_overrides_default() {
let cfg = VaultConfig::new("/tmp/ws").with_db_path("/var/cache/foo.kimuncache");
assert_eq!(
cfg.db_path.as_deref(),
Some(std::path::Path::new("/var/cache/foo.kimuncache"))
);
}
#[tokio::test]
async fn note_vault_new_uses_vault_config_with_legacy_default() {
use crate::{NoteVault, VaultConfig};
let tmp = tempfile::TempDir::new().unwrap();
let vault = NoteVault::new(VaultConfig::new(tmp.path())).await.unwrap();
let expected = tmp.path().join("kimun.sqlite");
assert!(
expected.exists(),
"legacy DB path should be used when db_path is None"
);
drop(vault);
}
#[tokio::test]
async fn note_vault_new_with_explicit_db_path_uses_override() {
use crate::{NoteVault, VaultConfig};
let workspace = tempfile::TempDir::new().unwrap();
let cache_dir = tempfile::TempDir::new().unwrap();
let custom_db = cache_dir.path().join("my-vault.kimuncache");
let vault = NoteVault::new(VaultConfig::new(workspace.path()).with_db_path(&custom_db))
.await
.unwrap();
assert!(custom_db.exists());
assert!(!workspace.path().join("kimun.sqlite").exists());
drop(vault);
}
}
#[cfg(test)]
mod label_api_tests {
use super::*;
use crate::nfs::VaultPath;
async fn new_vault() -> (tempfile::TempDir, NoteVault) {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = VaultConfig::new(tmp.path().to_path_buf());
let vault = NoteVault::new(cfg).await.unwrap();
vault.validate_and_init().await.unwrap();
(tmp, vault)
}
#[tokio::test]
async fn list_labels_returns_distinct_lowercase_names() {
let (_tmp, vault) = new_vault().await;
vault
.create_note(&VaultPath::note_path_from("/a.md"), "x #Foo and #bar")
.await
.unwrap();
vault
.create_note(&VaultPath::note_path_from("/b.md"), "y #foo only")
.await
.unwrap();
let mut labels = vault.list_labels().await.unwrap();
labels.sort();
assert_eq!(labels, vec!["bar".to_string(), "foo".to_string()]);
}
#[tokio::test]
async fn notes_with_label_is_case_insensitive() {
let (_tmp, vault) = new_vault().await;
let a = VaultPath::note_path_from("/a.md");
let b = VaultPath::note_path_from("/b.md");
vault.create_note(&a, "x #Important").await.unwrap();
vault.create_note(&b, "x #important #other").await.unwrap();
let mut paths = vault.notes_with_label("IMPORTANT").await.unwrap();
paths.sort_by_key(|p| p.to_string());
assert_eq!(paths, vec![a, b]);
}
#[tokio::test]
async fn notes_with_unknown_label_returns_empty() {
let (_tmp, vault) = new_vault().await;
vault
.create_note(&VaultPath::note_path_from("/a.md"), "x")
.await
.unwrap();
let paths = vault.notes_with_label("nosuch").await.unwrap();
assert!(paths.is_empty());
}
#[tokio::test]
async fn label_counts_returns_count_per_label() {
let (_tmp, vault) = new_vault().await;
vault
.create_note(&VaultPath::note_path_from("/a.md"), "x #foo #bar")
.await
.unwrap();
vault
.create_note(&VaultPath::note_path_from("/b.md"), "y #foo")
.await
.unwrap();
vault
.create_note(&VaultPath::note_path_from("/c.md"), "z #baz")
.await
.unwrap();
let counts = vault.label_counts().await.unwrap();
assert_eq!(
counts,
vec![
("bar".to_string(), 1usize),
("baz".to_string(), 1usize),
("foo".to_string(), 2usize),
],
);
}
#[tokio::test]
async fn label_counts_empty_vault_returns_empty() {
let (_tmp, vault) = new_vault().await;
let counts = vault.label_counts().await.unwrap();
assert!(counts.is_empty());
}
}
#[cfg(test)]
mod suggest_api_tests {
use super::*;
use crate::nfs::VaultPath;
async fn new_vault() -> (tempfile::TempDir, NoteVault) {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = VaultConfig::new(tmp.path().to_path_buf());
let vault = NoteVault::new(cfg).await.unwrap();
vault.validate_and_init().await.unwrap();
(tmp, vault)
}
#[tokio::test]
async fn suggest_notes_empty_prefix_returns_top_n() {
let (_tmp, vault) = new_vault().await;
for name in ["Alpha", "Beta", "Gamma"] {
vault
.create_note(&VaultPath::note_path_from(format!("/{name}.md")), "body")
.await
.unwrap();
}
let mut got = vault.suggest_notes_by_prefix("", 50).await.unwrap();
got.sort_by(|a, b| a.name.cmp(&b.name));
let names: Vec<String> = got.into_iter().map(|s| s.name).collect();
assert_eq!(names, vec!["alpha", "beta", "gamma"]);
}
#[tokio::test]
async fn suggest_notes_prefix_is_case_insensitive() {
let (_tmp, vault) = new_vault().await;
vault
.create_note(&VaultPath::note_path_from("/Meeting.md"), "x")
.await
.unwrap();
vault
.create_note(&VaultPath::note_path_from("/melon.md"), "x")
.await
.unwrap();
vault
.create_note(&VaultPath::note_path_from("/zebra.md"), "x")
.await
.unwrap();
let got = vault.suggest_notes_by_prefix("ME", 50).await.unwrap();
let names: std::collections::HashSet<String> = got.into_iter().map(|s| s.name).collect();
assert!(names.contains("meeting"));
assert!(names.contains("melon"));
assert!(!names.contains("zebra"));
}
#[tokio::test]
async fn suggest_notes_respects_limit() {
let (_tmp, vault) = new_vault().await;
for i in 0..10 {
vault
.create_note(&VaultPath::note_path_from(format!("/note{i}.md")), "x")
.await
.unwrap();
}
let got = vault.suggest_notes_by_prefix("note", 3).await.unwrap();
assert_eq!(got.len(), 3);
}
#[tokio::test]
async fn suggest_notes_keeps_same_name_at_different_paths_separate() {
let (_tmp, vault) = new_vault().await;
vault.create_directory(&VaultPath::new("/a")).await.unwrap();
vault.create_directory(&VaultPath::new("/b")).await.unwrap();
vault
.create_note(&VaultPath::note_path_from("/a/Shared.md"), "x")
.await
.unwrap();
vault
.create_note(&VaultPath::note_path_from("/b/Shared.md"), "y")
.await
.unwrap();
let got = vault.suggest_notes_by_prefix("Shared", 50).await.unwrap();
assert_eq!(got.len(), 2, "duplicates by name must not be deduped");
let mut paths: Vec<String> = got.iter().map(|s| s.path.to_string()).collect();
paths.sort();
assert!(paths[0].contains("/a/"));
assert!(paths[1].contains("/b/"));
assert!(got.iter().all(|s| s.name == "shared"));
}
#[tokio::test]
async fn suggest_notes_empty_vault_returns_empty() {
let (_tmp, vault) = new_vault().await;
let got = vault.suggest_notes_by_prefix("anything", 50).await.unwrap();
assert!(got.is_empty());
}
#[tokio::test]
async fn suggest_notes_unicode_and_long_prefix_do_not_panic() {
let (_tmp, vault) = new_vault().await;
vault
.create_note(&VaultPath::note_path_from("/over.md"), "x")
.await
.unwrap();
let long = "a".repeat(4096);
let _ = vault.suggest_notes_by_prefix(&long, 50).await.unwrap();
let _ = vault.suggest_notes_by_prefix("Über", 50).await.unwrap();
}
#[tokio::test]
async fn suggest_notes_special_like_chars_in_prefix_are_escaped() {
let (_tmp, vault) = new_vault().await;
vault
.create_note(&VaultPath::note_path_from("/normal.md"), "x")
.await
.unwrap();
let got = vault.suggest_notes_by_prefix("%", 50).await.unwrap();
assert!(got.is_empty());
let got = vault.suggest_notes_by_prefix("_", 50).await.unwrap();
assert!(got.is_empty());
}
#[tokio::test]
async fn suggest_tags_ranks_by_usage_count_then_name() {
let (_tmp, vault) = new_vault().await;
vault
.create_note(&VaultPath::note_path_from("/a.md"), "x #foo #bar")
.await
.unwrap();
vault
.create_note(&VaultPath::note_path_from("/b.md"), "y #foo")
.await
.unwrap();
vault
.create_note(&VaultPath::note_path_from("/c.md"), "z #foo #baz")
.await
.unwrap();
let got = vault.suggest_tags_by_prefix("", 50).await.unwrap();
assert_eq!(got[0].label, "foo");
assert_eq!(got[0].usage_count, 3);
let labels: Vec<&str> = got.iter().map(|t| t.label.as_str()).collect();
assert_eq!(labels, vec!["foo", "bar", "baz"]);
}
#[tokio::test]
async fn suggest_tags_prefix_is_case_insensitive() {
let (_tmp, vault) = new_vault().await;
vault
.create_note(&VaultPath::note_path_from("/a.md"), "x #Projects")
.await
.unwrap();
let got = vault.suggest_tags_by_prefix("PRO", 50).await.unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].label, "projects");
}
#[tokio::test]
async fn suggest_tags_respects_limit() {
let (_tmp, vault) = new_vault().await;
for i in 0..5 {
vault
.create_note(
&VaultPath::note_path_from(format!("/n{i}.md")),
format!("x #tag{i}"),
)
.await
.unwrap();
}
let got = vault.suggest_tags_by_prefix("tag", 2).await.unwrap();
assert_eq!(got.len(), 2);
}
#[tokio::test]
async fn suggest_tags_empty_vault_returns_empty() {
let (_tmp, vault) = new_vault().await;
let got = vault.suggest_tags_by_prefix("", 50).await.unwrap();
assert!(got.is_empty());
}
}
#[cfg(test)]
mod modify_backup_tests {
use super::{NoteVault, VaultConfig};
use crate::error::VaultError;
use crate::nfs::VaultPath;
use std::path::{Path, PathBuf};
async fn backup_vault() -> (tempfile::TempDir, NoteVault) {
let temp = tempfile::TempDir::new().unwrap();
let vault = NoteVault::new(VaultConfig::new(temp.path()).with_backup(true))
.await
.unwrap();
vault.validate_and_init().await.unwrap();
(temp, vault)
}
fn backups_dir_today(workspace: &Path) -> PathBuf {
let date = chrono::Utc::now().format("%Y-%m-%d").to_string();
workspace.join(".kimun").join("backups").join(date)
}
#[tokio::test]
async fn replace_swaps_unique_substring() {
let (_t, vault) = backup_vault().await;
let p = VaultPath::new("note.md");
vault.create_note(&p, "hello world").await.unwrap();
let n = vault
.replace_in_note(&p, "world", "there", false, false)
.await
.unwrap();
assert_eq!(n, 1);
assert_eq!(vault.get_note_text(&p).await.unwrap(), "hello there");
}
#[tokio::test]
async fn replace_errors_when_absent() {
let (_t, vault) = backup_vault().await;
let p = VaultPath::new("note.md");
vault.create_note(&p, "hello").await.unwrap();
let e = vault
.replace_in_note(&p, "nope", "x", false, false)
.await
.unwrap_err();
assert!(matches!(e, VaultError::ReplaceTextNotFound { .. }));
assert_eq!(vault.get_note_text(&p).await.unwrap(), "hello");
}
#[tokio::test]
async fn replace_errors_when_not_unique() {
let (_t, vault) = backup_vault().await;
let p = VaultPath::new("note.md");
vault.create_note(&p, "a a a").await.unwrap();
let e = vault
.replace_in_note(&p, "a", "b", false, false)
.await
.unwrap_err();
assert!(matches!(e, VaultError::ReplaceTextNotUnique { .. }));
assert_eq!(vault.get_note_text(&p).await.unwrap(), "a a a");
}
#[tokio::test]
async fn replace_all_replaces_every_occurrence() {
let (_t, vault) = backup_vault().await;
let p = VaultPath::new("note.md");
vault.create_note(&p, "a a a").await.unwrap();
let n = vault
.replace_in_note(&p, "a", "b", true, false)
.await
.unwrap();
assert_eq!(n, 3);
assert_eq!(vault.get_note_text(&p).await.unwrap(), "b b b");
}
#[tokio::test]
async fn replace_regex_unique_swaps_match() {
let (_t, vault) = backup_vault().await;
let p = VaultPath::new("note.md");
vault.create_note(&p, "version 1.2.3 here").await.unwrap();
let n = vault
.replace_in_note(&p, r"\d+\.\d+\.\d+", "9.9.9", false, true)
.await
.unwrap();
assert_eq!(n, 1);
assert_eq!(vault.get_note_text(&p).await.unwrap(), "version 9.9.9 here");
}
#[tokio::test]
async fn replace_regex_all_with_capture_groups() {
let (_t, vault) = backup_vault().await;
let p = VaultPath::new("note.md");
vault.create_note(&p, "a1 b2 c3").await.unwrap();
let n = vault
.replace_in_note(&p, r"([a-z])(\d)", "$2$1", true, true)
.await
.unwrap();
assert_eq!(n, 3);
assert_eq!(vault.get_note_text(&p).await.unwrap(), "1a 2b 3c");
}
#[tokio::test]
async fn replace_regex_not_unique_errors() {
let (_t, vault) = backup_vault().await;
let p = VaultPath::new("note.md");
vault.create_note(&p, "cat cot cut").await.unwrap();
let e = vault
.replace_in_note(&p, r"c.t", "X", false, true)
.await
.unwrap_err();
assert!(matches!(e, VaultError::ReplaceTextNotUnique { .. }));
assert_eq!(vault.get_note_text(&p).await.unwrap(), "cat cot cut");
}
#[tokio::test]
async fn replace_regex_invalid_pattern_errors_and_leaves_note() {
let (_t, vault) = backup_vault().await;
let p = VaultPath::new("note.md");
vault.create_note(&p, "untouched").await.unwrap();
let e = vault
.replace_in_note(&p, "(unclosed", "y", false, true)
.await
.unwrap_err();
assert!(matches!(e, VaultError::InvalidRegex { .. }));
assert_eq!(vault.get_note_text(&p).await.unwrap(), "untouched");
}
#[tokio::test]
async fn replace_literal_treats_metachars_literally() {
let (_t, vault) = backup_vault().await;
let p = VaultPath::new("note.md");
vault.create_note(&p, "a.b and axb").await.unwrap();
let n = vault
.replace_in_note(&p, "a.b", "Z", false, false)
.await
.unwrap();
assert_eq!(n, 1);
assert_eq!(vault.get_note_text(&p).await.unwrap(), "Z and axb");
}
#[tokio::test]
async fn preview_replace_reports_result_without_writing() {
let (_t, vault) = backup_vault().await;
let p = VaultPath::new("note.md");
vault.create_note(&p, "hello world").await.unwrap();
let pv = vault
.preview_replace(&p, "world", "there", false, false)
.await
.unwrap();
assert_eq!(pv.count, 1);
assert_eq!(pv.content, "hello there");
assert_eq!(vault.get_note_text(&p).await.unwrap(), "hello world");
}
#[tokio::test]
async fn preview_replace_surfaces_same_errors() {
let (_t, vault) = backup_vault().await;
let p = VaultPath::new("note.md");
vault.create_note(&p, "a a").await.unwrap();
let e = vault
.preview_replace(&p, "a", "b", false, false)
.await
.unwrap_err();
assert!(matches!(e, VaultError::ReplaceTextNotUnique { .. }));
assert_eq!(vault.get_note_text(&p).await.unwrap(), "a a");
}
#[tokio::test]
async fn overwrite_backs_up_previous_content_when_enabled() {
let (temp, vault) = backup_vault().await;
let p = VaultPath::new("note.md");
vault.create_note(&p, "original").await.unwrap();
vault.save_note(&p, "updated").await.unwrap();
let backup = backups_dir_today(temp.path()).join("note.md");
assert_eq!(std::fs::read_to_string(&backup).unwrap(), "original");
assert_eq!(vault.get_note_text(&p).await.unwrap(), "updated");
}
#[tokio::test]
async fn overwrite_does_not_back_up_when_disabled() {
let temp = tempfile::TempDir::new().unwrap();
let vault = NoteVault::new(VaultConfig::new(temp.path())).await.unwrap();
vault.validate_and_init().await.unwrap();
let p = VaultPath::new("note.md");
vault.create_note(&p, "original").await.unwrap();
vault.save_note(&p, "updated").await.unwrap();
assert!(!temp.path().join(".kimun").join("backups").exists());
}
#[tokio::test]
async fn delete_backs_up_when_enabled() {
let (temp, vault) = backup_vault().await;
let p = VaultPath::new("note.md");
vault.create_note(&p, "content").await.unwrap();
vault.delete_note(&p).await.unwrap();
let backup = backups_dir_today(temp.path()).join("note.md");
assert_eq!(std::fs::read_to_string(&backup).unwrap(), "content");
}
#[tokio::test]
async fn repeat_same_day_edit_keeps_every_backup() {
let (temp, vault) = backup_vault().await;
let p = VaultPath::new("note.md");
vault.create_note(&p, "v0").await.unwrap();
vault.save_note(&p, "v1").await.unwrap();
vault.save_note(&p, "v2").await.unwrap();
let dir = backups_dir_today(temp.path());
let count = std::fs::read_dir(&dir).unwrap().count();
assert_eq!(count, 2, "both pre-images should be retained");
}
#[tokio::test]
async fn purge_removes_backups_older_than_retention() {
let (temp, vault) = backup_vault().await;
let old = temp
.path()
.join(".kimun")
.join("backups")
.join("2000-01-01");
std::fs::create_dir_all(&old).unwrap();
std::fs::write(old.join("ancient.md"), "x").unwrap();
let p = VaultPath::new("note.md");
vault.create_note(&p, "a").await.unwrap();
vault.save_note(&p, "b").await.unwrap();
assert!(!old.exists(), "stale date-dir should be purged");
assert!(
backups_dir_today(temp.path()).exists(),
"today's backup is kept"
);
}
#[tokio::test]
async fn backups_are_not_indexed() {
let (_temp, vault) = backup_vault().await;
let p = VaultPath::note_path_from("/note.md");
vault.create_note(&p, "live").await.unwrap();
vault.save_note(&p, "changed").await.unwrap();
vault.validate_and_init().await.unwrap();
let notes = vault.get_all_notes().await.unwrap();
let paths: Vec<String> = notes.iter().map(|(e, _)| e.path.to_string()).collect();
assert!(
paths
.iter()
.all(|p| !p.contains(".kimun") && !p.contains("backups")),
"backup files must not be indexed: {paths:?}"
);
assert!(
paths.iter().any(|p| p.contains("note")),
"live note should be indexed: {paths:?}"
);
}
#[tokio::test]
async fn rename_backs_up_backlink_victims() {
let (temp, vault) = backup_vault().await;
let b = VaultPath::note_path_from("/b.md");
let a = VaultPath::note_path_from("/a.md");
vault.create_note(&b, "I am b").await.unwrap();
vault
.create_note(&a, "see [[b]] for details")
.await
.unwrap();
vault
.rename_note(&b, &VaultPath::note_path_from("/c.md"))
.await
.unwrap();
let backup = backups_dir_today(temp.path()).join("a.md");
let content = std::fs::read_to_string(&backup)
.unwrap_or_else(|e| panic!("victim backup a.md should exist: {e}"));
assert!(
content.contains("[[b]]"),
"backup should hold the pre-rewrite content: {content:?}"
);
assert!(vault.get_note_text(&a).await.unwrap().contains("[[c]]"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_replaces_do_not_lose_updates() {
let (_temp, vault) = backup_vault().await;
let path = VaultPath::note_path_from("/note.md");
let tokens = ["a", "b", "c", "d", "e", "f", "g", "h"];
vault.create_note(&path, tokens.join(" ")).await.unwrap();
let mut handles = Vec::new();
for t in tokens {
let v = vault.clone();
let p = path.clone();
handles.push(tokio::spawn(async move {
v.replace_in_note(&p, t, &t.to_uppercase(), false, false)
.await
.unwrap();
}));
}
for h in handles {
h.await.unwrap();
}
assert_eq!(vault.get_note_text(&path).await.unwrap(), "A B C D E F G H");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_appends_do_not_lose_updates() {
let (_temp, vault) = backup_vault().await;
let path = VaultPath::note_path_from("/log.md");
let lines = ["a", "b", "c", "d", "e", "f", "g", "h"];
let mut handles = Vec::new();
for l in lines {
let v = vault.clone();
let p = path.clone();
handles.push(tokio::spawn(async move {
v.append_to_note(&p, l, None).await.unwrap();
}));
}
for h in handles {
h.await.unwrap();
}
let text = vault.get_note_text(&path).await.unwrap();
assert_eq!(text.lines().count(), lines.len(), "lines: {text:?}");
for l in lines {
assert!(text.contains(l), "missing {l} in {text:?}");
}
}
}
#[cfg(test)]
mod saved_search_tests {
use super::*;
use tempfile::TempDir;
async fn make_vault(dir: &std::path::Path) -> NoteVault {
NoteVault::new(VaultConfig::new(dir)).await.unwrap()
}
#[tokio::test]
async fn saved_search_crud() {
let dir = TempDir::new().unwrap();
let vault = make_vault(dir.path()).await;
assert!(vault.list_saved_searches().await.unwrap().is_empty());
vault.save_search("todo", "#todo").await.unwrap();
vault.save_search("links", ">{note}").await.unwrap();
let all = vault.list_saved_searches().await.unwrap();
assert_eq!(all.len(), 2);
vault.save_search("Todo", "#todo #urgent").await.unwrap();
let all = vault.list_saved_searches().await.unwrap();
assert_eq!(all.len(), 2);
assert_eq!(
all.iter()
.find(|s| s.name.eq_ignore_ascii_case("todo"))
.unwrap()
.query,
"#todo #urgent"
);
vault
.rename_saved_search("links", "backlinks")
.await
.unwrap();
assert!(vault
.list_saved_searches()
.await
.unwrap()
.iter()
.any(|s| s.name == "backlinks"));
vault.delete_saved_search("todo").await.unwrap();
let all = vault.list_saved_searches().await.unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].name, "backlinks");
}
#[tokio::test]
async fn suggest_by_prefix_filters_case_insensitively_and_caps() {
let dir = TempDir::new().unwrap();
let vault = make_vault(dir.path()).await;
vault.save_search("Today", "#today").await.unwrap();
vault.save_search("todo-week", "#todo").await.unwrap();
vault.save_search("journal", "in:journal").await.unwrap();
let hits = vault
.suggest_saved_searches_by_prefix("TO", 9)
.await
.unwrap();
let names: Vec<&str> = hits.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["Today", "todo-week"]);
assert_eq!(
vault
.suggest_saved_searches_by_prefix("", 9)
.await
.unwrap()
.len(),
3
);
assert_eq!(
vault
.suggest_saved_searches_by_prefix("", 2)
.await
.unwrap()
.len(),
2
);
assert!(vault
.suggest_saved_searches_by_prefix("zzz", 9)
.await
.unwrap()
.is_empty());
}
}