use std::path::PathBuf;
use crate::error::{Result, VaultdbError};
use crate::query::Expr;
use crate::record::Value;
use crate::vault::Vault;
use crate::writer::{self, WriteResult};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MutationReport {
pub changes: Vec<PlannedChange>,
pub errors: Vec<MutationError>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PlannedChange {
pub path: PathBuf,
pub description: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MutationError {
pub path: PathBuf,
pub message: String,
}
#[derive(Debug, Clone)]
pub struct UpdateBuilder {
filter: Expr,
folder: String,
set_fields: Vec<(String, Value)>,
unset_fields: Vec<String>,
add_tags: Vec<String>,
remove_tags: Vec<String>,
write_options: writer::WriteOptions,
}
impl UpdateBuilder {
pub fn new(folder: impl Into<String>, filter: Expr) -> Self {
Self {
filter,
folder: folder.into(),
set_fields: Vec::new(),
unset_fields: Vec::new(),
add_tags: Vec::new(),
remove_tags: Vec::new(),
write_options: writer::WriteOptions::default(),
}
}
pub fn set(mut self, field: impl Into<String>, value: Value) -> Self {
self.set_fields.push((field.into(), value));
self
}
pub fn unset(mut self, field: impl Into<String>) -> Self {
self.unset_fields.push(field.into());
self
}
pub fn add_tag(mut self, tag: impl Into<String>) -> Self {
self.add_tags.push(tag.into());
self
}
pub fn remove_tag(mut self, tag: impl Into<String>) -> Self {
self.remove_tags.push(tag.into());
self
}
pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
self.write_options = opts;
self
}
pub fn fsync(mut self, yes: bool) -> Self {
self.write_options.fsync = yes;
self
}
pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
let (report, _writes) = self.compute(vault)?;
Ok(report)
}
pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
crate::lock::with_lock(&vault.root, || {
let (report, writes) = self.compute(vault)?;
for w in &writes {
writer::apply_with(w, self.write_options).map_err(VaultdbError::Io)?;
}
Ok(report)
})
}
fn compute(&self, vault: &Vault) -> Result<(MutationReport, Vec<WriteResult>)> {
let folder_path = vault.resolve_folder(&self.folder)?;
let load = vault.load_records_with_content(&folder_path, false, false)?;
let needs_links = crate::filter::expr_uses_links(&self.filter);
let link_index = if needs_links {
Some(crate::links::LinkGraph::build_with_root(
&load.records,
Some(&vault.root),
))
} else {
None
};
let mut changes = Vec::new();
let mut errors = Vec::new();
let mut writes = Vec::new();
for record in &load.records {
if !crate::filter::evaluate_expr(&self.filter, record, &vault.root, link_index.as_ref())
{
continue;
}
let mut content = match &record.raw_content {
Some(c) => c.clone(),
None => {
errors.push(MutationError {
path: record.path.clone(),
message: "record has no raw_content; cannot apply update".into(),
});
continue;
}
};
let original_content = content.clone();
let mut wr_changes = Vec::new();
let mut description_parts: Vec<String> = Vec::new();
let result: Result<()> = (|| {
for (field, value) in &self.set_fields {
let value_str = render_value_for_yaml(value);
let (new_content, change) = writer::set_field(&content, field, &value_str)?;
description_parts.push(format!("{}", change));
wr_changes.push(change);
content = new_content;
}
for field in &self.unset_fields {
let (new_content, change) = writer::unset_field(&content, field)?;
description_parts.push(format!("{}", change));
wr_changes.push(change);
content = new_content;
}
for tag in &self.add_tags {
let (new_content, change) = writer::add_tag(&content, tag)?;
description_parts.push(format!("{}", change));
wr_changes.push(change);
content = new_content;
}
for tag in &self.remove_tags {
let (new_content, change) = writer::remove_tag(&content, tag)?;
description_parts.push(format!("{}", change));
wr_changes.push(change);
content = new_content;
}
Ok(())
})();
match result {
Ok(_) => {
if !wr_changes.is_empty() {
writes.push(WriteResult {
path: record.path.clone(),
original_content,
modified_content: content,
changes: wr_changes,
});
changes.push(PlannedChange {
path: record.path.clone(),
description: description_parts.join("; "),
});
}
}
Err(e) => errors.push(MutationError {
path: record.path.clone(),
message: e.to_string(),
}),
}
}
Ok((MutationReport { changes, errors }, writes))
}
}
#[derive(Debug, Clone)]
pub struct DeleteBuilder {
filter: Expr,
folder: String,
permanent: bool,
write_options: writer::WriteOptions,
}
impl DeleteBuilder {
pub fn new(folder: impl Into<String>, filter: Expr) -> Self {
Self {
filter,
folder: folder.into(),
permanent: false,
write_options: writer::WriteOptions::default(),
}
}
pub fn permanent(mut self, yes: bool) -> Self {
self.permanent = yes;
self
}
pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
self.write_options = opts;
self
}
pub fn fsync(mut self, yes: bool) -> Self {
self.write_options.fsync = yes;
self
}
pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
let folder_path = vault.resolve_folder(&self.folder)?;
let load = vault.load_records(&folder_path, false, false)?;
let needs_links = crate::filter::expr_uses_links(&self.filter);
let link_index = if needs_links {
Some(crate::links::LinkGraph::build_with_root(
&load.records,
Some(&vault.root),
))
} else {
None
};
let mut changes = Vec::new();
for r in &load.records {
if !crate::filter::evaluate_expr(&self.filter, r, &vault.root, link_index.as_ref()) {
continue;
}
changes.push(PlannedChange {
path: r.path.clone(),
description: if self.permanent {
"delete (permanent)".to_string()
} else {
"move to .trash/".to_string()
},
});
}
Ok(MutationReport {
changes,
errors: Vec::new(),
})
}
pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
crate::lock::with_lock(&vault.root, || {
let report = self.plan(vault)?;
let mut errors = Vec::new();
let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
std::collections::BTreeSet::new();
if self.permanent {
for change in &report.changes {
if let Err(e) = std::fs::remove_file(&change.path) {
errors.push(MutationError {
path: change.path.clone(),
message: format!("remove failed: {}", e),
});
} else if let Some(parent) = change.path.parent() {
dirs_to_fsync.insert(parent.to_path_buf());
}
}
} else {
let trash_dir = vault.root.join(".trash");
if !report.changes.is_empty() {
std::fs::create_dir_all(&trash_dir).map_err(VaultdbError::Io)?;
}
for change in &report.changes {
let dest = unique_in_dir(&trash_dir, &change.path);
if let Err(e) = std::fs::rename(&change.path, &dest) {
errors.push(MutationError {
path: change.path.clone(),
message: format!("trash failed: {}", e),
});
} else {
if let Some(parent) = change.path.parent() {
dirs_to_fsync.insert(parent.to_path_buf());
}
dirs_to_fsync.insert(trash_dir.clone());
}
}
}
if self.write_options.fsync {
for d in &dirs_to_fsync {
if let Err(e) = writer::fsync_dir(d) {
errors.push(MutationError {
path: d.clone(),
message: format!("fsync_dir failed: {}", e),
});
}
}
}
Ok(MutationReport {
changes: report.changes,
errors,
})
})
}
}
fn unique_in_dir(dir: &std::path::Path, src: &std::path::Path) -> PathBuf {
let filename = src.file_name().and_then(|n| n.to_str()).unwrap_or("file");
let candidate = dir.join(filename);
if !candidate.exists() {
return candidate;
}
let stem = src.file_stem().and_then(|n| n.to_str()).unwrap_or("file");
let ext = src.extension().and_then(|n| n.to_str()).unwrap_or("md");
let mut i = 1;
loop {
let c = dir.join(format!("{}-{}.{}", stem, i, ext));
if !c.exists() {
return c;
}
i += 1;
}
}
#[derive(Debug, Clone)]
pub struct MoveBuilder {
filter: Expr,
folder: String,
to_folder: String,
write_options: writer::WriteOptions,
}
impl MoveBuilder {
pub fn new(folder: impl Into<String>, to_folder: impl Into<String>, filter: Expr) -> Self {
Self {
filter,
folder: folder.into(),
to_folder: to_folder.into(),
write_options: writer::WriteOptions::default(),
}
}
pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
self.write_options = opts;
self
}
pub fn fsync(mut self, yes: bool) -> Self {
self.write_options.fsync = yes;
self
}
pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
let folder_path = vault.resolve_folder(&self.folder)?;
let to_path = vault.root.join(&self.to_folder);
let load = vault.load_records(&folder_path, false, false)?;
let needs_links = crate::filter::expr_uses_links(&self.filter);
let link_index = if needs_links {
Some(crate::links::LinkGraph::build_with_root(
&load.records,
Some(&vault.root),
))
} else {
None
};
let mut changes = Vec::new();
let mut errors = Vec::new();
for r in &load.records {
if !crate::filter::evaluate_expr(&self.filter, r, &vault.root, link_index.as_ref()) {
continue;
}
let filename = match r.path.file_name() {
Some(n) => n,
None => continue,
};
let dest = to_path.join(filename);
if dest.exists() {
errors.push(MutationError {
path: r.path.clone(),
message: format!(
"move conflict: {} already exists in {}",
filename.to_string_lossy(),
self.to_folder
),
});
continue;
}
changes.push(PlannedChange {
path: r.path.clone(),
description: format!("move to {}", dest.display()),
});
}
Ok(MutationReport { changes, errors })
}
pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
crate::lock::with_lock(&vault.root, || {
let to_path = vault.root.join(&self.to_folder);
let report = self.plan(vault)?;
if !report.changes.is_empty() {
std::fs::create_dir_all(&to_path).map_err(VaultdbError::Io)?;
}
let mut errors = report.errors;
let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
std::collections::BTreeSet::new();
for change in &report.changes {
let filename = match change.path.file_name() {
Some(n) => n,
None => continue,
};
let dest = to_path.join(filename);
if let Err(e) = std::fs::rename(&change.path, &dest) {
errors.push(MutationError {
path: change.path.clone(),
message: format!("rename failed: {}", e),
});
} else {
if let Some(parent) = change.path.parent() {
dirs_to_fsync.insert(parent.to_path_buf());
}
dirs_to_fsync.insert(to_path.clone());
}
}
if self.write_options.fsync {
for d in &dirs_to_fsync {
if let Err(e) = writer::fsync_dir(d) {
errors.push(MutationError {
path: d.clone(),
message: format!("fsync_dir failed: {}", e),
});
}
}
}
Ok(MutationReport {
changes: report.changes,
errors,
})
})
}
}
#[derive(Debug, Clone)]
pub struct RenameBuilder {
folder: String,
from: String,
to: String,
write_options: writer::WriteOptions,
}
impl RenameBuilder {
pub fn new(folder: impl Into<String>, from: impl Into<String>, to: impl Into<String>) -> Self {
Self {
folder: folder.into(),
from: from.into(),
to: to.into(),
write_options: writer::WriteOptions::default(),
}
}
pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
self.write_options = opts;
self
}
pub fn fsync(mut self, yes: bool) -> Self {
self.write_options.fsync = yes;
self
}
pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
let folder_path = vault.resolve_folder(&self.folder)?;
let source = folder_path.join(format!("{}.md", self.from));
let dest = folder_path.join(format!("{}.md", self.to));
let mut changes = Vec::new();
let mut errors = Vec::new();
if !source.is_file() {
errors.push(MutationError {
path: source.clone(),
message: format!("source `{}` not found", self.from),
});
return Ok(MutationReport { changes, errors });
}
if dest.exists() {
errors.push(MutationError {
path: dest.clone(),
message: format!("target `{}.md` already exists", self.to),
});
return Ok(MutationReport { changes, errors });
}
changes.push(PlannedChange {
path: source.clone(),
description: format!("rename to {}", dest.display()),
});
let all = vault.load_records_with_content(&vault.root, true, false)?;
let graph = crate::links::LinkGraph::build_with_root(&all.records, Some(&vault.root));
for source_name in graph.incoming_links(&self.from) {
if let Some(record) = graph.record_by_name(source_name) {
changes.push(PlannedChange {
path: record.path.clone(),
description: format!("rewrite [[{}]] -> [[{}]]", self.from, self.to),
});
}
}
Ok(MutationReport { changes, errors })
}
pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
crate::lock::with_lock(&vault.root, || {
crate::journal::replay_all(&vault.root)?;
let folder_path = vault.resolve_folder(&self.folder)?;
let source = folder_path.join(format!("{}.md", self.from));
let dest = folder_path.join(format!("{}.md", self.to));
let report = self.plan(vault)?;
if !report.errors.is_empty() {
return Ok(report);
}
let backlinks: Vec<PathBuf> = report
.changes
.iter()
.skip(1) .map(|c| c.path.clone())
.collect();
let journal = crate::journal::RenameJournal {
source: source.clone(),
dest: dest.clone(),
from_name: self.from.clone(),
to_name: self.to.clone(),
backlinks,
};
let journal_path = crate::journal::write(&vault.root, &journal)?;
if let Err(e) = std::fs::rename(&source, &dest) {
crate::journal::delete(&journal_path);
return Ok(MutationReport {
changes: report.changes,
errors: vec![MutationError {
path: source,
message: format!("rename failed: {}", e),
}],
});
}
let mut errors = Vec::new();
let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
std::collections::BTreeSet::new();
if let Some(parent) = source.parent() {
dirs_to_fsync.insert(parent.to_path_buf());
}
if let Some(parent) = dest.parent() {
dirs_to_fsync.insert(parent.to_path_buf());
}
for change in report.changes.iter().skip(1) {
let path = &change.path;
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
errors.push(MutationError {
path: path.clone(),
message: format!("read failed: {}", e),
});
continue;
}
};
let new_content = rewrite_wikilinks(&content, &self.from, &self.to);
if new_content == content {
continue;
}
if let Err(e) = writer::atomic_write_with(path, &new_content, self.write_options) {
errors.push(MutationError {
path: path.clone(),
message: format!("write failed: {}", e),
});
}
}
if self.write_options.fsync {
for d in &dirs_to_fsync {
if let Err(e) = writer::fsync_dir(d) {
errors.push(MutationError {
path: d.clone(),
message: format!("fsync_dir failed: {}", e),
});
}
}
}
if errors.is_empty() {
crate::journal::delete(&journal_path);
}
Ok(MutationReport {
changes: report.changes,
errors,
})
})
}
}
pub(crate) fn rewrite_wikilinks(content: &str, from: &str, to: &str) -> String {
content
.replace(&format!("[[{}]]", from), &format!("[[{}]]", to))
.replace(&format!("[[{}|", from), &format!("[[{}|", to))
.replace(&format!("[[{}#", from), &format!("[[{}#", to))
}
fn render_value_for_yaml(v: &Value) -> String {
match v {
Value::Null => "null".to_string(),
Value::Bool(b) => b.to_string(),
Value::Integer(i) => i.to_string(),
Value::Float(f) => f.to_string(),
Value::String(s) => writer::quote_value(s),
Value::List(_) | Value::Map(_) => {
let yaml = serde_yaml::to_string(v).unwrap_or_default();
yaml.trim_end().to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::query::Predicate;
#[test]
fn update_builder_chains() {
let filter = Expr::Predicate(Predicate::Equals {
field: "status".into(),
value: Value::String("active".into()),
});
let b = UpdateBuilder::new("notes", filter)
.set("priority", Value::Integer(1))
.unset("draft")
.add_tag("urgent")
.remove_tag("stale");
assert_eq!(b.set_fields.len(), 1);
assert_eq!(b.unset_fields.len(), 1);
assert_eq!(b.add_tags.len(), 1);
assert_eq!(b.remove_tags.len(), 1);
}
#[test]
fn delete_builder_trash_moves_to_dot_trash() {
use std::fs;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join(".obsidian")).unwrap();
fs::create_dir(dir.path().join("notes")).unwrap();
fs::write(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
let vault = Vault::with_root(dir.path().to_path_buf());
let filter = Expr::Predicate(Predicate::Equals {
field: "status".into(),
value: Value::String("stale".into()),
});
let builder = DeleteBuilder::new("notes", filter);
let report = builder.execute(&vault).unwrap();
assert_eq!(report.changes.len(), 1);
assert_eq!(report.errors.len(), 0);
assert!(!dir.path().join("notes/a.md").exists());
assert!(dir.path().join(".trash/a.md").exists());
}
#[test]
fn delete_builder_permanent_removes_file() {
use std::fs;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join(".obsidian")).unwrap();
fs::create_dir(dir.path().join("notes")).unwrap();
fs::write(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
let vault = Vault::with_root(dir.path().to_path_buf());
let filter = Expr::Predicate(Predicate::Equals {
field: "status".into(),
value: Value::String("stale".into()),
});
let builder = DeleteBuilder::new("notes", filter).permanent(true);
builder.execute(&vault).unwrap();
assert!(!dir.path().join("notes/a.md").exists());
assert!(!dir.path().join(".trash/a.md").exists());
}
#[test]
fn move_builder_relocates_files() {
use std::fs;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join(".obsidian")).unwrap();
fs::create_dir(dir.path().join("notes")).unwrap();
fs::write(
dir.path().join("notes/a.md"),
"---\nstatus: archived\n---\n",
)
.unwrap();
let vault = Vault::with_root(dir.path().to_path_buf());
let filter = Expr::Predicate(Predicate::Equals {
field: "status".into(),
value: Value::String("archived".into()),
});
let builder = MoveBuilder::new("notes", "archive", filter);
let report = builder.execute(&vault).unwrap();
assert_eq!(report.changes.len(), 1);
assert_eq!(report.errors.len(), 0);
assert!(!dir.path().join("notes/a.md").exists());
assert!(dir.path().join("archive/a.md").exists());
}
#[test]
fn rename_builder_renames_and_rewrites_links() {
use std::fs;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join(".obsidian")).unwrap();
fs::create_dir(dir.path().join("notes")).unwrap();
fs::write(
dir.path().join("notes/old.md"),
"---\nstatus: x\n---\nBody\n",
)
.unwrap();
fs::write(
dir.path().join("notes/source.md"),
"---\nstatus: y\n---\nLinks to [[old]] and [[old|alias]] and [[old#section]].\n",
)
.unwrap();
let vault = Vault::with_root(dir.path().to_path_buf());
let builder = RenameBuilder::new("notes", "old", "new");
let report = builder.execute(&vault).unwrap();
assert_eq!(report.changes.len(), 2);
assert_eq!(report.errors.len(), 0);
assert!(!dir.path().join("notes/old.md").exists());
assert!(dir.path().join("notes/new.md").exists());
let source_after = fs::read_to_string(dir.path().join("notes/source.md")).unwrap();
assert!(source_after.contains("[[new]]"));
assert!(source_after.contains("[[new|alias]]"));
assert!(source_after.contains("[[new#section]]"));
assert!(!source_after.contains("[[old"));
}
#[test]
fn rename_builder_target_conflict_returns_error() {
use std::fs;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join(".obsidian")).unwrap();
fs::create_dir(dir.path().join("notes")).unwrap();
fs::write(dir.path().join("notes/old.md"), "---\nstatus: x\n---\n").unwrap();
fs::write(dir.path().join("notes/new.md"), "---\nstatus: y\n---\n").unwrap();
let vault = Vault::with_root(dir.path().to_path_buf());
let report = RenameBuilder::new("notes", "old", "new")
.execute(&vault)
.unwrap();
assert_eq!(report.changes.len(), 0);
assert_eq!(report.errors.len(), 1);
assert!(dir.path().join("notes/old.md").exists());
}
#[test]
fn update_builder_plan_and_execute_against_a_temp_vault() {
use std::fs;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join(".obsidian")).unwrap();
fs::create_dir(dir.path().join("notes")).unwrap();
fs::write(
dir.path().join("notes/a.md"),
"---\nstatus: active\n---\nBody A\n",
)
.unwrap();
fs::write(
dir.path().join("notes/b.md"),
"---\nstatus: pending\n---\nBody B\n",
)
.unwrap();
let vault = Vault::with_root(dir.path().to_path_buf());
let filter = Expr::Predicate(Predicate::Equals {
field: "status".into(),
value: Value::String("active".into()),
});
let builder = UpdateBuilder::new("notes", filter).set("priority", Value::Integer(1));
let plan_report = builder.plan(&vault).unwrap();
assert_eq!(plan_report.changes.len(), 1);
assert_eq!(plan_report.errors.len(), 0);
assert!(plan_report.changes[0].path.ends_with("a.md"));
let before = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
assert!(!before.contains("priority"));
let exec_report = builder.execute(&vault).unwrap();
assert_eq!(exec_report.changes.len(), 1);
let after = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
assert!(after.contains("priority"));
let b_after = fs::read_to_string(dir.path().join("notes/b.md")).unwrap();
assert!(!b_after.contains("priority"));
}
#[test]
fn write_options_fsync_propagates_through_update_builder() {
use std::fs;
use tempfile::TempDir;
let f1 = Expr::Predicate(Predicate::Equals {
field: "x".into(),
value: Value::Integer(1),
});
let b = UpdateBuilder::new("notes", f1).fsync(true);
assert!(b.write_options.fsync);
let f2 = Expr::Predicate(Predicate::Equals {
field: "x".into(),
value: Value::Integer(1),
});
let b =
UpdateBuilder::new("notes", f2).write_options(crate::writer::WriteOptions::durable());
assert!(b.write_options.fsync);
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join(".obsidian")).unwrap();
fs::create_dir(dir.path().join("notes")).unwrap();
fs::write(
dir.path().join("notes/durable.md"),
"---\nstatus: active\n---\nBody.\n",
)
.unwrap();
let vault = Vault::with_root(dir.path().to_path_buf());
let f3 = Expr::Predicate(Predicate::Equals {
field: "status".into(),
value: Value::String("active".into()),
});
let report = UpdateBuilder::new("notes", f3)
.set("priority", Value::Integer(99))
.fsync(true)
.execute(&vault)
.unwrap();
assert_eq!(report.changes.len(), 1);
assert_eq!(report.errors.len(), 0);
let after = fs::read_to_string(dir.path().join("notes/durable.md")).unwrap();
assert!(after.contains("priority: 99"));
assert!(after.contains("status: active"));
}
#[test]
fn rename_clean_run_leaves_no_journal_behind() {
use std::fs;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join(".obsidian")).unwrap();
fs::create_dir(dir.path().join("notes")).unwrap();
fs::write(
dir.path().join("notes/old.md"),
"---\nstatus: x\n---\nBody\n",
)
.unwrap();
fs::write(
dir.path().join("notes/source.md"),
"---\nstatus: y\n---\nLinks to [[old]].\n",
)
.unwrap();
let vault = Vault::with_root(dir.path().to_path_buf());
RenameBuilder::new("notes", "old", "new")
.execute(&vault)
.unwrap();
let pending = crate::journal::list_pending(dir.path()).unwrap();
assert!(
pending.is_empty(),
"successful rename must not leave journals behind: {:?}",
pending
);
}
#[test]
fn rename_recovers_from_pre_existing_journal() {
use std::fs;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join(".obsidian")).unwrap();
fs::create_dir(dir.path().join("notes")).unwrap();
let source = dir.path().join("notes/Stanford.md");
let dest = dir.path().join("notes/Stanford University.md");
let backlink = dir.path().join("notes/Application.md");
fs::write(&source, "---\nkind: university\n---\nMain note.\n").unwrap();
fs::write(
&backlink,
"---\nkind: application\n---\nApplied to [[Stanford]].\n",
)
.unwrap();
let journal = crate::journal::RenameJournal {
source: source.clone(),
dest: dest.clone(),
from_name: "Stanford".into(),
to_name: "Stanford University".into(),
backlinks: vec![backlink.clone()],
};
crate::journal::write(dir.path(), &journal).unwrap();
let vault = Vault::with_root(dir.path().to_path_buf());
let recovered = vault.recover().unwrap();
assert_eq!(recovered, 1, "expected exactly one journal replayed");
assert!(!source.exists());
assert!(dest.is_file());
let backlink_content = fs::read_to_string(&backlink).unwrap();
assert!(backlink_content.contains("[[Stanford University]]"));
assert!(!backlink_content.contains("[[Stanford]]"));
assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
}
#[test]
fn rename_replays_pending_journal_before_starting_new_rename() {
use std::fs;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join(".obsidian")).unwrap();
fs::create_dir(dir.path().join("notes")).unwrap();
let a = dir.path().join("notes/A.md");
let b = dir.path().join("notes/B.md");
let c = dir.path().join("notes/C.md");
let d = dir.path().join("notes/D.md");
fs::write(&a, "---\n---\nA body.\n").unwrap();
fs::write(&c, "---\n---\nC body.\n").unwrap();
crate::journal::write(
dir.path(),
&crate::journal::RenameJournal {
source: a.clone(),
dest: b.clone(),
from_name: "A".into(),
to_name: "B".into(),
backlinks: vec![],
},
)
.unwrap();
let vault = Vault::with_root(dir.path().to_path_buf());
RenameBuilder::new("notes", "C", "D")
.execute(&vault)
.unwrap();
assert!(!a.exists(), "A.md should be gone (replayed journal)");
assert!(b.is_file(), "B.md should exist (replayed journal)");
assert!(!c.exists(), "C.md should be gone (new rename)");
assert!(d.is_file(), "D.md should exist (new rename)");
assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
}
#[test]
fn concurrent_updates_serialize_via_vault_lock() {
use std::fs;
use std::sync::Arc;
use std::thread;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join(".obsidian")).unwrap();
fs::create_dir(dir.path().join("notes")).unwrap();
fs::write(
dir.path().join("notes/race.md"),
"---\nstatus: active\n---\nBody.\n",
)
.unwrap();
let vault_path = Arc::new(dir.path().to_path_buf());
let p1 = Arc::clone(&vault_path);
let t1 = thread::spawn(move || {
let vault = Vault::with_root((*p1).clone());
let filter = Expr::Predicate(Predicate::Equals {
field: "status".into(),
value: Value::String("active".into()),
});
UpdateBuilder::new("notes", filter)
.set("touched_by_t1", Value::Integer(1))
.execute(&vault)
.expect("t1 execute")
});
let p2 = Arc::clone(&vault_path);
let t2 = thread::spawn(move || {
let vault = Vault::with_root((*p2).clone());
let filter = Expr::Predicate(Predicate::Equals {
field: "status".into(),
value: Value::String("active".into()),
});
UpdateBuilder::new("notes", filter)
.set("touched_by_t2", Value::Integer(1))
.execute(&vault)
.expect("t2 execute")
});
let r1 = t1.join().unwrap();
let r2 = t2.join().unwrap();
assert_eq!(r1.errors.len(), 0);
assert_eq!(r2.errors.len(), 0);
let final_content = fs::read_to_string(dir.path().join("notes/race.md")).unwrap();
assert!(
final_content.contains("touched_by_t1"),
"t1's edit lost; concurrent writer race: {}",
final_content
);
assert!(
final_content.contains("touched_by_t2"),
"t2's edit lost; concurrent writer race: {}",
final_content
);
}
#[test]
fn atomic_write_does_not_leave_partial_files_on_failed_writes() {
use std::fs;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let target = dir.path().join("subdir/that-does-not-exist/x.md");
let result = crate::writer::atomic_write(&target, "new content");
assert!(
result.is_err(),
"expected atomic_write to fail when parent dir doesn't exist"
);
let real_dir = dir.path().join("real");
fs::create_dir(&real_dir).unwrap();
let real_target = real_dir.join("x.md");
fs::write(&real_target, "original").unwrap();
crate::writer::atomic_write(&real_target, "replacement").unwrap();
let after = fs::read_to_string(&real_target).unwrap();
assert_eq!(after, "replacement");
let leftovers: Vec<_> = fs::read_dir(&real_dir)
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().starts_with(".tmp"))
.collect();
assert!(
leftovers.is_empty(),
"expected no tempfile leftovers, found: {:?}",
leftovers.iter().map(|e| e.path()).collect::<Vec<_>>()
);
}
}