pub mod filename;
pub mod saved_searches;
use std::{
fmt::Display,
hash::Hash,
path::{Path, PathBuf},
str::FromStr,
sync::LazyLock,
time::UNIX_EPOCH,
};
use ignore::{WalkBuilder, WalkParallel};
use log::warn;
use regex::Regex;
use serde::{de::Visitor, Deserialize, Serialize};
use twox_hash::XxHash64;
use super::{error::FSError, DirectoryDetails, NoteDetails};
use super::utilities::path_to_string;
pub const PATH_SEPARATOR: char = '/';
const NOTE_EXTENSION: &str = ".md";
pub fn with_note_extension<S: AsRef<str>>(name: S) -> String {
let name = name.as_ref();
if name.ends_with(NOTE_EXTENSION) {
name.to_string()
} else {
format!("{name}{NOTE_EXTENSION}")
}
}
static RX_INCREMENT_SUFFIX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"_(?P<number>[0-9]+)$").unwrap());
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct VaultEntry {
pub path: VaultPath,
pub path_string: String,
pub data: EntryData,
}
impl AsRef<str> for VaultEntry {
fn as_ref(&self) -> &str {
self.path_string.as_ref()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) enum EntryData {
Note(NoteEntryData),
Directory(DirectoryEntryData),
Attachment,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
pub struct NoteEntryData {
pub path: VaultPath,
pub size: u64,
pub modified_secs: u64,
}
impl NoteEntryData {
#[cfg(test)]
pub async fn load_details<P: AsRef<Path>>(
&self,
workspace_path: P,
path: &VaultPath,
) -> Result<NoteDetails, FSError> {
let content = load_note(workspace_path, path).await?;
Ok(NoteDetails::new(path, content))
}
pub(crate) fn load_details_from_os_path(&self, os_path: &Path) -> Result<NoteDetails, FSError> {
let bytes = std::fs::read(os_path)?;
let text = String::from_utf8(bytes)?;
Ok(NoteDetails::new(&self.path, text))
}
async fn from_os_path(path: &VaultPath, file_path: &Path) -> Result<NoteEntryData, FSError> {
let metadata = tokio::fs::metadata(file_path).await?;
Ok(Self::from_metadata(path, &metadata))
}
fn from_metadata(path: &VaultPath, metadata: &std::fs::Metadata) -> NoteEntryData {
let size = metadata.len();
let modified_secs = metadata
.modified()
.map(|t| t.duration_since(UNIX_EPOCH).unwrap().as_secs())
.unwrap_or(0);
NoteEntryData {
path: path.flatten(),
size,
modified_secs,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DirectoryEntryData {
pub path: VaultPath,
}
impl DirectoryEntryData {
pub fn get_details<P: AsRef<Path>>(&self) -> DirectoryDetails {
DirectoryDetails {
path: self.path.clone(),
}
}
}
#[cfg(test)]
#[derive(Debug, Clone)]
pub(crate) enum VaultEntryDetails {
Note(NoteDetails),
#[allow(dead_code)]
Directory(DirectoryDetails),
None,
}
#[cfg(test)]
impl VaultEntryDetails {
pub fn get_title(&mut self) -> String {
match self {
VaultEntryDetails::Note(note_details) => note_details.get_title(),
VaultEntryDetails::Directory(_) => String::new(),
VaultEntryDetails::None => String::new(),
}
}
}
impl VaultEntry {
#[cfg(test)]
pub async fn new<P: AsRef<Path>>(workspace_path: P, path: VaultPath) -> Result<Self, FSError> {
let os_path = resolve_path_on_disk(&workspace_path, &path).await;
let metadata = tokio::fs::metadata(&os_path)
.await
.map_err(|e| Self::map_metadata_err(e, &os_path))?;
Self::assemble(path, &metadata)
}
#[cfg(test)]
pub async fn from_path<P: AsRef<Path>, F: AsRef<Path>>(
workspace_path: P,
full_path: F,
) -> Result<Self, FSError> {
let note_path = VaultPath::from_path(&workspace_path, &full_path)?;
let os_path = full_path.as_ref();
let metadata = tokio::fs::metadata(os_path)
.await
.map_err(|e| Self::map_metadata_err(e, os_path))?;
Self::assemble(note_path, &metadata)
}
pub(crate) fn from_path_sync<P: AsRef<Path>, F: AsRef<Path>>(
workspace_path: P,
full_path: F,
) -> Result<Self, FSError> {
let note_path = VaultPath::from_path(&workspace_path, &full_path)?;
let os_path = full_path.as_ref();
let metadata =
std::fs::metadata(os_path).map_err(|e| Self::map_metadata_err(e, os_path))?;
Self::assemble(note_path, &metadata)
}
fn map_metadata_err(e: std::io::Error, os_path: &Path) -> FSError {
match e.kind() {
std::io::ErrorKind::NotFound => FSError::NoFileOrDirectoryFound {
path: path_to_string(os_path),
},
_ => FSError::ReadFileError(e),
}
}
fn assemble(path: VaultPath, metadata: &std::fs::Metadata) -> Result<Self, FSError> {
let data = if metadata.is_dir() {
EntryData::Directory(DirectoryEntryData { path: path.clone() })
} else if path.is_note() {
EntryData::Note(NoteEntryData::from_metadata(&path, metadata))
} else {
EntryData::Attachment
};
let path_string = path.to_string();
Ok(VaultEntry {
path,
path_string,
data,
})
}
}
impl Display for VaultEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.data {
EntryData::Note(_details) => write!(f, "[NOT] {}", self.path),
EntryData::Directory(_details) => write!(f, "[DIR] {}", self.path),
EntryData::Attachment => write!(f, "[ATT]"),
}
}
}
pub(crate) fn hash_text<S: AsRef<str>>(text: S) -> u64 {
XxHash64::oneshot(42, text.as_ref().as_bytes())
}
pub(crate) async fn resolve_path_on_disk<P: AsRef<Path>>(
workspace_path: P,
vault_path: &VaultPath,
) -> PathBuf {
let canonical = vault_path.to_pathbuf(&workspace_path);
if matches!(tokio::fs::try_exists(&canonical).await, Ok(true)) {
return canonical;
}
let mut current = workspace_path.as_ref().to_path_buf();
for slice in &vault_path.flatten().slices {
let name = slice.to_string();
let real_name = async {
let mut entries = tokio::fs::read_dir(¤t).await.ok()?;
while let Ok(Some(entry)) = entries.next_entry().await {
if entry.file_name().to_string_lossy().to_lowercase() == name {
return Some(entry.file_name().to_string_lossy().into_owned());
}
}
None
}
.await
.unwrap_or(name);
current = current.join(real_name);
}
current
}
pub(crate) fn resolve_path_on_disk_sync<P: AsRef<Path>>(
workspace_path: P,
vault_path: &VaultPath,
) -> PathBuf {
let canonical = vault_path.to_pathbuf(&workspace_path);
if canonical.exists() {
return canonical;
}
let mut current = workspace_path.as_ref().to_path_buf();
for slice in &vault_path.flatten().slices {
let name = slice.to_string();
let real_name = std::fs::read_dir(¤t)
.ok()
.and_then(|entries| {
entries
.filter_map(|e| e.ok())
.find(|e| e.file_name().to_string_lossy().to_lowercase() == name)
.map(|e| e.file_name().to_string_lossy().into_owned())
})
.unwrap_or(name);
current = current.join(real_name);
}
current
}
pub(crate) fn check_case_conflicts<P: AsRef<Path>>(workspace_path: P) -> Vec<String> {
let root = workspace_path.as_ref();
check_conflicts_in_dir(root, root)
}
fn check_conflicts_in_dir(workspace_root: &Path, dir: &Path) -> Vec<String> {
let mut conflicts = Vec::new();
let mut seen: std::collections::HashMap<String, std::ffi::OsString> =
std::collections::HashMap::new();
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return conflicts,
};
let mut subdirs = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy().to_string();
if name_str.starts_with('.') {
continue;
}
let lower = name_str.to_lowercase();
if let Some(existing) = seen.get(&lower) {
let rel = dir.strip_prefix(workspace_root).unwrap_or(dir);
let rel_str = rel.to_string_lossy();
let location = if rel_str.is_empty() {
PATH_SEPARATOR.to_string()
} else {
format!("{}{}", PATH_SEPARATOR, rel_str)
};
conflicts.push(format!(
"\"{}\" conflicts with \"{}\" in {}",
name_str,
existing.to_string_lossy(),
location
));
} else {
seen.insert(lower, name);
}
if let Ok(ft) = entry.file_type() {
if ft.is_dir() {
subdirs.push(entry.path());
}
}
}
for subdir in subdirs {
conflicts.extend(check_conflicts_in_dir(workspace_root, &subdir));
}
conflicts
}
pub(crate) async fn load_note<P: AsRef<Path>>(
workspace_path: P,
path: &VaultPath,
) -> Result<String, FSError> {
let os_path = resolve_path_on_disk(&workspace_path, path).await;
match tokio::fs::read(&os_path).await {
Ok(file) => {
let text = String::from_utf8(file)?;
Ok(text)
}
Err(e) => match e.kind() {
std::io::ErrorKind::NotFound => Err(FSError::VaultPathNotFound {
path: path.to_owned(),
}),
_ => Err(FSError::ReadFileError(e)),
},
}
}
pub(crate) async fn create_directory<P: AsRef<Path>>(
workspace_path: P,
path: &VaultPath,
) -> Result<DirectoryEntryData, FSError> {
path.ensure_directory()?;
let full_path = resolve_path_on_disk(&workspace_path, path).await;
if let Some(parent) = full_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
match tokio::fs::create_dir(&full_path).await {
Ok(()) => Ok(DirectoryEntryData {
path: path.to_owned(),
}),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(FSError::AlreadyExists {
path: path.to_owned(),
}),
Err(e) => Err(FSError::ReadFileError(e)),
}
}
pub(crate) async fn save_attachment<P: AsRef<Path>>(
workspace_path: P,
path: &VaultPath,
bytes: &[u8],
) -> Result<(), FSError> {
let full_path = path.flatten().to_pathbuf(workspace_path);
if let Some(parent) = full_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(&full_path, bytes).await?;
Ok(())
}
pub(crate) async fn save_note<P: AsRef<Path>, S: AsRef<str>>(
workspace_path: P,
path: &VaultPath,
text: S,
) -> Result<NoteEntryData, FSError> {
path.ensure_note()?;
let full_path = resolve_path_on_disk(&workspace_path, path).await;
if let Some(base_path) = full_path.parent() {
tokio::fs::create_dir_all(base_path).await?;
}
tokio::fs::write(&full_path, text.as_ref().as_bytes()).await?;
let entry = NoteEntryData::from_os_path(path, &full_path).await?;
Ok(entry)
}
pub(crate) async fn create_note_exclusive<P: AsRef<Path>, S: AsRef<str>>(
workspace_path: P,
path: &VaultPath,
text: S,
) -> Result<NoteEntryData, FSError> {
path.ensure_note()?;
let full_path = resolve_path_on_disk(&workspace_path, path).await;
if let Some(base_path) = full_path.parent() {
tokio::fs::create_dir_all(base_path).await?;
}
let mut file = match tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&full_path)
.await
{
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(FSError::AlreadyExists {
path: path.to_owned(),
});
}
Err(e) => return Err(FSError::ReadFileError(e)),
};
use tokio::io::AsyncWriteExt;
file.write_all(text.as_ref().as_bytes()).await?;
file.flush().await?;
drop(file);
NoteEntryData::from_os_path(path, &full_path).await
}
pub(crate) async fn rename_note<P: AsRef<Path>>(
workspace_path: P,
from: &VaultPath,
to: &VaultPath,
) -> Result<(), FSError> {
from.ensure_note()?;
to.ensure_note()?;
rename_path(workspace_path, from, to).await
}
pub(crate) async fn rename_directory<P: AsRef<Path>>(
workspace_path: P,
from: &VaultPath,
to: &VaultPath,
) -> Result<(), FSError> {
from.ensure_directory()?;
to.ensure_directory()?;
rename_path(workspace_path, from, to).await
}
async fn rename_path<P: AsRef<Path>>(
workspace_path: P,
from: &VaultPath,
to: &VaultPath,
) -> Result<(), FSError> {
let full_from_path = resolve_path_on_disk(&workspace_path, from).await;
let (to_parent, to_name) = to.get_parent_path();
let to_base = resolve_path_on_disk(&workspace_path, &to_parent).await;
let full_to_path = to_base.join(&to_name);
if matches!(tokio::fs::try_exists(&full_to_path).await, Ok(true)) {
return Err(FSError::AlreadyExists {
path: to.to_owned(),
});
}
match tokio::fs::metadata(&to_base).await {
Ok(m) if m.is_dir() => {}
_ => {
tokio::fs::create_dir_all(&to_base).await?;
}
}
tokio::fs::rename(full_from_path, full_to_path).await?;
Ok(())
}
const BACKUP_RETENTION_DAYS: i64 = 30;
static LAST_PURGE: std::sync::LazyLock<
std::sync::Mutex<Option<(std::path::PathBuf, chrono::NaiveDate)>>,
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(None));
async fn purge_old_backups(backups_root: &Path) {
let today = chrono::Utc::now().date_naive();
if LAST_PURGE
.lock()
.unwrap()
.as_ref()
.is_some_and(|(root, day)| root == backups_root && *day == today)
{
return;
}
let cutoff = today - chrono::Duration::days(BACKUP_RETENTION_DAYS);
let mut entries = match tokio::fs::read_dir(backups_root).await {
Ok(e) => e,
Err(_) => return,
};
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name();
if let Ok(date) = chrono::NaiveDate::parse_from_str(&name.to_string_lossy(), "%Y-%m-%d") {
if date < cutoff {
let _ = tokio::fs::remove_dir_all(entry.path()).await;
}
}
}
*LAST_PURGE.lock().unwrap() = Some((backups_root.to_path_buf(), today));
}
async fn reserve_backup_dest(base: &Path) -> Result<std::path::PathBuf, FSError> {
let mut candidate = base.to_path_buf();
let mut attempt: u32 = 0;
loop {
match tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&candidate)
.await
{
Ok(_) => return Ok(candidate),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
let ts = chrono::Utc::now().format("%H%M%S%6f");
let mut name = base.file_name().unwrap_or_default().to_os_string();
name.push(format!(".{ts}.{attempt}"));
candidate = base.with_file_name(name);
attempt = attempt.wrapping_add(1);
}
Err(e) => return Err(FSError::ReadFileError(e)),
}
}
}
pub(crate) async fn backup_note<P: AsRef<Path>>(
workspace_path: P,
path: &VaultPath,
) -> Result<(), FSError> {
let workspace_path = workspace_path.as_ref();
let src = resolve_path_on_disk(workspace_path, path).await;
match tokio::fs::try_exists(&src).await {
Ok(true) => {}
Ok(false) => return Ok(()),
Err(e) => return Err(FSError::ReadFileError(e)),
}
let rel = src
.strip_prefix(workspace_path)
.map_err(|_| FSError::InvalidPath {
path: src.to_string_lossy().into_owned(),
message: "note path escapes the workspace".to_string(),
})?;
let backups_root = workspace_path.join(".kimun").join("backups");
purge_old_backups(&backups_root).await;
let date = chrono::Utc::now().format("%Y-%m-%d").to_string();
let base = backups_root.join(date).join(rel);
if let Some(parent) = base.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let dest = reserve_backup_dest(&base).await?;
tokio::fs::copy(&src, &dest).await?;
Ok(())
}
pub(crate) async fn delete_note<P: AsRef<Path>>(
workspace_path: P,
path: &VaultPath,
) -> Result<(), FSError> {
let full_path = resolve_path_on_disk(&workspace_path, path).await;
tokio::fs::remove_file(full_path).await?;
Ok(())
}
pub(crate) fn ensure_dir(dir: &Path) -> Result<(), FSError> {
std::fs::create_dir_all(dir).map_err(FSError::ReadFileError)
}
pub(crate) async fn path_exists<P: AsRef<Path>>(
workspace_path: P,
path: &VaultPath,
) -> Result<bool, FSError> {
let full_path = resolve_path_on_disk(&workspace_path, path).await;
Ok(tokio::fs::try_exists(&full_path).await?)
}
pub(crate) async fn delete_directory<P: AsRef<Path>>(
workspace_path: P,
path: &VaultPath,
) -> Result<(), FSError> {
let full_path = resolve_path_on_disk(&workspace_path, path).await;
tokio::fs::remove_dir_all(full_path).await?;
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct VaultPath {
absolute: bool,
slices: Vec<VaultPathSlice>,
}
impl FromStr for VaultPath {
type Err = FSError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_string(s)
}
}
impl TryFrom<String> for VaultPath {
type Error = FSError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::from_string(value)
}
}
impl From<&VaultPath> for VaultPath {
fn from(value: &VaultPath) -> Self {
value.to_owned()
}
}
impl TryFrom<&str> for VaultPath {
type Error = FSError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
VaultPath::from_string(value)
}
}
impl TryFrom<&String> for VaultPath {
type Error = FSError;
fn try_from(value: &String) -> Result<Self, Self::Error> {
VaultPath::from_string(value)
}
}
impl Serialize for VaultPath {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let string = self.to_string();
serializer.serialize_str(string.as_ref())
}
}
struct DeserializeVaultPathVisitor;
impl Visitor<'_> for DeserializeVaultPathVisitor {
type Value = VaultPath;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("A valid path with `/` separators, no need of starting `/`")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
let path = VaultPath::new(value);
Ok(path)
}
}
impl<'de> Deserialize<'de> for VaultPath {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_str(DeserializeVaultPathVisitor)
}
}
impl VaultPath {
pub fn new<S: AsRef<str>>(path: S) -> Self {
let mut slices = vec![];
let absolute = path.as_ref().starts_with(PATH_SEPARATOR);
path.as_ref()
.split(PATH_SEPARATOR)
.filter(|p| !p.is_empty()) .for_each(|slice| {
slices.push(VaultPathSlice::new(slice));
});
Self { absolute, slices }
}
fn from_string<S: AsRef<str>>(value: S) -> Result<Self, FSError> {
let path = value.as_ref();
if Self::is_valid(path) {
Ok(Self::new(path))
} else {
Err(FSError::InvalidPath {
path: path.to_string(),
message: "path contains invalid characters".to_string(),
})
}
}
pub fn is_valid<S: AsRef<str>>(path: S) -> bool {
if path
.as_ref()
.starts_with(format!("{}{}", PATH_SEPARATOR, PATH_SEPARATOR).as_str())
{
return false;
}
!path
.as_ref()
.split(PATH_SEPARATOR)
.any(|s| !VaultPathSlice::is_valid(s))
}
pub fn note_path_from<S: AsRef<str>>(path: S) -> Self {
let path = path.as_ref();
let path_clean = path.strip_suffix(PATH_SEPARATOR).unwrap_or(path);
let p = if !path_clean.ends_with(NOTE_EXTENSION) {
[path_clean, NOTE_EXTENSION].concat()
} else {
path_clean.to_owned()
};
VaultPath::new(p)
}
pub fn root() -> Self {
Self {
absolute: true,
slices: vec![],
}
}
pub fn empty() -> Self {
Self {
absolute: false,
slices: vec![],
}
}
pub fn is_root_or_empty(&self) -> bool {
self.slices.is_empty()
}
pub fn get_name_on_conflict(&self) -> VaultPath {
let mut slices = self.slices.clone();
match slices.pop() {
Some(slice) => {
if let VaultPathSlice::PathSlice(name) = slice {
let new_name = if let Some(name) = name.strip_suffix(NOTE_EXTENSION) {
format!("{}{}", Self::increment(name), NOTE_EXTENSION)
} else {
Self::increment(name)
};
slices.push(VaultPathSlice::new(new_name));
VaultPath {
absolute: self.absolute,
slices,
}
} else {
VaultPath::new("0")
}
}
None => VaultPath::new("0"),
}
}
pub fn get_clean_name(&self) -> String {
let name = self.get_name();
if let Some(name) = name.strip_suffix(NOTE_EXTENSION) {
name.to_string()
} else {
name
}
}
pub fn to_bare_string(&self) -> String {
let s = self.to_string();
s.strip_suffix(NOTE_EXTENSION)
.map(|bare| bare.to_owned())
.unwrap_or(s)
}
pub fn to_string_with_ext(&self) -> String {
with_note_extension(self.to_string())
}
fn increment<S: AsRef<str>>(name: S) -> String {
let name = name.as_ref();
let (n, suffix_num) = if let Some(caps) = RX_INCREMENT_SUFFIX.captures(name) {
let suffix = &caps["number"];
let n = name
.strip_suffix(&format!("_{}", suffix))
.map_or_else(|| name.to_string(), |s| s.to_string());
(n, suffix.parse::<u64>().map_or_else(|_e| 0, |n| n + 1))
} else {
(name.to_string(), 0)
};
format!("{}_{}", n, suffix_num)
}
pub fn get_slices(&self) -> Vec<String> {
self.flatten()
.slices
.iter()
.map(|slice| slice.to_string())
.collect()
}
pub fn to_pathbuf<P: AsRef<Path>>(&self, workspace_path: P) -> PathBuf {
let mut path = workspace_path.as_ref().to_path_buf();
for p in &self.flatten().slices {
let slice = p.to_string();
path = path.join(&slice);
}
path
}
pub fn flatten(&self) -> VaultPath {
let mut slices = vec![];
for slice in &self.slices {
match slice {
VaultPathSlice::PathSlice(_name) => slices.push(slice.clone()),
VaultPathSlice::Up => {
if slices.pop().is_none() {
warn!("Trying to move a directory up from root")
}
}
VaultPathSlice::Current => {}
}
}
VaultPath {
absolute: self.absolute,
slices,
}
}
pub fn get_name(&self) -> String {
self.flatten().slices.last().map_or_else(String::new, |s| {
if let VaultPathSlice::PathSlice(name) = s {
name.to_owned()
} else {
String::new()
}
})
}
pub fn relative_link_from_note(&self, note_path: &VaultPath) -> VaultPath {
let (parent, _) = note_path.flatten().get_parent_path();
self.flatten().get_relative_to(&parent)
}
pub fn resolve_link_in_note(&self, note_path: &VaultPath) -> VaultPath {
if self.is_note_file() {
return self.clone();
}
let (parent, _) = note_path.flatten().get_parent_path();
parent.append(self).flatten().absolute()
}
pub fn get_relative_to(&self, reference_path: &VaultPath) -> VaultPath {
let mut slices = vec![];
let ref_slices = reference_path.slices.clone();
let mut position = 0;
for (pos, slice) in self.slices.iter().enumerate() {
position = pos;
if let Some(reference) = ref_slices.get(pos) {
if !slice.eq(reference) {
break;
}
} else {
break;
}
}
ref_slices.iter().skip(position).for_each(|_| {
slices.push(VaultPathSlice::Up);
});
self.slices.iter().skip(position).for_each(|slice| {
slices.push(slice.to_owned());
});
VaultPath {
absolute: false,
slices,
}
}
pub fn from_path<P: AsRef<Path>, F: AsRef<Path>>(
workspace_path: P,
full_path: F,
) -> Result<Self, FSError> {
let fp = full_path.as_ref();
let relative = fp
.strip_prefix(&workspace_path)
.map_err(|_e| FSError::InvalidPath {
path: path_to_string(&full_path),
message: format!(
"The path provided is not a path belonging to the workspace: {}",
path_to_string(workspace_path)
),
})?;
let mut path_list = vec![PATH_SEPARATOR.to_string()];
relative.components().for_each(|component| {
let os_str = component.as_os_str();
let slice = match os_str.to_str() {
Some(comp) => comp.to_owned(),
None => os_str.to_string_lossy().to_string(),
};
path_list.push(slice);
});
let pl = path_list.join(PATH_SEPARATOR.to_string().as_str());
Ok(VaultPath::new(pl).absolute())
}
pub fn is_note_file(&self) -> bool {
match self.slices.last() {
Some(path_slice) => path_slice.is_note() && self.slices.len() == 1 && !self.absolute,
None => false,
}
}
pub fn is_note(&self) -> bool {
match self.slices.last() {
Some(path_slice) => path_slice.is_note(),
None => false,
}
}
pub fn ensure_note(&self) -> Result<(), FSError> {
if self.is_note() {
Ok(())
} else {
Err(FSError::InvalidPath {
path: self.to_string(),
message: "The path is not a note".to_string(),
})
}
}
pub fn ensure_directory(&self) -> Result<(), FSError> {
if self.is_note() {
Err(FSError::InvalidPath {
path: self.to_string(),
message: "The path is not a directory".to_string(),
})
} else {
Ok(())
}
}
pub fn is_relative(&self) -> bool {
!self.absolute
}
pub fn is_absolute(&self) -> bool {
self.absolute
}
pub fn to_absolute(&mut self) {
self.absolute = true;
}
pub fn absolute(mut self) -> Self {
self.absolute = true;
self
}
pub fn to_relative(&mut self) {
self.absolute = false;
}
pub fn get_parent_path(&self) -> (VaultPath, String) {
let mut new_path = self.slices.clone();
let current = new_path
.pop()
.map_or_else(|| "".to_string(), |s| s.to_string());
(
Self {
absolute: self.absolute,
slices: new_path,
},
current,
)
}
pub fn append(&self, path: &VaultPath) -> VaultPath {
if !path.is_relative() {
path.to_owned()
} else {
let mut slices = self.slices.clone();
let mut other_slices = path.slices.clone();
slices.append(&mut other_slices);
VaultPath {
absolute: self.absolute,
slices,
}
}
}
pub fn is_like(&self, other: &VaultPath) -> bool {
self.slices.eq(&other.slices)
}
}
impl Display for VaultPath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.absolute {
write!(f, "{}", PATH_SEPARATOR)?;
}
write!(
f,
"{}",
self.slices
.iter()
.map(|s| s.to_string())
.collect::<Vec<String>>()
.join(&PATH_SEPARATOR.to_string())
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
enum VaultPathSlice {
PathSlice(String),
Up,
Current,
}
impl VaultPathSlice {
fn new<S: AsRef<str>>(slice: S) -> Self {
let slice = if filename::RX_PATH_NAME.is_match(slice.as_ref()) {
slice.as_ref().replace(".", "_")
} else {
slice.as_ref().to_string()
};
if slice.eq("..") {
VaultPathSlice::Up
} else if slice.eq(".") {
VaultPathSlice::Current
} else {
let sanitized = filename::RX_PATH_CHARS
.replace_all(&slice, "_")
.to_lowercase();
let sanitized = sanitized.trim().trim_end_matches('.').to_string();
let final_slice = if filename::RX_WIN_RESERVED.is_match(&sanitized) {
format!("_{}", sanitized)
} else {
sanitized
};
VaultPathSlice::PathSlice(final_slice)
}
}
fn is_valid<S: AsRef<str>>(slice: S) -> bool {
let slice = slice.as_ref();
if slice == "." || slice == ".." {
return true;
}
!filename::RX_PATH_CHARS.is_match(slice)
&& !filename::RX_PATH_NAME.is_match(slice)
&& !filename::RX_WIN_RESERVED.is_match(slice)
&& !slice.ends_with('.')
&& !slice.starts_with(' ')
&& !slice.ends_with(' ')
}
fn is_note(&self) -> bool {
match self {
VaultPathSlice::PathSlice(name) => name.ends_with(NOTE_EXTENSION),
_ => false,
}
}
}
impl Display for VaultPathSlice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VaultPathSlice::PathSlice(name) => write!(f, "{}", name),
VaultPathSlice::Up => write!(f, ".."),
VaultPathSlice::Current => write!(f, "."),
}
}
}
fn filter_files(dir: &ignore::DirEntry) -> bool {
dir.file_name()
.to_str()
.map(|name| !name.starts_with('.'))
.unwrap_or(true)
}
pub(crate) fn list_directories<P: AsRef<Path>>(
base_path: P,
path: &VaultPath,
recursive: bool,
) -> Result<Vec<super::DirectoryDetails>, FSError> {
let base_path = base_path.as_ref();
let os_path = resolve_path_on_disk_sync(base_path, path);
let walker = WalkBuilder::new(&os_path)
.max_depth(if recursive { None } else { Some(1) })
.filter_entry(filter_files)
.build();
let mut dirs = Vec::new();
for entry in walker.flatten() {
let entry_path = entry.path();
if entry_path.is_dir() && entry_path != os_path {
let vault_path = VaultPath::from_path(base_path, entry_path)?;
dirs.push(super::DirectoryDetails { path: vault_path });
}
}
Ok(dirs)
}
pub(crate) fn get_file_walker<P: AsRef<Path>>(
base_path: P,
path: &VaultPath,
recurse: bool,
) -> WalkParallel {
let w = WalkBuilder::new(resolve_path_on_disk_sync(base_path, path))
.max_depth(if recurse { None } else { Some(1) })
.filter_entry(filter_files)
.build_parallel();
w
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use super::{save_attachment, with_note_extension};
#[test]
fn with_note_extension_appends_when_missing() {
assert_eq!(with_note_extension("projects"), "projects.md");
}
#[test]
fn with_note_extension_keeps_when_present() {
assert_eq!(with_note_extension("projects.md"), "projects.md");
}
#[test]
fn with_note_extension_preserves_wildcards_and_path() {
assert_eq!(with_note_extension("work/proj*"), "work/proj*.md");
}
fn is_case_sensitive_fs(dir: &Path) -> bool {
let upper = dir.join("__CaseSensitivityProbe__");
std::fs::write(&upper, "").unwrap();
let result = !dir.join("__casesensitivityprobe__").exists();
std::fs::remove_file(&upper).unwrap();
result
}
use crate::{
error::FSError,
nfs::{
create_directory, delete_directory, delete_note, rename_directory, rename_note,
save_note, DirectoryEntryData, EntryData, VaultEntry, VaultEntryDetails,
},
utilities::path_to_string,
DirectoryDetails, NoteDetails,
};
use super::{load_note, VaultPath, VaultPathSlice};
#[test]
fn control_chars_are_invalid() {
assert!(!VaultPath::is_valid("note\x01name"));
assert!(!VaultPath::is_valid("dir\x1fname"));
}
#[test]
fn control_chars_are_sanitized_in_new() {
let path = VaultPath::new("note\x07name");
assert_eq!("note_name", path.to_string());
}
#[test]
fn windows_reserved_names_are_invalid() {
for name in &["CON", "PRN", "AUX", "NUL", "COM1", "COM9", "LPT1", "LPT9"] {
assert!(!VaultPath::is_valid(name), "{name} should be invalid");
assert!(
!VaultPath::is_valid(format!("{name}.md")),
"{name}.md should be invalid"
);
}
assert!(!VaultPath::is_valid("con.md"));
assert!(!VaultPath::is_valid("nul"));
}
#[test]
fn windows_reserved_names_are_sanitized_in_new() {
let path = VaultPath::new("con.md");
assert_eq!("_con.md", path.to_string());
let path = VaultPath::new("nul");
assert_eq!("_nul", path.to_string());
let path = VaultPath::new("COM1.md");
assert_eq!("_com1.md", path.to_string());
}
#[test]
fn trailing_dot_is_invalid() {
assert!(!VaultPath::is_valid("notes."));
assert!(!VaultPath::is_valid("dir./sub"));
}
#[test]
fn trailing_dot_is_sanitized_in_new() {
let path = VaultPath::new("notes./sub");
assert_eq!("notes/sub", path.to_string());
}
#[test]
fn leading_or_trailing_spaces_are_invalid() {
assert!(!VaultPath::is_valid(" note"));
assert!(!VaultPath::is_valid("note "));
assert!(!VaultPath::is_valid(" dir /sub"));
}
#[test]
fn leading_and_trailing_spaces_are_sanitized_in_new() {
let path = VaultPath::new(" note ");
assert_eq!("note", path.to_string());
}
#[test]
fn should_print_correctly() {
let path_with_root = "/some/path";
let path_without_root = "another/one";
let path1 = VaultPath::new(path_with_root);
let path2 = VaultPath::new(path_without_root);
assert_eq!("/some/path".to_string(), path1.to_string());
assert_eq!("another/one".to_string(), path2.to_string());
}
#[test]
fn test_valid_path() {
let path = "/some/path.md";
assert!(VaultPath::is_valid(path));
}
#[test]
fn test_rel_path() {
let path = VaultPath::new("../some/path.md");
assert_eq!("../some/path.md", path.to_string());
assert!(path.is_relative());
}
#[test]
fn join_two_paths() {
let path1 = VaultPath::new("main/path");
let path2 = VaultPath::new("sub/path");
let joined = path1.append(&path2);
assert_eq!("main/path/sub/path".to_string(), joined.to_string());
}
#[test]
fn join_two_paths_with_relative() {
let path1 = VaultPath::new("/main/path");
let path2 = VaultPath::new("../sub/path");
let joined = path1.append(&path2).flatten();
assert_eq!("/main/sub/path".to_string(), joined.to_string());
}
#[test]
fn path_with_up_dir_end() {
let path = VaultPath::new("/main/path/..");
assert_eq!("/main".to_string(), path.flatten().to_string());
}
#[test]
fn from_current_path() {
let path = VaultPath::new("./path/subpath");
assert!(!path.flatten().absolute);
assert_eq!("path/subpath", path.flatten().to_string());
}
#[test]
fn only_dots_three_or_more_not_allowed_in_path() {
let path = "/some/.../path";
assert!(!VaultPath::is_valid(path));
let vault_path = VaultPath::new(path);
assert_eq!("/some/___/path", vault_path.to_string());
}
#[test]
fn get_relative_to() {
let path1 = VaultPath::new("/main/path/first");
let path2 = VaultPath::new("/main/second");
let rel = path2.get_relative_to(&path1);
assert_eq!("../../second".to_string(), rel.to_string());
}
#[test]
fn get_relative_to_less_deep() {
let path1 = VaultPath::new("/main/second");
let path2 = VaultPath::new("/main/path/first");
let rel = path2.get_relative_to(&path1);
assert_eq!("../path/first".to_string(), rel.to_string());
}
#[test]
fn get_relative_to_same() {
let path1 = VaultPath::new("/main/second");
let path2 = VaultPath::new("/main/second/sub/deep");
let rel = path2.get_relative_to(&path1);
assert_eq!("sub/deep".to_string(), rel.to_string());
}
#[test]
fn relative_link_from_note_uses_parent_dir() {
let note = VaultPath::new("/notes/journal/today.md");
let asset = VaultPath::new("/assets/img.png");
assert_eq!(
"../../assets/img.png",
asset.relative_link_from_note(¬e).to_string()
);
}
#[test]
fn relative_link_from_root_note_to_assets() {
let note = VaultPath::new("/note.md");
let asset = VaultPath::new("/assets/img.png");
assert_eq!(
"assets/img.png",
asset.relative_link_from_note(¬e).to_string()
);
}
#[test]
fn relative_link_to_sibling_dir() {
let note = VaultPath::new("/notes/today.md");
let asset = VaultPath::new("/notes/assets/img.png");
assert_eq!(
"assets/img.png",
asset.relative_link_from_note(¬e).to_string()
);
}
#[test]
fn resolve_link_in_note_walks_up_and_lowercases() {
let note = VaultPath::new("/journal/2026-03-01.md");
let target = VaultPath::note_path_from("../Work/People/anton.md");
assert_eq!(
"/work/people/anton.md",
target.resolve_link_in_note(¬e).to_string()
);
}
#[test]
fn resolve_link_in_note_keeps_bare_name_for_name_lookup() {
let note = VaultPath::new("/journal/2026-03-01.md");
let target = VaultPath::note_path_from("anton.md");
let resolved = target.resolve_link_in_note(¬e);
assert_eq!("anton.md", resolved.to_string());
assert!(resolved.is_note_file());
}
#[test]
fn resolve_link_in_note_absolute_target_unchanged() {
let note = VaultPath::new("/journal/2026-03-01.md");
let target = VaultPath::note_path_from("/work/people/anton.md");
assert_eq!(
"/work/people/anton.md",
target.resolve_link_in_note(¬e).to_string()
);
}
#[test]
fn resolve_link_in_note_sibling_subdir() {
let note = VaultPath::new("/journal/2026-03-01.md");
let target = VaultPath::note_path_from("attachments/notes.md");
assert_eq!(
"/journal/attachments/notes.md",
target.resolve_link_in_note(¬e).to_string()
);
}
#[test]
fn get_root() {
let vault_path = VaultPath::root();
assert_eq!("/".to_string(), vault_path.to_string());
let root_path = VaultPath::new("/");
assert_eq!(root_path, vault_path);
}
#[test]
fn get_empty() {
let vault_path = VaultPath::empty();
assert_eq!("".to_string(), vault_path.to_string());
let root_path = VaultPath::new("");
assert_eq!(root_path, vault_path);
}
#[test]
fn should_tell_if_its_note() {
let path = "/some/../path.md";
assert!(VaultPath::new(path).is_note());
}
#[test]
fn paths_should_flatten_correctly() {
let path = "some/path/../hola";
assert!(VaultPath::is_valid(path));
let vault_path = VaultPath::from_string(path).unwrap();
let vault_path = vault_path.flatten();
assert_eq!("some/hola".to_string(), vault_path.to_string());
}
#[test]
fn test_file_should_not_look_like_url() {
let valid = VaultPath::is_valid("http://example.com");
assert!(!valid);
}
#[tokio::test]
async fn test_file_not_exists() {
let path = VaultPath::new("don't exist");
let res = load_note(std::env::current_dir().unwrap(), &path).await;
let result = if let Err(e) = res {
matches!(e, FSError::VaultPathNotFound { path: _ })
} else {
false
};
assert!(result);
}
#[test]
fn test_slice_char_replace() {
let slice_str = "Some?unvalid:Chars?";
let slice = VaultPathSlice::new(slice_str);
assert_eq!("some_unvalid_chars_", slice.to_string());
if let VaultPathSlice::PathSlice(name) = slice {
assert_eq!("some_unvalid_chars_", name);
}
}
#[test]
fn test_path_create_from_string() {
let path = "this/is/five/level/path";
let path = VaultPath::new(path);
assert_eq!(5, path.slices.len());
assert_eq!("this", path.slices[0].to_string());
assert_eq!("is", path.slices[1].to_string());
assert_eq!("five", path.slices[2].to_string());
assert_eq!("level", path.slices[3].to_string());
assert_eq!("path", path.slices[4].to_string());
}
#[test]
fn test_path_with_unvalid_chars() {
let path = "t*his/i+s/caca?/";
let path = VaultPath::new(path);
assert_eq!(3, path.slices.len());
assert_eq!("t_his", path.slices[0].to_string());
assert_eq!("i+s", path.slices[1].to_string());
assert_eq!("caca_", path.slices[2].to_string());
}
#[test]
fn test_to_path_buf() {
let workspace_path = PathBuf::from("workspace");
let sep = std::path::MAIN_SEPARATOR_STR;
let path = "/some/subpath";
let path = VaultPath::new(path);
let path_buf = path.to_pathbuf(&workspace_path);
let path_string = path_to_string(path_buf);
let expected_path_str = format!("workspace{sep}some{sep}subpath");
assert_eq!(expected_path_str, path_string);
}
#[test]
fn test_path_check_valid() {
let path = PathBuf::from("/some/valid/path/workspace/note.md");
let workspace = PathBuf::from("/some/valid/path");
let entry = VaultPath::from_path(&workspace, &path).unwrap();
assert_eq!("/workspace/note.md", entry.to_string());
}
#[tokio::test]
async fn create_a_note() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let workspace_path = temp_dir.path();
let note_path = VaultPath::new("note.md");
let note_text = "this is an empty note".to_string();
let res = save_note(workspace_path, ¬e_path, ¬e_text).await;
if let Err(e) = &res {
panic!("Error saving note: {e}")
}
let note = load_note(workspace_path, ¬e_path).await;
if let Err(e) = ¬e {
panic!("Error loading note: {e}")
}
assert_eq!(note.unwrap(), note_text);
let del_res = delete_note(workspace_path, ¬e_path).await;
if let Err(e) = &del_res {
panic!("Error deleting note: {e}")
}
assert!(load_note(workspace_path, ¬e_path).await.is_err());
}
#[tokio::test]
async fn move_a_note() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let workspace_path = temp_dir.path();
let note_path = VaultPath::new("note.md");
let dest_note_path = VaultPath::new("directory/moved_note.md");
let note_text = "this is an empty note".to_string();
let res = save_note(workspace_path, ¬e_path, ¬e_text).await;
if let Err(e) = &res {
panic!("Error saving note: {e}")
}
let note = load_note(workspace_path, ¬e_path).await;
if let Err(e) = ¬e {
panic!("Error loading note: {e}")
}
assert_eq!(note.as_ref().unwrap().to_owned(), note_text);
let ren_res = rename_note(workspace_path, ¬e_path, &dest_note_path).await;
if let Err(e) = &ren_res {
panic!("Error renaming note: {e}")
}
let moved_note = load_note(workspace_path, &dest_note_path).await;
if let Err(e) = &moved_note {
panic!("Error loading note: {e}")
}
assert_eq!(note.unwrap(), moved_note.unwrap());
assert!(load_note(workspace_path, ¬e_path).await.is_err());
let del_res = delete_note(workspace_path, &dest_note_path).await;
if let Err(e) = &del_res {
panic!("Error deleting note: {e}")
}
assert!(load_note(workspace_path, &dest_note_path).await.is_err());
let del_res = delete_directory(workspace_path, &dest_note_path.get_parent_path().0).await;
if let Err(e) = &del_res {
panic!("Error deleting directory: {e}")
}
}
#[tokio::test]
async fn move_a_directory() -> Result<(), FSError> {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let workspace_path = temp_dir.path();
let from_note_dir = VaultPath::new("old_dir");
let from_note_path = from_note_dir.append(&VaultPath::new("note.md"));
let dest_note_dir = VaultPath::new("new_dir/two_levels");
let dest_note_path = dest_note_dir.append(&VaultPath::new("note.md"));
let note_text = "this is an empty note".to_string();
save_note(workspace_path, &from_note_path, ¬e_text).await?;
let note = load_note(workspace_path, &from_note_path).await?;
assert_eq!(note, note_text);
rename_directory(workspace_path, &from_note_dir, &dest_note_dir).await?;
let moved_note = load_note(workspace_path, &dest_note_path).await?;
assert_eq!(note, moved_note);
assert!(load_note(workspace_path, &from_note_dir).await.is_err());
delete_note(workspace_path, &dest_note_path).await?;
assert!(load_note(workspace_path, &dest_note_path).await.is_err());
let first_level = dest_note_path.get_parent_path().0;
let second_level = first_level.get_parent_path().0;
delete_directory(workspace_path, &first_level).await?;
delete_directory(workspace_path, &second_level).await?;
Ok(())
}
#[tokio::test]
async fn test_vault_entry_new_with_directory() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let workspace_path = temp_dir.path();
let dir_path = VaultPath::new("test_directory");
tokio::fs::create_dir_all(workspace_path.join("test_directory"))
.await
.ok();
let result = VaultEntry::new(workspace_path, dir_path.clone()).await;
assert!(result.is_ok());
let entry = result.unwrap();
assert_eq!(entry.path, dir_path);
assert_eq!(entry.path_string, dir_path.to_string());
match entry.data {
EntryData::Directory(dir_data) => {
assert_eq!(dir_data.path, dir_path);
}
_ => panic!("Expected Directory entry data"),
}
tokio::fs::remove_dir_all(workspace_path.join("test_directory"))
.await
.ok();
}
#[tokio::test]
async fn test_vault_entry_new_with_note() {
let workspace_path = Path::new("testdata");
let note_path = VaultPath::new("test_note.md");
let note_content = "# Test Note\n\nThis is a test.";
save_note(workspace_path, ¬e_path, note_content)
.await
.unwrap();
let result = VaultEntry::new(workspace_path, note_path.clone()).await;
assert!(result.is_ok());
let entry = result.unwrap();
assert_eq!(entry.path, note_path);
match entry.data {
EntryData::Note(note_data) => {
assert_eq!(note_data.path, note_path);
assert!(note_data.size > 0);
assert!(note_data.modified_secs > 0);
}
_ => panic!("Expected Note entry data"),
}
delete_note(workspace_path, ¬e_path).await.ok();
}
#[tokio::test]
async fn test_vault_entry_new_with_attachment() {
let workspace_path = Path::new("testdata");
let attachment_path = VaultPath::new("test.txt");
tokio::fs::create_dir_all(workspace_path).await.ok();
tokio::fs::write(workspace_path.join("test.txt"), "test content")
.await
.unwrap();
let result = VaultEntry::new(workspace_path, attachment_path.clone()).await;
assert!(result.is_ok());
let entry = result.unwrap();
match entry.data {
EntryData::Attachment => (),
_ => panic!("Expected Attachment entry data"),
}
tokio::fs::remove_file(workspace_path.join("test.txt"))
.await
.ok();
}
#[tokio::test]
async fn test_vault_entry_new_with_nonexistent_path() {
let workspace_path = Path::new("testdata");
let nonexistent_path = VaultPath::new("does_not_exist.md");
let result = VaultEntry::new(workspace_path, nonexistent_path).await;
assert!(result.is_err());
match result.unwrap_err() {
FSError::NoFileOrDirectoryFound { .. } => (),
_ => panic!("Expected NoFileOrDirectoryFound error"),
}
}
#[tokio::test]
async fn test_vault_entry_from_path() {
let workspace_path = Path::new("testdata");
let note_path = VaultPath::new("from_path_test.md");
let note_content = "Test content";
save_note(workspace_path, ¬e_path, note_content)
.await
.unwrap();
let full_path = workspace_path.join("from_path_test.md");
let result = VaultEntry::from_path(workspace_path, &full_path).await;
assert!(result.is_ok());
let entry = result.unwrap();
assert_eq!(entry.path, note_path.clone().absolute());
delete_note(workspace_path, ¬e_path).await.ok();
}
#[tokio::test]
async fn test_vault_entry_display() {
let workspace_path = Path::new("testdata");
let note_path = VaultPath::new("display_test.md");
let dir_path = VaultPath::new("display_dir");
let attachment_path = VaultPath::new("display.txt");
save_note(workspace_path, ¬e_path, "content")
.await
.unwrap();
let note_entry = VaultEntry::new(workspace_path, note_path.clone())
.await
.unwrap();
let note_display = format!("{}", note_entry);
assert!(note_display.contains("[NOT]"));
assert!(note_display.contains(¬e_path.to_string()));
tokio::fs::create_dir_all(workspace_path.join("display_dir"))
.await
.ok();
let dir_entry = VaultEntry::new(workspace_path, dir_path.clone())
.await
.unwrap();
let dir_display = format!("{}", dir_entry);
assert!(dir_display.contains("[DIR]"));
assert!(dir_display.contains(&dir_path.to_string()));
tokio::fs::write(workspace_path.join("display.txt"), "content")
.await
.ok();
let attachment_entry = VaultEntry::new(workspace_path, attachment_path.clone())
.await
.unwrap();
let attachment_display = format!("{}", attachment_entry);
assert!(attachment_display.contains("[ATT]"));
delete_note(workspace_path, ¬e_path).await.ok();
tokio::fs::remove_dir_all(workspace_path.join("display_dir"))
.await
.ok();
tokio::fs::remove_file(workspace_path.join("display.txt"))
.await
.ok();
}
#[tokio::test]
async fn test_note_entry_data_load_details() {
let workspace_path = Path::new("testdata");
let note_path = VaultPath::new("details_test.md");
let note_content = "# Test\n\nContent here";
save_note(workspace_path, ¬e_path, note_content)
.await
.unwrap();
let entry = VaultEntry::new(workspace_path, note_path.clone())
.await
.unwrap();
if let EntryData::Note(note_data) = entry.data {
let details_result = note_data.load_details(workspace_path, ¬e_path).await;
assert!(details_result.is_ok());
let details = details_result.unwrap();
assert_eq!(details.path, note_path);
assert_eq!(details.raw_text, note_content);
} else {
panic!("Expected Note entry data");
}
delete_note(workspace_path, ¬e_path).await.ok();
}
#[test]
fn test_directory_entry_data_get_details() {
let dir_path = VaultPath::new("test_dir");
let dir_data = DirectoryEntryData {
path: dir_path.clone(),
};
let details = dir_data.get_details::<PathBuf>();
assert_eq!(details.path, dir_path);
}
#[test]
fn test_vault_entry_details_get_title() {
let note_path = VaultPath::new("test.md");
let note_content = "# My Title\n\nContent";
let note_details = NoteDetails::new(¬e_path, note_content);
let mut note_entry_details = VaultEntryDetails::Note(note_details);
let title = note_entry_details.get_title();
assert_eq!(title, "My Title");
let dir_path = VaultPath::new("test_dir");
let dir_details = DirectoryDetails { path: dir_path };
let mut dir_entry_details = VaultEntryDetails::Directory(dir_details);
let dir_title = dir_entry_details.get_title();
assert_eq!(dir_title, "");
let mut none_details = VaultEntryDetails::None;
let none_title = none_details.get_title();
assert_eq!(none_title, "");
}
#[test]
fn test_hash_text() {
use super::hash_text;
let text1 = "Hello, world!";
let text2 = "Hello, world!";
let text3 = "Different text";
let hash1 = hash_text(text1);
let hash2 = hash_text(text2);
let hash3 = hash_text(text3);
assert_eq!(hash1, hash2);
assert_ne!(hash1, hash3);
assert!(hash1 > 0);
}
#[tokio::test]
async fn test_create_directory_with_note_path() {
let workspace_path = Path::new("testdata");
let note_path = VaultPath::new("invalid.md");
let result = create_directory(workspace_path, ¬e_path).await;
assert!(result.is_err());
match result.unwrap_err() {
FSError::InvalidPath { message, .. } => {
assert_eq!(message, "The path is not a directory");
}
_ => panic!("Expected InvalidPath error"),
}
}
#[tokio::test]
async fn save_attachment_writes_bytes_and_creates_parent_dirs() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let workspace = temp_dir.path();
let path = VaultPath::new("/assets/img.png");
let bytes = b"\x89PNG\r\n\x1a\n stub".to_vec();
save_attachment(workspace, &path, &bytes).await.unwrap();
let on_disk = workspace.join("assets").join("img.png");
let read_back = tokio::fs::read(&on_disk).await.unwrap();
assert_eq!(read_back, bytes);
}
#[tokio::test]
async fn test_save_note_with_directory_path() {
let workspace_path = Path::new("testdata");
let dir_path = VaultPath::new("directory");
let content = "test content";
let result = save_note(workspace_path, &dir_path, content).await;
assert!(result.is_err());
match result.unwrap_err() {
FSError::InvalidPath { message, .. } => {
assert_eq!(message, "The path is not a note");
}
_ => panic!("Expected InvalidPath error"),
}
}
#[tokio::test]
async fn test_rename_note_with_invalid_paths() {
let workspace_path = Path::new("testdata");
let dir_path = VaultPath::new("directory");
let note_path = VaultPath::new("note.md");
let result = rename_note(workspace_path, &dir_path, ¬e_path).await;
assert!(result.is_err());
let result = rename_note(workspace_path, ¬e_path, &dir_path).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_rename_directory_with_invalid_paths() {
let workspace_path = Path::new("testdata");
let dir_path = VaultPath::new("directory");
let note_path = VaultPath::new("note.md");
let result = rename_directory(workspace_path, ¬e_path, &dir_path).await;
assert!(result.is_err());
let result = rename_directory(workspace_path, &dir_path, ¬e_path).await;
assert!(result.is_err());
}
#[test]
fn test_vault_path_serialization() {
use serde_json;
let path = VaultPath::new("/test/path.md");
let serialized = serde_json::to_string(&path).unwrap();
assert_eq!(serialized, "\"/test/path.md\"");
let deserialized: VaultPath = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized, path);
}
#[test]
fn test_vault_path_try_from() {
let path_str = "/valid/path.md";
let path_result: Result<VaultPath, FSError> = path_str.try_into();
assert!(path_result.is_ok());
let invalid_path_str = "/invalid:path.md";
let invalid_result: Result<VaultPath, FSError> = invalid_path_str.try_into();
assert!(invalid_result.is_err());
}
#[test]
fn test_vault_path_from_str() {
use std::str::FromStr;
let path_str = "/test/path.md";
let path = VaultPath::from_str(path_str).unwrap();
assert_eq!(path.to_string(), path_str);
let invalid_str = "/invalid:path.md";
let result = VaultPath::from_str(invalid_str);
assert!(result.is_err());
}
#[test]
fn test_vault_path_note_path_from() {
let path_without_extension = "test/note";
let path_with_extension = "test/note.md";
let path_with_trailing_slash = "test/note/";
let note_path1 = VaultPath::note_path_from(path_without_extension);
let note_path2 = VaultPath::note_path_from(path_with_extension);
let note_path3 = VaultPath::note_path_from(path_with_trailing_slash);
assert_eq!(note_path1.to_string(), "test/note.md");
assert_eq!(note_path2.to_string(), "test/note.md");
assert_eq!(note_path3.to_string(), "test/note.md");
assert!(note_path1.is_note());
assert!(note_path2.is_note());
assert!(note_path3.is_note());
}
#[test]
fn test_vault_path_get_name_on_conflict() {
let note_path = VaultPath::new("test.md");
let conflicted = note_path.get_name_on_conflict();
assert_eq!(conflicted.to_string(), "test_0.md");
let numbered_path = VaultPath::new("test_5.md");
let conflicted_numbered = numbered_path.get_name_on_conflict();
assert_eq!(conflicted_numbered.to_string(), "test_6.md");
let dir_path = VaultPath::new("directory");
let conflicted_dir = dir_path.get_name_on_conflict();
assert_eq!(conflicted_dir.to_string(), "directory_0");
let empty_path = VaultPath::empty();
let conflicted_empty = empty_path.get_name_on_conflict();
assert_eq!(conflicted_empty.to_string(), "0");
}
#[test]
fn test_vault_path_get_clean_name() {
let note_path = VaultPath::new("/path/to/note.md");
assert_eq!(note_path.get_clean_name(), "note");
let dir_path = VaultPath::new("/path/to/directory");
assert_eq!(dir_path.get_clean_name(), "directory");
let root_path = VaultPath::root();
assert_eq!(root_path.get_clean_name(), "");
}
#[test]
fn test_vault_path_get_slices() {
let path = VaultPath::new("/path/to/../file.md");
let slices = path.get_slices();
assert_eq!(slices, vec!["path", "file.md"]);
}
#[test]
fn test_vault_path_is_like() {
let path1 = VaultPath::new("/test/path.md");
let path2 = VaultPath::new("test/path.md"); let path3 = VaultPath::new("/different/path.md");
assert!(path1.is_like(&path2));
assert!(!path1.is_like(&path3));
}
#[test]
fn test_vault_path_slice_edge_cases() {
let path_with_dots = VaultPath::new("...invalid");
assert_eq!(path_with_dots.to_string(), "___invalid");
let path_with_invalid = VaultPath::new("test:file?.md");
assert_eq!(path_with_invalid.to_string(), "test_file_.md");
let path_with_current = VaultPath::new("./test");
assert_eq!(path_with_current.flatten().to_string(), "test");
let path_with_parent = VaultPath::new("../test");
assert_eq!(path_with_parent.to_string(), "../test");
}
#[test]
fn test_vault_path_increment_function() {
use super::VaultPath;
let base_name = VaultPath::new("test");
let incremented = base_name.get_name_on_conflict();
assert_eq!(incremented.to_string(), "test_0");
let numbered_name = VaultPath::new("test_3");
let incremented_numbered = numbered_name.get_name_on_conflict();
assert_eq!(incremented_numbered.to_string(), "test_4");
}
#[test]
fn vault_path_normalizes_to_lowercase() {
let a = VaultPath::new("/Projects/Note.md");
let b = VaultPath::new("/projects/note.md");
assert_eq!(a, b);
assert_eq!(a.to_string(), "/projects/note.md");
}
#[tokio::test]
async fn resolve_finds_uppercase_directory() {
let tmp = tempfile::TempDir::new().unwrap();
tokio::fs::create_dir(tmp.path().join("Journal"))
.await
.unwrap();
let result = super::resolve_path_on_disk(tmp.path(), &VaultPath::new("/journal")).await;
assert_eq!(result, tmp.path().join("Journal"));
}
#[tokio::test]
async fn resolve_finds_uppercase_file() {
let tmp = tempfile::TempDir::new().unwrap();
tokio::fs::create_dir(tmp.path().join("Projects"))
.await
.unwrap();
tokio::fs::write(tmp.path().join("Projects").join("MyNote.md"), "hi")
.await
.unwrap();
let result =
super::resolve_path_on_disk(tmp.path(), &VaultPath::new("/projects/mynote.md")).await;
assert_eq!(result, tmp.path().join("Projects").join("MyNote.md"));
}
#[tokio::test]
async fn resolve_uses_lowercase_for_nonexistent_path() {
let tmp = tempfile::TempDir::new().unwrap();
let result =
super::resolve_path_on_disk(tmp.path(), &VaultPath::new("/newdir/note.md")).await;
assert_eq!(result, tmp.path().join("newdir").join("note.md"));
}
#[test]
fn resolve_sync_finds_uppercase_directory() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join("Archive")).unwrap();
let result = super::resolve_path_on_disk_sync(tmp.path(), &VaultPath::new("/archive"));
assert_eq!(result, tmp.path().join("Archive"));
}
#[tokio::test]
async fn load_note_finds_uppercase_file() {
let tmp = tempfile::TempDir::new().unwrap();
tokio::fs::create_dir(tmp.path().join("Journal"))
.await
.unwrap();
tokio::fs::write(tmp.path().join("Journal").join("MyNote.md"), "# Hello")
.await
.unwrap();
let text = super::load_note(tmp.path(), &VaultPath::new("/journal/mynote.md"))
.await
.unwrap();
assert_eq!(text, "# Hello");
}
#[tokio::test]
async fn save_note_writes_to_existing_uppercase_file() {
let tmp = tempfile::TempDir::new().unwrap();
tokio::fs::create_dir(tmp.path().join("Journal"))
.await
.unwrap();
tokio::fs::write(tmp.path().join("Journal").join("MyNote.md"), "original")
.await
.unwrap();
save_note(tmp.path(), &VaultPath::new("/journal/mynote.md"), "updated")
.await
.unwrap();
let content = tokio::fs::read_to_string(tmp.path().join("Journal").join("MyNote.md"))
.await
.unwrap();
assert_eq!(content, "updated");
if is_case_sensitive_fs(tmp.path()) {
assert!(!tmp.path().join("Journal").join("mynote.md").exists());
assert!(!tmp.path().join("journal").exists());
}
}
#[tokio::test]
async fn save_note_in_uppercase_parent_directory() {
let tmp = tempfile::TempDir::new().unwrap();
tokio::fs::create_dir(tmp.path().join("Projects"))
.await
.unwrap();
save_note(tmp.path(), &VaultPath::new("/projects/new.md"), "content")
.await
.unwrap();
assert!(tmp.path().join("Projects").join("new.md").exists());
if is_case_sensitive_fs(tmp.path()) {
assert!(!tmp.path().join("projects").exists());
}
}
#[tokio::test]
async fn delete_note_removes_uppercase_file() {
let tmp = tempfile::TempDir::new().unwrap();
tokio::fs::create_dir(tmp.path().join("Journal"))
.await
.unwrap();
let file = tmp.path().join("Journal").join("MyNote.md");
tokio::fs::write(&file, "bye").await.unwrap();
delete_note(tmp.path(), &VaultPath::new("/journal/mynote.md"))
.await
.unwrap();
assert!(!file.exists());
}
#[tokio::test]
async fn delete_directory_removes_uppercase_directory() {
let tmp = tempfile::TempDir::new().unwrap();
tokio::fs::create_dir(tmp.path().join("Archive"))
.await
.unwrap();
tokio::fs::write(tmp.path().join("Archive").join("note.md"), "x")
.await
.unwrap();
delete_directory(tmp.path(), &VaultPath::new("/archive"))
.await
.unwrap();
assert!(!tmp.path().join("Archive").exists());
}
#[tokio::test]
async fn rename_note_finds_uppercase_source() {
let tmp = tempfile::TempDir::new().unwrap();
tokio::fs::create_dir(tmp.path().join("Projects"))
.await
.unwrap();
tokio::fs::write(tmp.path().join("Projects").join("MyNote.md"), "data")
.await
.unwrap();
rename_note(
tmp.path(),
&VaultPath::new("/projects/mynote.md"),
&VaultPath::new("/projects/renamed.md"),
)
.await
.unwrap();
assert!(tmp.path().join("Projects").join("renamed.md").exists());
assert!(!tmp.path().join("Projects").join("MyNote.md").exists());
}
#[tokio::test]
async fn rename_note_into_uppercase_parent() {
let tmp = tempfile::TempDir::new().unwrap();
tokio::fs::create_dir(tmp.path().join("Inbox"))
.await
.unwrap();
tokio::fs::write(tmp.path().join("Inbox").join("note.md"), "data")
.await
.unwrap();
tokio::fs::create_dir(tmp.path().join("Archive"))
.await
.unwrap();
rename_note(
tmp.path(),
&VaultPath::new("/inbox/note.md"),
&VaultPath::new("/archive/note.md"),
)
.await
.unwrap();
assert!(tmp.path().join("Archive").join("note.md").exists());
if is_case_sensitive_fs(tmp.path()) {
assert!(!tmp.path().join("archive").exists());
}
}
#[tokio::test]
async fn rename_directory_finds_uppercase_source() {
let tmp = tempfile::TempDir::new().unwrap();
tokio::fs::create_dir(tmp.path().join("OldName"))
.await
.unwrap();
rename_directory(
tmp.path(),
&VaultPath::new("/oldname"),
&VaultPath::new("/newname"),
)
.await
.unwrap();
assert!(tmp.path().join("newname").exists());
assert!(!tmp.path().join("OldName").exists());
}
#[tokio::test]
async fn vault_entry_from_path_uses_lowercase_vault_path() {
let tmp = tempfile::TempDir::new().unwrap();
tokio::fs::create_dir(tmp.path().join("Projects"))
.await
.unwrap();
tokio::fs::write(tmp.path().join("Projects").join("MyNote.md"), "# Title")
.await
.unwrap();
let entry =
VaultEntry::from_path(tmp.path(), tmp.path().join("Projects").join("MyNote.md"))
.await
.unwrap();
assert_eq!(entry.path.to_string(), "/projects/mynote.md");
assert!(matches!(entry.data, EntryData::Note(_)));
}
#[tokio::test]
async fn vault_entry_new_finds_uppercase_file() {
let tmp = tempfile::TempDir::new().unwrap();
tokio::fs::create_dir(tmp.path().join("Projects"))
.await
.unwrap();
tokio::fs::write(tmp.path().join("Projects").join("MyNote.md"), "# Title")
.await
.unwrap();
let entry = VaultEntry::new(tmp.path(), VaultPath::new("/projects/mynote.md"))
.await
.unwrap();
assert_eq!(entry.path.to_string(), "/projects/mynote.md");
assert!(matches!(entry.data, EntryData::Note(_)));
}
}