Skip to main content

vaultdb_core/
mutation.rs

1//! Public typed mutation API for vault edits.
2//!
3//! Each builder exposes two methods: `plan(&self, vault) -> Result<MutationReport>`
4//! produces a read-only preview without touching disk, and
5//! `execute(self, vault) -> Result<MutationReport>` applies the planned changes
6//! and returns the same shape of report.
7//!
8//! The report shape is intentionally small — a vector of `PlannedChange` (path
9//! plus a human-readable description) and a vector of `MutationError` for any
10//! per-record failures. Consumers that need before/after frontmatter snapshots
11//! can compute them by running their own diff against the records on disk.
12
13use 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/// A report of changes a builder would (or did) make.
22#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23pub struct MutationReport {
24    pub changes: Vec<PlannedChange>,
25    pub errors: Vec<MutationError>,
26}
27
28/// A single planned (or applied) change to one record.
29#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
30pub struct PlannedChange {
31    pub path: PathBuf,
32    pub description: String,
33}
34
35/// A failure to apply a single change.
36#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
37pub struct MutationError {
38    pub path: PathBuf,
39    pub message: String,
40}
41
42// ── UpdateBuilder ──────────────────────────────────────────────────────────
43
44/// Build an update mutation. The `filter` selects records; the chained
45/// `set`/`unset`/`add_tag`/`remove_tag` calls accumulate operations applied
46/// to each matching record's frontmatter.
47#[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    /// Replace the [`writer::WriteOptions`] used by `execute`. See its
92    /// docs for what each field controls. Defaults to fast (no fsync).
93    pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
94        self.write_options = opts;
95        self
96    }
97
98    /// Convenience: enable durable writes (fsync data + parent dir).
99    /// Equivalent to `.write_options(WriteOptions::durable())`.
100    pub fn fsync(mut self, yes: bool) -> Self {
101        self.write_options.fsync = yes;
102        self
103    }
104
105    /// Compute the report without writing.
106    pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
107        let (report, _writes) = self.compute(vault)?;
108        Ok(report)
109    }
110
111    /// Plan, then apply each computed `WriteResult` to disk.
112    ///
113    /// Holds the vault-scoped exclusive lock (see [`crate::lock`]) for the
114    /// entire duration of compute + writes, so concurrent mutations from
115    /// other vaultdb-core consumers serialize cleanly. Each individual
116    /// file write is atomic via tempfile+rename — readers never see a
117    /// partial write.
118    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// ── DeleteBuilder ──────────────────────────────────────────────────────────
221
222/// Build a delete mutation. Records matching `filter` are moved to
223/// `<vault>/.trash/` by default (collision-safe). With `permanent(true)`,
224/// files are removed entirely.
225#[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    /// Replace the [`writer::WriteOptions`] used by `execute`.
249    pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
250        self.write_options = opts;
251        self
252    }
253
254    /// Convenience: enable durable deletes (fsync the parent dir so the
255    /// dirent change is on disk before this returns).
256    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            // Track parents to fsync at the end if durability requested.
299            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            // Honour the fsync write option: flush every modified parent
335            // directory's dirent so the metadata changes are durable.
336            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// ── MoveBuilder ────────────────────────────────────────────────────────────
374
375/// Build a move mutation. Records matching `filter` are relocated into
376/// `to_folder` (created if needed). Filename collisions at the destination
377/// surface as `MutationError`s in the report.
378#[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    /// Replace the [`writer::WriteOptions`] used by `execute`.
397    pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
398        self.write_options = opts;
399        self
400    }
401
402    /// Convenience: enable durable moves (fsync source + dest dirents).
403    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// ── RenameBuilder ──────────────────────────────────────────────────────────
502
503/// Build a rename mutation. The single record at `<folder>/<from>.md` is
504/// renamed to `<folder>/<to>.md`, and every `[[wikilink]]` across the vault
505/// pointing at `from` is rewritten to point at `to`.
506///
507/// Handled wikilink shapes: `[[from]]`, `[[from|alias]]`, `[[from#section]]`,
508/// `[[from#section|alias]]`.
509#[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    /// Replace the [`writer::WriteOptions`] used by `execute`.
528    pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
529        self.write_options = opts;
530        self
531    }
532
533    /// Convenience: enable durable rename + backlink rewrites (fsync
534    /// every modified file's data and every modified directory's dirent).
535    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        // Find every record that links to `self.from` and add a planned
569        // backlink-rewrite change.
570        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            // Recover any pending journals from previous crashed renames
587            // before we start a new one. Without this, a stale journal
588            // could be replayed *after* our new rename, producing
589            // surprising results (e.g. backlink rewrites for an old
590            // rename happening on top of a new one). Replay first means
591            // the vault is in a clean known state when we start.
592            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 the plan reported errors at the source/dest stage, don't proceed.
600            if !report.errors.is_empty() {
601                return Ok(report);
602            }
603
604            // Build and write the journal BEFORE any disk-modifying
605            // step. If the process dies between this point and the
606            // final delete-journal call, the next mutation (or an
607            // explicit `Vault::recover` call) will replay it.
608            let backlinks: Vec<PathBuf> = report
609                .changes
610                .iter()
611                .skip(1) // first change is the rename itself
612                .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            // Now do the rename. If this fails, drop the journal — the
624            // vault is unchanged, no recovery work to do.
625            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            // Rewrite incoming wikilinks atomically (tempfile + rename
637            // per file). Each rewrite is itself idempotent so a partial
638            // run + journal replay reaches the same end state.
639            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            // Fsync every dir whose dirent changed (the source's parent
673            // for the removed entry, the dest's parent for the added
674            // entry). atomic_write_with already fsynced the backlinks'
675            // parents internally when fsync was on.
676            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            // All rewrites attempted. If any failed, leave the journal
688            // so the next replay sweep retries them. If everything
689            // succeeded, drop the journal — recovery has nothing to do.
690            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
702/// Rewrite `[[from]]` (and `[[from|alias]]`, `[[from#section]]`,
703/// `[[from#section|alias]]`) to point at `to`.
704pub(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
711/// Render a `Value` as a YAML scalar suitable for inline frontmatter.
712///
713/// String values are quoted via `writer::quote_value` to match the existing
714/// writer's escape rules. Lists and maps fall back to `serde_yaml::to_string`
715/// (trimmed) for a flow-style representation.
716fn 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        // 1 rename + 1 backlink rewrite = 2 changes
839        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        // Source file should be untouched
866        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        // plan() does not touch disk
897        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        // execute() applies the change
905        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        // b.md was NOT touched (its status is pending, doesn't match the filter)
910        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        // We can't easily test that fsync was actually called (no kernel
917        // hook from user space), but we can test that:
918        // 1. The fsync(true) builder method round-trips through to
919        //    write_options.fsync = true.
920        // 2. An update with fsync(true) still produces correct content
921        //    on disk.
922        // 3. WriteOptions::durable() is a shorthand for { fsync: true }.
923        use std::fs;
924        use tempfile::TempDir;
925
926        // 1. The fluent API.
927        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        // 2. Real execute with fsync=true still produces correct content.
943        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        // Successful rename writes a journal then deletes it. After the
973        // call, the journal directory should be empty (or absent).
974        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        // Simulate a crashed rename by hand-writing a journal that points
1006        // at an unrenamed source file with stale backlinks. Vault::recover
1007        // should pick it up and complete the work.
1008        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        // Plant a journal as if a previous rename had crashed mid-flight.
1024        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        // Source renamed, backlink rewritten, journal gone.
1038        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        // A pending journal from a previous crashed rename must be
1049        // replayed before the next mutation starts. Otherwise we'd be
1050        // operating on a stale view of the vault.
1051        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        // Pre-existing files: A.md (will be renamed to B.md by the
1058        // pending journal), and C.md (will be renamed to D.md by the
1059        // new RenameBuilder call).
1060        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        // Plant a pending journal that says "rename A -> B".
1068        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        // Now call execute() on a separate rename C -> D.
1081        let vault = Vault::with_root(dir.path().to_path_buf());
1082        RenameBuilder::new("notes", "C", "D")
1083            .execute(&vault)
1084            .unwrap();
1085
1086        // BOTH renames must have completed: the journal-replay before the
1087        // new rename did A -> B, and the new RenameBuilder did C -> D.
1088        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        // No leftover journals.
1094        assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
1095    }
1096
1097    #[test]
1098    fn concurrent_updates_serialize_via_vault_lock() {
1099        // Two threads each run an UpdateBuilder against the same vault.
1100        // Without the lock, both would read a.md, both would compute their
1101        // edit against the same baseline, and the second writer would
1102        // clobber the first's change. With the lock, the second thread
1103        // waits for the first to finish, re-reads the (now-updated) file,
1104        // and applies its change on top. The result should reflect both
1105        // edits, in some serial order.
1106        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        // The final file content must contain BOTH edits. If the lock
1154        // failed, exactly one would survive (whichever thread wrote last)
1155        // because the slower thread's snapshot would be stale.
1156        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        // Direct test of the writer::atomic_write contract: if the write
1172        // is interrupted, readers should never observe partial content.
1173        // We simulate a failed write by trying to atomic_write to a path
1174        // whose parent doesn't exist — that fails cleanly, and the
1175        // ORIGINAL file (if any) should be untouched.
1176        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        // Pre-existing target shouldn't be touched (there is none here, but
1182        // the atomic_write itself must fail cleanly without side effects).
1183        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        // Now: create a target with original content, then try a write
1190        // that succeeds. Verify the file is fully replaced (no merge).
1191        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        // No leftover tempfile in the directory.
1200        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}