1use std::path::PathBuf;
14
15use crate::error::{Result, VaultdbError};
16use crate::query::Expr;
17use crate::record::Value;
18use crate::vault::Vault;
19use crate::writer::{self, WriteResult};
20
21#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23pub struct MutationReport {
24 pub changes: Vec<PlannedChange>,
25 pub errors: Vec<MutationError>,
26}
27
28#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
30pub struct PlannedChange {
31 pub path: PathBuf,
32 pub description: String,
33}
34
35#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
37pub struct MutationError {
38 pub path: PathBuf,
39 pub message: String,
40}
41
42#[derive(Debug, Clone)]
48pub struct UpdateBuilder {
49 filter: Expr,
50 folder: String,
51 set_fields: Vec<(String, Value)>,
52 unset_fields: Vec<String>,
53 add_tags: Vec<String>,
54 remove_tags: Vec<String>,
55 write_options: writer::WriteOptions,
56}
57
58impl UpdateBuilder {
59 pub fn new(folder: impl Into<String>, filter: Expr) -> Self {
60 Self {
61 filter,
62 folder: folder.into(),
63 set_fields: Vec::new(),
64 unset_fields: Vec::new(),
65 add_tags: Vec::new(),
66 remove_tags: Vec::new(),
67 write_options: writer::WriteOptions::default(),
68 }
69 }
70
71 pub fn set(mut self, field: impl Into<String>, value: Value) -> Self {
72 self.set_fields.push((field.into(), value));
73 self
74 }
75
76 pub fn unset(mut self, field: impl Into<String>) -> Self {
77 self.unset_fields.push(field.into());
78 self
79 }
80
81 pub fn add_tag(mut self, tag: impl Into<String>) -> Self {
82 self.add_tags.push(tag.into());
83 self
84 }
85
86 pub fn remove_tag(mut self, tag: impl Into<String>) -> Self {
87 self.remove_tags.push(tag.into());
88 self
89 }
90
91 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
94 self.write_options = opts;
95 self
96 }
97
98 pub fn fsync(mut self, yes: bool) -> Self {
101 self.write_options.fsync = yes;
102 self
103 }
104
105 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
107 let (report, _writes) = self.compute(vault)?;
108 Ok(report)
109 }
110
111 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
119 crate::lock::with_lock(&vault.root, || {
120 let (report, writes) = self.compute(vault)?;
121 for w in &writes {
122 writer::apply_with(w, self.write_options).map_err(VaultdbError::Io)?;
123 }
124 Ok(report)
125 })
126 }
127
128 fn compute(&self, vault: &Vault) -> Result<(MutationReport, Vec<WriteResult>)> {
129 let folder_path = vault.resolve_folder(&self.folder)?;
130 let load = vault.load_records_with_content(&folder_path, false, false)?;
131 let needs_links = crate::filter::expr_uses_links(&self.filter);
132 let link_index = if needs_links {
133 Some(crate::links::LinkGraph::build_with_root(
134 &load.records,
135 Some(&vault.root),
136 ))
137 } else {
138 None
139 };
140
141 let mut changes = Vec::new();
142 let mut errors = Vec::new();
143 let mut writes = Vec::new();
144
145 for record in &load.records {
146 if !crate::filter::evaluate_expr(&self.filter, record, &vault.root, link_index.as_ref())
147 {
148 continue;
149 }
150
151 let mut content = match &record.raw_content {
152 Some(c) => c.clone(),
153 None => {
154 errors.push(MutationError {
155 path: record.path.clone(),
156 message: "record has no raw_content; cannot apply update".into(),
157 });
158 continue;
159 }
160 };
161 let original_content = content.clone();
162 let mut wr_changes = Vec::new();
163 let mut description_parts: Vec<String> = Vec::new();
164
165 let result: Result<()> = (|| {
166 for (field, value) in &self.set_fields {
167 let value_str = render_value_for_yaml(value);
168 let (new_content, change) = writer::set_field(&content, field, &value_str)?;
169 description_parts.push(format!("{}", change));
170 wr_changes.push(change);
171 content = new_content;
172 }
173 for field in &self.unset_fields {
174 let (new_content, change) = writer::unset_field(&content, field)?;
175 description_parts.push(format!("{}", change));
176 wr_changes.push(change);
177 content = new_content;
178 }
179 for tag in &self.add_tags {
180 let (new_content, change) = writer::add_tag(&content, tag)?;
181 description_parts.push(format!("{}", change));
182 wr_changes.push(change);
183 content = new_content;
184 }
185 for tag in &self.remove_tags {
186 let (new_content, change) = writer::remove_tag(&content, tag)?;
187 description_parts.push(format!("{}", change));
188 wr_changes.push(change);
189 content = new_content;
190 }
191 Ok(())
192 })();
193
194 match result {
195 Ok(_) => {
196 if !wr_changes.is_empty() {
197 writes.push(WriteResult {
198 path: record.path.clone(),
199 original_content,
200 modified_content: content,
201 changes: wr_changes,
202 });
203 changes.push(PlannedChange {
204 path: record.path.clone(),
205 description: description_parts.join("; "),
206 });
207 }
208 }
209 Err(e) => errors.push(MutationError {
210 path: record.path.clone(),
211 message: e.to_string(),
212 }),
213 }
214 }
215
216 Ok((MutationReport { changes, errors }, writes))
217 }
218}
219
220#[derive(Debug, Clone)]
226pub struct DeleteBuilder {
227 filter: Expr,
228 folder: String,
229 permanent: bool,
230 write_options: writer::WriteOptions,
231}
232
233impl DeleteBuilder {
234 pub fn new(folder: impl Into<String>, filter: Expr) -> Self {
235 Self {
236 filter,
237 folder: folder.into(),
238 permanent: false,
239 write_options: writer::WriteOptions::default(),
240 }
241 }
242
243 pub fn permanent(mut self, yes: bool) -> Self {
244 self.permanent = yes;
245 self
246 }
247
248 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
250 self.write_options = opts;
251 self
252 }
253
254 pub fn fsync(mut self, yes: bool) -> Self {
257 self.write_options.fsync = yes;
258 self
259 }
260
261 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
262 let folder_path = vault.resolve_folder(&self.folder)?;
263 let load = vault.load_records(&folder_path, false, false)?;
264 let needs_links = crate::filter::expr_uses_links(&self.filter);
265 let link_index = if needs_links {
266 Some(crate::links::LinkGraph::build_with_root(
267 &load.records,
268 Some(&vault.root),
269 ))
270 } else {
271 None
272 };
273
274 let mut changes = Vec::new();
275 for r in &load.records {
276 if !crate::filter::evaluate_expr(&self.filter, r, &vault.root, link_index.as_ref()) {
277 continue;
278 }
279 changes.push(PlannedChange {
280 path: r.path.clone(),
281 description: if self.permanent {
282 "delete (permanent)".to_string()
283 } else {
284 "move to .trash/".to_string()
285 },
286 });
287 }
288 Ok(MutationReport {
289 changes,
290 errors: Vec::new(),
291 })
292 }
293
294 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
295 crate::lock::with_lock(&vault.root, || {
296 let report = self.plan(vault)?;
297 let mut errors = Vec::new();
298 let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
300 std::collections::BTreeSet::new();
301
302 if self.permanent {
303 for change in &report.changes {
304 if let Err(e) = std::fs::remove_file(&change.path) {
305 errors.push(MutationError {
306 path: change.path.clone(),
307 message: format!("remove failed: {}", e),
308 });
309 } else if let Some(parent) = change.path.parent() {
310 dirs_to_fsync.insert(parent.to_path_buf());
311 }
312 }
313 } else {
314 let trash_dir = vault.root.join(".trash");
315 if !report.changes.is_empty() {
316 std::fs::create_dir_all(&trash_dir).map_err(VaultdbError::Io)?;
317 }
318 for change in &report.changes {
319 let dest = unique_in_dir(&trash_dir, &change.path);
320 if let Err(e) = std::fs::rename(&change.path, &dest) {
321 errors.push(MutationError {
322 path: change.path.clone(),
323 message: format!("trash failed: {}", e),
324 });
325 } else {
326 if let Some(parent) = change.path.parent() {
327 dirs_to_fsync.insert(parent.to_path_buf());
328 }
329 dirs_to_fsync.insert(trash_dir.clone());
330 }
331 }
332 }
333
334 if self.write_options.fsync {
337 for d in &dirs_to_fsync {
338 if let Err(e) = writer::fsync_dir(d) {
339 errors.push(MutationError {
340 path: d.clone(),
341 message: format!("fsync_dir failed: {}", e),
342 });
343 }
344 }
345 }
346
347 Ok(MutationReport {
348 changes: report.changes,
349 errors,
350 })
351 })
352 }
353}
354
355fn unique_in_dir(dir: &std::path::Path, src: &std::path::Path) -> PathBuf {
356 let filename = src.file_name().and_then(|n| n.to_str()).unwrap_or("file");
357 let candidate = dir.join(filename);
358 if !candidate.exists() {
359 return candidate;
360 }
361 let stem = src.file_stem().and_then(|n| n.to_str()).unwrap_or("file");
362 let ext = src.extension().and_then(|n| n.to_str()).unwrap_or("md");
363 let mut i = 1;
364 loop {
365 let c = dir.join(format!("{}-{}.{}", stem, i, ext));
366 if !c.exists() {
367 return c;
368 }
369 i += 1;
370 }
371}
372
373#[derive(Debug, Clone)]
379pub struct MoveBuilder {
380 filter: Expr,
381 folder: String,
382 to_folder: String,
383 write_options: writer::WriteOptions,
384}
385
386impl MoveBuilder {
387 pub fn new(folder: impl Into<String>, to_folder: impl Into<String>, filter: Expr) -> Self {
388 Self {
389 filter,
390 folder: folder.into(),
391 to_folder: to_folder.into(),
392 write_options: writer::WriteOptions::default(),
393 }
394 }
395
396 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
398 self.write_options = opts;
399 self
400 }
401
402 pub fn fsync(mut self, yes: bool) -> Self {
404 self.write_options.fsync = yes;
405 self
406 }
407
408 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
409 let folder_path = vault.resolve_folder(&self.folder)?;
410 let to_path = vault.root.join(&self.to_folder);
411 let load = vault.load_records(&folder_path, false, false)?;
412 let needs_links = crate::filter::expr_uses_links(&self.filter);
413 let link_index = if needs_links {
414 Some(crate::links::LinkGraph::build_with_root(
415 &load.records,
416 Some(&vault.root),
417 ))
418 } else {
419 None
420 };
421
422 let mut changes = Vec::new();
423 let mut errors = Vec::new();
424
425 for r in &load.records {
426 if !crate::filter::evaluate_expr(&self.filter, r, &vault.root, link_index.as_ref()) {
427 continue;
428 }
429 let filename = match r.path.file_name() {
430 Some(n) => n,
431 None => continue,
432 };
433 let dest = to_path.join(filename);
434 if dest.exists() {
435 errors.push(MutationError {
436 path: r.path.clone(),
437 message: format!(
438 "move conflict: {} already exists in {}",
439 filename.to_string_lossy(),
440 self.to_folder
441 ),
442 });
443 continue;
444 }
445 changes.push(PlannedChange {
446 path: r.path.clone(),
447 description: format!("move to {}", dest.display()),
448 });
449 }
450 Ok(MutationReport { changes, errors })
451 }
452
453 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
454 crate::lock::with_lock(&vault.root, || {
455 let to_path = vault.root.join(&self.to_folder);
456 let report = self.plan(vault)?;
457 if !report.changes.is_empty() {
458 std::fs::create_dir_all(&to_path).map_err(VaultdbError::Io)?;
459 }
460 let mut errors = report.errors;
461 let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
462 std::collections::BTreeSet::new();
463 for change in &report.changes {
464 let filename = match change.path.file_name() {
465 Some(n) => n,
466 None => continue,
467 };
468 let dest = to_path.join(filename);
469 if let Err(e) = std::fs::rename(&change.path, &dest) {
470 errors.push(MutationError {
471 path: change.path.clone(),
472 message: format!("rename failed: {}", e),
473 });
474 } else {
475 if let Some(parent) = change.path.parent() {
476 dirs_to_fsync.insert(parent.to_path_buf());
477 }
478 dirs_to_fsync.insert(to_path.clone());
479 }
480 }
481
482 if self.write_options.fsync {
483 for d in &dirs_to_fsync {
484 if let Err(e) = writer::fsync_dir(d) {
485 errors.push(MutationError {
486 path: d.clone(),
487 message: format!("fsync_dir failed: {}", e),
488 });
489 }
490 }
491 }
492
493 Ok(MutationReport {
494 changes: report.changes,
495 errors,
496 })
497 })
498 }
499}
500
501#[derive(Debug, Clone)]
510pub struct RenameBuilder {
511 folder: String,
512 from: String,
513 to: String,
514 write_options: writer::WriteOptions,
515}
516
517impl RenameBuilder {
518 pub fn new(folder: impl Into<String>, from: impl Into<String>, to: impl Into<String>) -> Self {
519 Self {
520 folder: folder.into(),
521 from: from.into(),
522 to: to.into(),
523 write_options: writer::WriteOptions::default(),
524 }
525 }
526
527 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
529 self.write_options = opts;
530 self
531 }
532
533 pub fn fsync(mut self, yes: bool) -> Self {
536 self.write_options.fsync = yes;
537 self
538 }
539
540 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
541 let folder_path = vault.resolve_folder(&self.folder)?;
542 let source = folder_path.join(format!("{}.md", self.from));
543 let dest = folder_path.join(format!("{}.md", self.to));
544
545 let mut changes = Vec::new();
546 let mut errors = Vec::new();
547
548 if !source.is_file() {
549 errors.push(MutationError {
550 path: source.clone(),
551 message: format!("source `{}` not found", self.from),
552 });
553 return Ok(MutationReport { changes, errors });
554 }
555 if dest.exists() {
556 errors.push(MutationError {
557 path: dest.clone(),
558 message: format!("target `{}.md` already exists", self.to),
559 });
560 return Ok(MutationReport { changes, errors });
561 }
562
563 changes.push(PlannedChange {
564 path: source.clone(),
565 description: format!("rename to {}", dest.display()),
566 });
567
568 let all = vault.load_records_with_content(&vault.root, true, false)?;
571 let graph = crate::links::LinkGraph::build_with_root(&all.records, Some(&vault.root));
572 for source_name in graph.incoming_links(&self.from) {
573 if let Some(record) = graph.record_by_name(source_name) {
574 changes.push(PlannedChange {
575 path: record.path.clone(),
576 description: format!("rewrite [[{}]] -> [[{}]]", self.from, self.to),
577 });
578 }
579 }
580
581 Ok(MutationReport { changes, errors })
582 }
583
584 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
585 crate::lock::with_lock(&vault.root, || {
586 crate::journal::replay_all(&vault.root)?;
593
594 let folder_path = vault.resolve_folder(&self.folder)?;
595 let source = folder_path.join(format!("{}.md", self.from));
596 let dest = folder_path.join(format!("{}.md", self.to));
597
598 let report = self.plan(vault)?;
599 if !report.errors.is_empty() {
601 return Ok(report);
602 }
603
604 let backlinks: Vec<PathBuf> = report
609 .changes
610 .iter()
611 .skip(1) .map(|c| c.path.clone())
613 .collect();
614 let journal = crate::journal::RenameJournal {
615 source: source.clone(),
616 dest: dest.clone(),
617 from_name: self.from.clone(),
618 to_name: self.to.clone(),
619 backlinks,
620 };
621 let journal_path = crate::journal::write(&vault.root, &journal)?;
622
623 if let Err(e) = std::fs::rename(&source, &dest) {
626 crate::journal::delete(&journal_path);
627 return Ok(MutationReport {
628 changes: report.changes,
629 errors: vec![MutationError {
630 path: source,
631 message: format!("rename failed: {}", e),
632 }],
633 });
634 }
635
636 let mut errors = Vec::new();
640 let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
641 std::collections::BTreeSet::new();
642 if let Some(parent) = source.parent() {
643 dirs_to_fsync.insert(parent.to_path_buf());
644 }
645 if let Some(parent) = dest.parent() {
646 dirs_to_fsync.insert(parent.to_path_buf());
647 }
648 for change in report.changes.iter().skip(1) {
649 let path = &change.path;
650 let content = match std::fs::read_to_string(path) {
651 Ok(c) => c,
652 Err(e) => {
653 errors.push(MutationError {
654 path: path.clone(),
655 message: format!("read failed: {}", e),
656 });
657 continue;
658 }
659 };
660 let new_content = rewrite_wikilinks(&content, &self.from, &self.to);
661 if new_content == content {
662 continue;
663 }
664 if let Err(e) = writer::atomic_write_with(path, &new_content, self.write_options) {
665 errors.push(MutationError {
666 path: path.clone(),
667 message: format!("write failed: {}", e),
668 });
669 }
670 }
671
672 if self.write_options.fsync {
677 for d in &dirs_to_fsync {
678 if let Err(e) = writer::fsync_dir(d) {
679 errors.push(MutationError {
680 path: d.clone(),
681 message: format!("fsync_dir failed: {}", e),
682 });
683 }
684 }
685 }
686
687 if errors.is_empty() {
691 crate::journal::delete(&journal_path);
692 }
693
694 Ok(MutationReport {
695 changes: report.changes,
696 errors,
697 })
698 })
699 }
700}
701
702pub(crate) fn rewrite_wikilinks(content: &str, from: &str, to: &str) -> String {
705 content
706 .replace(&format!("[[{}]]", from), &format!("[[{}]]", to))
707 .replace(&format!("[[{}|", from), &format!("[[{}|", to))
708 .replace(&format!("[[{}#", from), &format!("[[{}#", to))
709}
710
711fn render_value_for_yaml(v: &Value) -> String {
717 match v {
718 Value::Null => "null".to_string(),
719 Value::Bool(b) => b.to_string(),
720 Value::Integer(i) => i.to_string(),
721 Value::Float(f) => f.to_string(),
722 Value::String(s) => writer::quote_value(s),
723 Value::List(_) | Value::Map(_) => {
724 let yaml = serde_yaml::to_string(v).unwrap_or_default();
725 yaml.trim_end().to_string()
726 }
727 }
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733 use crate::query::Predicate;
734
735 #[test]
736 fn update_builder_chains() {
737 let filter = Expr::Predicate(Predicate::Equals {
738 field: "status".into(),
739 value: Value::String("active".into()),
740 });
741 let b = UpdateBuilder::new("notes", filter)
742 .set("priority", Value::Integer(1))
743 .unset("draft")
744 .add_tag("urgent")
745 .remove_tag("stale");
746 assert_eq!(b.set_fields.len(), 1);
747 assert_eq!(b.unset_fields.len(), 1);
748 assert_eq!(b.add_tags.len(), 1);
749 assert_eq!(b.remove_tags.len(), 1);
750 }
751
752 #[test]
753 fn delete_builder_trash_moves_to_dot_trash() {
754 use std::fs;
755 use tempfile::TempDir;
756 let dir = TempDir::new().unwrap();
757 fs::create_dir(dir.path().join(".obsidian")).unwrap();
758 fs::create_dir(dir.path().join("notes")).unwrap();
759 fs::write(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
760 let vault = Vault::with_root(dir.path().to_path_buf());
761 let filter = Expr::Predicate(Predicate::Equals {
762 field: "status".into(),
763 value: Value::String("stale".into()),
764 });
765 let builder = DeleteBuilder::new("notes", filter);
766 let report = builder.execute(&vault).unwrap();
767 assert_eq!(report.changes.len(), 1);
768 assert_eq!(report.errors.len(), 0);
769 assert!(!dir.path().join("notes/a.md").exists());
770 assert!(dir.path().join(".trash/a.md").exists());
771 }
772
773 #[test]
774 fn delete_builder_permanent_removes_file() {
775 use std::fs;
776 use tempfile::TempDir;
777 let dir = TempDir::new().unwrap();
778 fs::create_dir(dir.path().join(".obsidian")).unwrap();
779 fs::create_dir(dir.path().join("notes")).unwrap();
780 fs::write(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
781 let vault = Vault::with_root(dir.path().to_path_buf());
782 let filter = Expr::Predicate(Predicate::Equals {
783 field: "status".into(),
784 value: Value::String("stale".into()),
785 });
786 let builder = DeleteBuilder::new("notes", filter).permanent(true);
787 builder.execute(&vault).unwrap();
788 assert!(!dir.path().join("notes/a.md").exists());
789 assert!(!dir.path().join(".trash/a.md").exists());
790 }
791
792 #[test]
793 fn move_builder_relocates_files() {
794 use std::fs;
795 use tempfile::TempDir;
796 let dir = TempDir::new().unwrap();
797 fs::create_dir(dir.path().join(".obsidian")).unwrap();
798 fs::create_dir(dir.path().join("notes")).unwrap();
799 fs::write(
800 dir.path().join("notes/a.md"),
801 "---\nstatus: archived\n---\n",
802 )
803 .unwrap();
804 let vault = Vault::with_root(dir.path().to_path_buf());
805 let filter = Expr::Predicate(Predicate::Equals {
806 field: "status".into(),
807 value: Value::String("archived".into()),
808 });
809 let builder = MoveBuilder::new("notes", "archive", filter);
810 let report = builder.execute(&vault).unwrap();
811 assert_eq!(report.changes.len(), 1);
812 assert_eq!(report.errors.len(), 0);
813 assert!(!dir.path().join("notes/a.md").exists());
814 assert!(dir.path().join("archive/a.md").exists());
815 }
816
817 #[test]
818 fn rename_builder_renames_and_rewrites_links() {
819 use std::fs;
820 use tempfile::TempDir;
821 let dir = TempDir::new().unwrap();
822 fs::create_dir(dir.path().join(".obsidian")).unwrap();
823 fs::create_dir(dir.path().join("notes")).unwrap();
824 fs::write(
825 dir.path().join("notes/old.md"),
826 "---\nstatus: x\n---\nBody\n",
827 )
828 .unwrap();
829 fs::write(
830 dir.path().join("notes/source.md"),
831 "---\nstatus: y\n---\nLinks to [[old]] and [[old|alias]] and [[old#section]].\n",
832 )
833 .unwrap();
834 let vault = Vault::with_root(dir.path().to_path_buf());
835
836 let builder = RenameBuilder::new("notes", "old", "new");
837 let report = builder.execute(&vault).unwrap();
838 assert_eq!(report.changes.len(), 2);
840 assert_eq!(report.errors.len(), 0);
841 assert!(!dir.path().join("notes/old.md").exists());
842 assert!(dir.path().join("notes/new.md").exists());
843 let source_after = fs::read_to_string(dir.path().join("notes/source.md")).unwrap();
844 assert!(source_after.contains("[[new]]"));
845 assert!(source_after.contains("[[new|alias]]"));
846 assert!(source_after.contains("[[new#section]]"));
847 assert!(!source_after.contains("[[old"));
848 }
849
850 #[test]
851 fn rename_builder_target_conflict_returns_error() {
852 use std::fs;
853 use tempfile::TempDir;
854 let dir = TempDir::new().unwrap();
855 fs::create_dir(dir.path().join(".obsidian")).unwrap();
856 fs::create_dir(dir.path().join("notes")).unwrap();
857 fs::write(dir.path().join("notes/old.md"), "---\nstatus: x\n---\n").unwrap();
858 fs::write(dir.path().join("notes/new.md"), "---\nstatus: y\n---\n").unwrap();
859 let vault = Vault::with_root(dir.path().to_path_buf());
860 let report = RenameBuilder::new("notes", "old", "new")
861 .execute(&vault)
862 .unwrap();
863 assert_eq!(report.changes.len(), 0);
864 assert_eq!(report.errors.len(), 1);
865 assert!(dir.path().join("notes/old.md").exists());
867 }
868
869 #[test]
870 fn update_builder_plan_and_execute_against_a_temp_vault() {
871 use std::fs;
872 use tempfile::TempDir;
873
874 let dir = TempDir::new().unwrap();
875 fs::create_dir(dir.path().join(".obsidian")).unwrap();
876 fs::create_dir(dir.path().join("notes")).unwrap();
877 fs::write(
878 dir.path().join("notes/a.md"),
879 "---\nstatus: active\n---\nBody A\n",
880 )
881 .unwrap();
882 fs::write(
883 dir.path().join("notes/b.md"),
884 "---\nstatus: pending\n---\nBody B\n",
885 )
886 .unwrap();
887
888 let vault = Vault::with_root(dir.path().to_path_buf());
889
890 let filter = Expr::Predicate(Predicate::Equals {
891 field: "status".into(),
892 value: Value::String("active".into()),
893 });
894 let builder = UpdateBuilder::new("notes", filter).set("priority", Value::Integer(1));
895
896 let plan_report = builder.plan(&vault).unwrap();
898 assert_eq!(plan_report.changes.len(), 1);
899 assert_eq!(plan_report.errors.len(), 0);
900 assert!(plan_report.changes[0].path.ends_with("a.md"));
901 let before = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
902 assert!(!before.contains("priority"));
903
904 let exec_report = builder.execute(&vault).unwrap();
906 assert_eq!(exec_report.changes.len(), 1);
907 let after = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
908 assert!(after.contains("priority"));
909 let b_after = fs::read_to_string(dir.path().join("notes/b.md")).unwrap();
911 assert!(!b_after.contains("priority"));
912 }
913
914 #[test]
915 fn write_options_fsync_propagates_through_update_builder() {
916 use std::fs;
924 use tempfile::TempDir;
925
926 let f1 = Expr::Predicate(Predicate::Equals {
928 field: "x".into(),
929 value: Value::Integer(1),
930 });
931 let b = UpdateBuilder::new("notes", f1).fsync(true);
932 assert!(b.write_options.fsync);
933
934 let f2 = Expr::Predicate(Predicate::Equals {
935 field: "x".into(),
936 value: Value::Integer(1),
937 });
938 let b =
939 UpdateBuilder::new("notes", f2).write_options(crate::writer::WriteOptions::durable());
940 assert!(b.write_options.fsync);
941
942 let dir = TempDir::new().unwrap();
944 fs::create_dir(dir.path().join(".obsidian")).unwrap();
945 fs::create_dir(dir.path().join("notes")).unwrap();
946 fs::write(
947 dir.path().join("notes/durable.md"),
948 "---\nstatus: active\n---\nBody.\n",
949 )
950 .unwrap();
951 let vault = Vault::with_root(dir.path().to_path_buf());
952
953 let f3 = Expr::Predicate(Predicate::Equals {
954 field: "status".into(),
955 value: Value::String("active".into()),
956 });
957 let report = UpdateBuilder::new("notes", f3)
958 .set("priority", Value::Integer(99))
959 .fsync(true)
960 .execute(&vault)
961 .unwrap();
962 assert_eq!(report.changes.len(), 1);
963 assert_eq!(report.errors.len(), 0);
964
965 let after = fs::read_to_string(dir.path().join("notes/durable.md")).unwrap();
966 assert!(after.contains("priority: 99"));
967 assert!(after.contains("status: active"));
968 }
969
970 #[test]
971 fn rename_clean_run_leaves_no_journal_behind() {
972 use std::fs;
975 use tempfile::TempDir;
976 let dir = TempDir::new().unwrap();
977 fs::create_dir(dir.path().join(".obsidian")).unwrap();
978 fs::create_dir(dir.path().join("notes")).unwrap();
979 fs::write(
980 dir.path().join("notes/old.md"),
981 "---\nstatus: x\n---\nBody\n",
982 )
983 .unwrap();
984 fs::write(
985 dir.path().join("notes/source.md"),
986 "---\nstatus: y\n---\nLinks to [[old]].\n",
987 )
988 .unwrap();
989 let vault = Vault::with_root(dir.path().to_path_buf());
990
991 RenameBuilder::new("notes", "old", "new")
992 .execute(&vault)
993 .unwrap();
994
995 let pending = crate::journal::list_pending(dir.path()).unwrap();
996 assert!(
997 pending.is_empty(),
998 "successful rename must not leave journals behind: {:?}",
999 pending
1000 );
1001 }
1002
1003 #[test]
1004 fn rename_recovers_from_pre_existing_journal() {
1005 use std::fs;
1009 use tempfile::TempDir;
1010 let dir = TempDir::new().unwrap();
1011 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1012 fs::create_dir(dir.path().join("notes")).unwrap();
1013 let source = dir.path().join("notes/Stanford.md");
1014 let dest = dir.path().join("notes/Stanford University.md");
1015 let backlink = dir.path().join("notes/Application.md");
1016 fs::write(&source, "---\nkind: university\n---\nMain note.\n").unwrap();
1017 fs::write(
1018 &backlink,
1019 "---\nkind: application\n---\nApplied to [[Stanford]].\n",
1020 )
1021 .unwrap();
1022
1023 let journal = crate::journal::RenameJournal {
1025 source: source.clone(),
1026 dest: dest.clone(),
1027 from_name: "Stanford".into(),
1028 to_name: "Stanford University".into(),
1029 backlinks: vec![backlink.clone()],
1030 };
1031 crate::journal::write(dir.path(), &journal).unwrap();
1032
1033 let vault = Vault::with_root(dir.path().to_path_buf());
1034 let recovered = vault.recover().unwrap();
1035 assert_eq!(recovered, 1, "expected exactly one journal replayed");
1036
1037 assert!(!source.exists());
1039 assert!(dest.is_file());
1040 let backlink_content = fs::read_to_string(&backlink).unwrap();
1041 assert!(backlink_content.contains("[[Stanford University]]"));
1042 assert!(!backlink_content.contains("[[Stanford]]"));
1043 assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
1044 }
1045
1046 #[test]
1047 fn rename_replays_pending_journal_before_starting_new_rename() {
1048 use std::fs;
1052 use tempfile::TempDir;
1053 let dir = TempDir::new().unwrap();
1054 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1055 fs::create_dir(dir.path().join("notes")).unwrap();
1056
1057 let a = dir.path().join("notes/A.md");
1061 let b = dir.path().join("notes/B.md");
1062 let c = dir.path().join("notes/C.md");
1063 let d = dir.path().join("notes/D.md");
1064 fs::write(&a, "---\n---\nA body.\n").unwrap();
1065 fs::write(&c, "---\n---\nC body.\n").unwrap();
1066
1067 crate::journal::write(
1069 dir.path(),
1070 &crate::journal::RenameJournal {
1071 source: a.clone(),
1072 dest: b.clone(),
1073 from_name: "A".into(),
1074 to_name: "B".into(),
1075 backlinks: vec![],
1076 },
1077 )
1078 .unwrap();
1079
1080 let vault = Vault::with_root(dir.path().to_path_buf());
1082 RenameBuilder::new("notes", "C", "D")
1083 .execute(&vault)
1084 .unwrap();
1085
1086 assert!(!a.exists(), "A.md should be gone (replayed journal)");
1089 assert!(b.is_file(), "B.md should exist (replayed journal)");
1090 assert!(!c.exists(), "C.md should be gone (new rename)");
1091 assert!(d.is_file(), "D.md should exist (new rename)");
1092
1093 assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
1095 }
1096
1097 #[test]
1098 fn concurrent_updates_serialize_via_vault_lock() {
1099 use std::fs;
1107 use std::sync::Arc;
1108 use std::thread;
1109 use tempfile::TempDir;
1110
1111 let dir = TempDir::new().unwrap();
1112 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1113 fs::create_dir(dir.path().join("notes")).unwrap();
1114 fs::write(
1115 dir.path().join("notes/race.md"),
1116 "---\nstatus: active\n---\nBody.\n",
1117 )
1118 .unwrap();
1119
1120 let vault_path = Arc::new(dir.path().to_path_buf());
1121
1122 let p1 = Arc::clone(&vault_path);
1123 let t1 = thread::spawn(move || {
1124 let vault = Vault::with_root((*p1).clone());
1125 let filter = Expr::Predicate(Predicate::Equals {
1126 field: "status".into(),
1127 value: Value::String("active".into()),
1128 });
1129 UpdateBuilder::new("notes", filter)
1130 .set("touched_by_t1", Value::Integer(1))
1131 .execute(&vault)
1132 .expect("t1 execute")
1133 });
1134
1135 let p2 = Arc::clone(&vault_path);
1136 let t2 = thread::spawn(move || {
1137 let vault = Vault::with_root((*p2).clone());
1138 let filter = Expr::Predicate(Predicate::Equals {
1139 field: "status".into(),
1140 value: Value::String("active".into()),
1141 });
1142 UpdateBuilder::new("notes", filter)
1143 .set("touched_by_t2", Value::Integer(1))
1144 .execute(&vault)
1145 .expect("t2 execute")
1146 });
1147
1148 let r1 = t1.join().unwrap();
1149 let r2 = t2.join().unwrap();
1150 assert_eq!(r1.errors.len(), 0);
1151 assert_eq!(r2.errors.len(), 0);
1152
1153 let final_content = fs::read_to_string(dir.path().join("notes/race.md")).unwrap();
1157 assert!(
1158 final_content.contains("touched_by_t1"),
1159 "t1's edit lost; concurrent writer race: {}",
1160 final_content
1161 );
1162 assert!(
1163 final_content.contains("touched_by_t2"),
1164 "t2's edit lost; concurrent writer race: {}",
1165 final_content
1166 );
1167 }
1168
1169 #[test]
1170 fn atomic_write_does_not_leave_partial_files_on_failed_writes() {
1171 use std::fs;
1177 use tempfile::TempDir;
1178
1179 let dir = TempDir::new().unwrap();
1180 let target = dir.path().join("subdir/that-does-not-exist/x.md");
1181 let result = crate::writer::atomic_write(&target, "new content");
1184 assert!(
1185 result.is_err(),
1186 "expected atomic_write to fail when parent dir doesn't exist"
1187 );
1188
1189 let real_dir = dir.path().join("real");
1192 fs::create_dir(&real_dir).unwrap();
1193 let real_target = real_dir.join("x.md");
1194 fs::write(&real_target, "original").unwrap();
1195 crate::writer::atomic_write(&real_target, "replacement").unwrap();
1196 let after = fs::read_to_string(&real_target).unwrap();
1197 assert_eq!(after, "replacement");
1198
1199 let leftovers: Vec<_> = fs::read_dir(&real_dir)
1201 .unwrap()
1202 .flatten()
1203 .filter(|e| e.file_name().to_string_lossy().starts_with(".tmp"))
1204 .collect();
1205 assert!(
1206 leftovers.is_empty(),
1207 "expected no tempfile leftovers, found: {:?}",
1208 leftovers.iter().map(|e| e.path()).collect::<Vec<_>>()
1209 );
1210 }
1211}