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            // `load_records_with_content` (called above) guarantees raw_content
152            // is populated. A None here means the loader's invariant was
153            // violated — surface as Internal so the caller sees a real bug
154            // rather than a per-record "soft" error.
155            let mut content = record
156                .raw_content
157                .as_ref()
158                .ok_or_else(|| {
159                    VaultdbError::Internal(format!(
160                        "record at {} has no raw_content; UpdateBuilder loaded without content",
161                        record.path.display()
162                    ))
163                })?
164                .clone();
165            let original_content = content.clone();
166            let mut wr_changes = Vec::new();
167            let mut description_parts: Vec<String> = Vec::new();
168
169            let result: Result<()> = (|| {
170                for (field, value) in &self.set_fields {
171                    let value_str = render_value_for_yaml(value);
172                    let (new_content, change) = writer::set_field(&content, field, &value_str)?;
173                    description_parts.push(format!("{}", change));
174                    wr_changes.push(change);
175                    content = new_content;
176                }
177                for field in &self.unset_fields {
178                    let (new_content, change) = writer::unset_field(&content, field)?;
179                    description_parts.push(format!("{}", change));
180                    wr_changes.push(change);
181                    content = new_content;
182                }
183                for tag in &self.add_tags {
184                    let (new_content, change) = writer::add_tag(&content, tag)?;
185                    description_parts.push(format!("{}", change));
186                    wr_changes.push(change);
187                    content = new_content;
188                }
189                for tag in &self.remove_tags {
190                    let (new_content, change) = writer::remove_tag(&content, tag)?;
191                    description_parts.push(format!("{}", change));
192                    wr_changes.push(change);
193                    content = new_content;
194                }
195                Ok(())
196            })();
197
198            match result {
199                Ok(_) => {
200                    if !wr_changes.is_empty() {
201                        writes.push(WriteResult {
202                            path: record.path.clone(),
203                            original_content,
204                            modified_content: content,
205                            changes: wr_changes,
206                        });
207                        changes.push(PlannedChange {
208                            path: record.path.clone(),
209                            description: description_parts.join("; "),
210                        });
211                    }
212                }
213                Err(e) => errors.push(MutationError {
214                    path: record.path.clone(),
215                    message: e.to_string(),
216                }),
217            }
218        }
219
220        Ok((MutationReport { changes, errors }, writes))
221    }
222}
223
224// ── DeleteBuilder ──────────────────────────────────────────────────────────
225
226/// Build a delete mutation. Records matching `filter` are moved to
227/// `<vault>/.trash/` by default (collision-safe). With `permanent(true)`,
228/// files are removed entirely.
229#[derive(Debug, Clone)]
230pub struct DeleteBuilder {
231    filter: Expr,
232    folder: String,
233    permanent: bool,
234    write_options: writer::WriteOptions,
235}
236
237impl DeleteBuilder {
238    pub fn new(folder: impl Into<String>, filter: Expr) -> Self {
239        Self {
240            filter,
241            folder: folder.into(),
242            permanent: false,
243            write_options: writer::WriteOptions::default(),
244        }
245    }
246
247    pub fn permanent(mut self, yes: bool) -> Self {
248        self.permanent = yes;
249        self
250    }
251
252    /// Replace the [`writer::WriteOptions`] used by `execute`.
253    pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
254        self.write_options = opts;
255        self
256    }
257
258    /// Convenience: enable durable deletes (fsync the parent dir so the
259    /// dirent change is on disk before this returns).
260    pub fn fsync(mut self, yes: bool) -> Self {
261        self.write_options.fsync = yes;
262        self
263    }
264
265    pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
266        let folder_path = vault.resolve_folder(&self.folder)?;
267        let load = vault.load_records(&folder_path, false, false)?;
268        let needs_links = crate::filter::expr_uses_links(&self.filter);
269        let link_index = if needs_links {
270            Some(crate::links::LinkGraph::build_with_root(
271                &load.records,
272                Some(&vault.root),
273            ))
274        } else {
275            None
276        };
277
278        let mut changes = Vec::new();
279        for r in &load.records {
280            if !crate::filter::evaluate_expr(&self.filter, r, &vault.root, link_index.as_ref()) {
281                continue;
282            }
283            changes.push(PlannedChange {
284                path: r.path.clone(),
285                description: if self.permanent {
286                    "delete (permanent)".to_string()
287                } else {
288                    "move to .trash/".to_string()
289                },
290            });
291        }
292        Ok(MutationReport {
293            changes,
294            errors: Vec::new(),
295        })
296    }
297
298    pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
299        crate::lock::with_lock(&vault.root, || {
300            let report = self.plan(vault)?;
301            let mut errors = Vec::new();
302            // Track parents to fsync at the end if durability requested.
303            let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
304                std::collections::BTreeSet::new();
305
306            if self.permanent {
307                for change in &report.changes {
308                    if let Err(e) = std::fs::remove_file(&change.path) {
309                        errors.push(MutationError {
310                            path: change.path.clone(),
311                            message: format!("remove failed: {}", e),
312                        });
313                    } else if let Some(parent) = change.path.parent() {
314                        dirs_to_fsync.insert(parent.to_path_buf());
315                    }
316                }
317            } else {
318                let trash_dir = vault.root.join(".trash");
319                if !report.changes.is_empty() {
320                    std::fs::create_dir_all(&trash_dir).map_err(VaultdbError::Io)?;
321                }
322                for change in &report.changes {
323                    let dest = unique_in_dir(&trash_dir, &change.path);
324                    if let Err(e) = std::fs::rename(&change.path, &dest) {
325                        errors.push(MutationError {
326                            path: change.path.clone(),
327                            message: format!("trash failed: {}", e),
328                        });
329                    } else {
330                        if let Some(parent) = change.path.parent() {
331                            dirs_to_fsync.insert(parent.to_path_buf());
332                        }
333                        dirs_to_fsync.insert(trash_dir.clone());
334                    }
335                }
336            }
337
338            // Honour the fsync write option: flush every modified parent
339            // directory's dirent so the metadata changes are durable.
340            if self.write_options.fsync {
341                for d in &dirs_to_fsync {
342                    if let Err(e) = writer::fsync_dir(d) {
343                        errors.push(MutationError {
344                            path: d.clone(),
345                            message: format!("fsync_dir failed: {}", e),
346                        });
347                    }
348                }
349            }
350
351            Ok(MutationReport {
352                changes: report.changes,
353                errors,
354            })
355        })
356    }
357}
358
359fn unique_in_dir(dir: &std::path::Path, src: &std::path::Path) -> PathBuf {
360    let filename = src.file_name().and_then(|n| n.to_str()).unwrap_or("file");
361    let candidate = dir.join(filename);
362    if !candidate.exists() {
363        return candidate;
364    }
365    let stem = src.file_stem().and_then(|n| n.to_str()).unwrap_or("file");
366    let ext = src.extension().and_then(|n| n.to_str()).unwrap_or("md");
367    let mut i = 1;
368    loop {
369        let c = dir.join(format!("{}-{}.{}", stem, i, ext));
370        if !c.exists() {
371            return c;
372        }
373        i += 1;
374    }
375}
376
377// ── MoveBuilder ────────────────────────────────────────────────────────────
378
379/// Build a move mutation. Records matching `filter` are relocated into
380/// `to_folder` (created if needed). Filename collisions at the destination
381/// surface as `MutationError`s in the report.
382#[derive(Debug, Clone)]
383pub struct MoveBuilder {
384    filter: Expr,
385    folder: String,
386    to_folder: String,
387    write_options: writer::WriteOptions,
388}
389
390impl MoveBuilder {
391    pub fn new(folder: impl Into<String>, to_folder: impl Into<String>, filter: Expr) -> Self {
392        Self {
393            filter,
394            folder: folder.into(),
395            to_folder: to_folder.into(),
396            write_options: writer::WriteOptions::default(),
397        }
398    }
399
400    /// Replace the [`writer::WriteOptions`] used by `execute`.
401    pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
402        self.write_options = opts;
403        self
404    }
405
406    /// Convenience: enable durable moves (fsync source + dest dirents).
407    pub fn fsync(mut self, yes: bool) -> Self {
408        self.write_options.fsync = yes;
409        self
410    }
411
412    pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
413        let folder_path = vault.resolve_folder(&self.folder)?;
414        let to_path = vault.root.join(&self.to_folder);
415        let load = vault.load_records(&folder_path, false, false)?;
416        let needs_links = crate::filter::expr_uses_links(&self.filter);
417        let link_index = if needs_links {
418            Some(crate::links::LinkGraph::build_with_root(
419                &load.records,
420                Some(&vault.root),
421            ))
422        } else {
423            None
424        };
425
426        let mut changes = Vec::new();
427        let mut errors = Vec::new();
428
429        for r in &load.records {
430            if !crate::filter::evaluate_expr(&self.filter, r, &vault.root, link_index.as_ref()) {
431                continue;
432            }
433            let filename = match r.path.file_name() {
434                Some(n) => n,
435                None => continue,
436            };
437            let dest = to_path.join(filename);
438            if dest.exists() {
439                errors.push(MutationError {
440                    path: r.path.clone(),
441                    message: format!(
442                        "move conflict: {} already exists in {}",
443                        filename.to_string_lossy(),
444                        self.to_folder
445                    ),
446                });
447                continue;
448            }
449            changes.push(PlannedChange {
450                path: r.path.clone(),
451                description: format!("move to {}", dest.display()),
452            });
453        }
454        Ok(MutationReport { changes, errors })
455    }
456
457    pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
458        crate::lock::with_lock(&vault.root, || {
459            let to_path = vault.root.join(&self.to_folder);
460            let report = self.plan(vault)?;
461            if !report.changes.is_empty() {
462                std::fs::create_dir_all(&to_path).map_err(VaultdbError::Io)?;
463            }
464            let mut errors = report.errors;
465            let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
466                std::collections::BTreeSet::new();
467            for change in &report.changes {
468                let filename = match change.path.file_name() {
469                    Some(n) => n,
470                    None => continue,
471                };
472                let dest = to_path.join(filename);
473                if let Err(e) = std::fs::rename(&change.path, &dest) {
474                    errors.push(MutationError {
475                        path: change.path.clone(),
476                        message: format!("rename failed: {}", e),
477                    });
478                } else {
479                    if let Some(parent) = change.path.parent() {
480                        dirs_to_fsync.insert(parent.to_path_buf());
481                    }
482                    dirs_to_fsync.insert(to_path.clone());
483                }
484            }
485
486            if self.write_options.fsync {
487                for d in &dirs_to_fsync {
488                    if let Err(e) = writer::fsync_dir(d) {
489                        errors.push(MutationError {
490                            path: d.clone(),
491                            message: format!("fsync_dir failed: {}", e),
492                        });
493                    }
494                }
495            }
496
497            Ok(MutationReport {
498                changes: report.changes,
499                errors,
500            })
501        })
502    }
503}
504
505// ── RenameBuilder ──────────────────────────────────────────────────────────
506
507/// Build a rename mutation. The single record at `<folder>/<from>.md` is
508/// renamed to `<folder>/<to>.md`, and every `[[wikilink]]` across the vault
509/// pointing at `from` is rewritten to point at `to`.
510///
511/// Handled wikilink shapes: `[[from]]`, `[[from|alias]]`, `[[from#section]]`,
512/// `[[from#section|alias]]`.
513#[derive(Debug, Clone)]
514pub struct RenameBuilder {
515    folder: String,
516    from: String,
517    to: String,
518    write_options: writer::WriteOptions,
519}
520
521impl RenameBuilder {
522    pub fn new(folder: impl Into<String>, from: impl Into<String>, to: impl Into<String>) -> Self {
523        Self {
524            folder: folder.into(),
525            from: from.into(),
526            to: to.into(),
527            write_options: writer::WriteOptions::default(),
528        }
529    }
530
531    /// Replace the [`writer::WriteOptions`] used by `execute`.
532    pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
533        self.write_options = opts;
534        self
535    }
536
537    /// Convenience: enable durable rename + backlink rewrites (fsync
538    /// every modified file's data and every modified directory's dirent).
539    pub fn fsync(mut self, yes: bool) -> Self {
540        self.write_options.fsync = yes;
541        self
542    }
543
544    pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
545        let folder_path = vault.resolve_folder(&self.folder)?;
546        let source = folder_path.join(format!("{}.md", self.from));
547        let dest = folder_path.join(format!("{}.md", self.to));
548
549        let mut changes = Vec::new();
550        let mut errors = Vec::new();
551
552        if !source.is_file() {
553            errors.push(MutationError {
554                path: source.clone(),
555                message: format!("source `{}` not found", self.from),
556            });
557            return Ok(MutationReport { changes, errors });
558        }
559        if dest.exists() {
560            errors.push(MutationError {
561                path: dest.clone(),
562                message: format!("target `{}.md` already exists", self.to),
563            });
564            return Ok(MutationReport { changes, errors });
565        }
566
567        changes.push(PlannedChange {
568            path: source.clone(),
569            description: format!("rename to {}", dest.display()),
570        });
571
572        // Find every record that links to `self.from` and add a planned
573        // backlink-rewrite change.
574        let all = vault.load_records_with_content(&vault.root, true, false)?;
575        let graph = crate::links::LinkGraph::build_with_root(&all.records, Some(&vault.root));
576        for source_name in graph.incoming_links(&self.from) {
577            if let Some(record) = graph.record_by_name(source_name) {
578                changes.push(PlannedChange {
579                    path: record.path.clone(),
580                    description: format!("rewrite [[{}]] -> [[{}]]", self.from, self.to),
581                });
582            }
583        }
584
585        Ok(MutationReport { changes, errors })
586    }
587
588    pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
589        crate::lock::with_lock(&vault.root, || {
590            // Recover any pending journals from previous crashed renames
591            // before we start a new one. Without this, a stale journal
592            // could be replayed *after* our new rename, producing
593            // surprising results (e.g. backlink rewrites for an old
594            // rename happening on top of a new one). Replay first means
595            // the vault is in a clean known state when we start.
596            crate::journal::replay_all(&vault.root)?;
597
598            let folder_path = vault.resolve_folder(&self.folder)?;
599            let source = folder_path.join(format!("{}.md", self.from));
600            let dest = folder_path.join(format!("{}.md", self.to));
601
602            let report = self.plan(vault)?;
603            // If the plan reported errors at the source/dest stage, don't proceed.
604            if !report.errors.is_empty() {
605                return Ok(report);
606            }
607
608            // Build and write the journal BEFORE any disk-modifying
609            // step. If the process dies between this point and the
610            // final delete-journal call, the next mutation (or an
611            // explicit `Vault::recover` call) will replay it.
612            let backlinks: Vec<PathBuf> = report
613                .changes
614                .iter()
615                .skip(1) // first change is the rename itself
616                .map(|c| c.path.clone())
617                .collect();
618            let journal = crate::journal::RenameJournal {
619                source: source.clone(),
620                dest: dest.clone(),
621                from_name: self.from.clone(),
622                to_name: self.to.clone(),
623                backlinks,
624            };
625            let journal_path = crate::journal::write(&vault.root, &journal)?;
626
627            // Now do the rename. If this fails, drop the journal — the
628            // vault is unchanged, no recovery work to do.
629            if let Err(e) = std::fs::rename(&source, &dest) {
630                crate::journal::delete(&journal_path);
631                return Ok(MutationReport {
632                    changes: report.changes,
633                    errors: vec![MutationError {
634                        path: source,
635                        message: format!("rename failed: {}", e),
636                    }],
637                });
638            }
639
640            // Rewrite incoming wikilinks atomically (tempfile + rename
641            // per file). Each rewrite is itself idempotent so a partial
642            // run + journal replay reaches the same end state.
643            let mut errors = Vec::new();
644            let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
645                std::collections::BTreeSet::new();
646            if let Some(parent) = source.parent() {
647                dirs_to_fsync.insert(parent.to_path_buf());
648            }
649            if let Some(parent) = dest.parent() {
650                dirs_to_fsync.insert(parent.to_path_buf());
651            }
652            for change in report.changes.iter().skip(1) {
653                let path = &change.path;
654                let content = match std::fs::read_to_string(path) {
655                    Ok(c) => c,
656                    Err(e) => {
657                        errors.push(MutationError {
658                            path: path.clone(),
659                            message: format!("read failed: {}", e),
660                        });
661                        continue;
662                    }
663                };
664                let new_content = rewrite_wikilinks(&content, &self.from, &self.to);
665                if new_content == content {
666                    continue;
667                }
668                if let Err(e) = writer::atomic_write_with(path, &new_content, self.write_options) {
669                    errors.push(MutationError {
670                        path: path.clone(),
671                        message: format!("write failed: {}", e),
672                    });
673                }
674            }
675
676            // Fsync every dir whose dirent changed (the source's parent
677            // for the removed entry, the dest's parent for the added
678            // entry). atomic_write_with already fsynced the backlinks'
679            // parents internally when fsync was on.
680            if self.write_options.fsync {
681                for d in &dirs_to_fsync {
682                    if let Err(e) = writer::fsync_dir(d) {
683                        errors.push(MutationError {
684                            path: d.clone(),
685                            message: format!("fsync_dir failed: {}", e),
686                        });
687                    }
688                }
689            }
690
691            // All rewrites attempted. If any failed, leave the journal
692            // so the next replay sweep retries them. If everything
693            // succeeded, drop the journal — recovery has nothing to do.
694            if errors.is_empty() {
695                crate::journal::delete(&journal_path);
696            }
697
698            Ok(MutationReport {
699                changes: report.changes,
700                errors,
701            })
702        })
703    }
704}
705
706// ── CreateBuilder ──────────────────────────────────────────────────────────
707
708/// Build a create mutation. Writes a new `.md` file under `folder` with
709/// name `name` (the `.md` extension is appended automatically).
710///
711/// Frontmatter is composed in three layers, in order of precedence:
712///
713/// 1. Template — if `template(path)` is set, the template's frontmatter
714///    is parsed as the base. Template body is preserved.
715/// 2. `--set` overrides — anything set via `set()` overrides matching
716///    template fields.
717/// 3. Schema defaults — when `with_schema(schema)` is supplied, every
718///    `default:` / `default_expr:` whose field isn't already set by
719///    the template or `--set` is applied.
720///
721/// After composition, schema's `required:` list is checked. Missing
722/// required fields surface as `MutationError`s in the report, with the
723/// file NOT written.
724#[derive(Debug, Clone)]
725pub struct CreateBuilder {
726    folder: String,
727    name: String,
728    template: Option<String>,
729    set_fields: Vec<(String, Value)>,
730    schema: Option<crate::schema::CollectionSchema>,
731    write_options: writer::WriteOptions,
732}
733
734impl CreateBuilder {
735    pub fn new(folder: impl Into<String>, name: impl Into<String>) -> Self {
736        Self {
737            folder: folder.into(),
738            name: name.into(),
739            template: None,
740            set_fields: Vec::new(),
741            schema: None,
742            write_options: writer::WriteOptions::default(),
743        }
744    }
745
746    /// Path to a template file, relative to the vault root. The
747    /// template's frontmatter forms the base of the new note; its body
748    /// is preserved.
749    pub fn template(mut self, path: impl Into<String>) -> Self {
750        self.template = Some(path.into());
751        self
752    }
753
754    /// Set (or override) a frontmatter field. Layered on top of the
755    /// template; layered under schema defaults.
756    pub fn set(mut self, field: impl Into<String>, value: Value) -> Self {
757        self.set_fields.push((field.into(), value));
758        self
759    }
760
761    /// Attach the collection schema for this folder. When set:
762    /// `default:` / `default_expr:` fields are applied for any field
763    /// the user didn't supply, and `required:` is enforced before
764    /// writing.
765    pub fn with_schema(mut self, schema: crate::schema::CollectionSchema) -> Self {
766        self.schema = Some(schema);
767        self
768    }
769
770    pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
771        self.write_options = opts;
772        self
773    }
774
775    pub fn fsync(mut self, yes: bool) -> Self {
776        self.write_options.fsync = yes;
777        self
778    }
779
780    /// Compute the planned change without writing.
781    pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
782        let (report, _) = self.compute(vault)?;
783        Ok(report)
784    }
785
786    /// Plan and also return the file content that would be written.
787    /// Used by the CLI's `--dry-run` for create — the post-defaults
788    /// frontmatter is the value of the preview.
789    pub fn plan_with_content(&self, vault: &Vault) -> Result<(MutationReport, Option<String>)> {
790        let (report, write) = self.compute(vault)?;
791        Ok((report, write.map(|w| w.modified_content)))
792    }
793
794    /// Plan, then write the file atomically. Holds the vault-scoped
795    /// lock so concurrent creates against the same vault serialise
796    /// cleanly.
797    pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
798        crate::lock::with_lock(&vault.root, || {
799            let (report, write) = self.compute(vault)?;
800            if !report.errors.is_empty() {
801                return Ok(report);
802            }
803            if let Some(w) = write {
804                if let Some(parent) = w.path.parent()
805                    && !parent.exists()
806                {
807                    std::fs::create_dir_all(parent).map_err(VaultdbError::Io)?;
808                }
809                writer::atomic_write_with(&w.path, &w.modified_content, self.write_options)
810                    .map_err(VaultdbError::Io)?;
811            }
812            Ok(report)
813        })
814    }
815
816    fn compute(&self, vault: &Vault) -> Result<(MutationReport, Option<WriteResult>)> {
817        // We DON'T require the folder to exist on disk — create can
818        // make it. `vault.resolve_folder` errors on missing dirs, so
819        // we build the path ourselves and let the writer create the
820        // parent at execute time.
821        let folder_path = vault.root.join(&self.folder);
822        let filename = format!("{}.md", self.name);
823        let dest = folder_path.join(&filename);
824
825        let mut changes = Vec::new();
826        let mut errors = Vec::new();
827
828        if dest.exists() {
829            errors.push(MutationError {
830                path: dest.clone(),
831                message: format!("file already exists: {}", dest.display()),
832            });
833            return Ok((MutationReport { changes, errors }, None));
834        }
835
836        // 1. Template — parse its frontmatter into a typed map, keep
837        //    its body verbatim. No template → empty map + minimal body
838        //    (`# {name}` so the note isn't blank).
839        let (mut fields, body) = match &self.template {
840            Some(tmpl) => {
841                let tmpl_path = vault.root.join(tmpl);
842                if !tmpl_path.is_file() {
843                    errors.push(MutationError {
844                        path: tmpl_path.clone(),
845                        message: format!("template not found: {}", tmpl_path.display()),
846                    });
847                    return Ok((MutationReport { changes, errors }, None));
848                }
849                let raw = std::fs::read_to_string(&tmpl_path).map_err(VaultdbError::Io)?;
850                split_template(&raw)
851            }
852            None => (
853                std::collections::BTreeMap::<String, Value>::new(),
854                format!("\n# {}\n", self.name),
855            ),
856        };
857
858        // 2. --set overrides.
859        for (k, v) in &self.set_fields {
860            fields.insert(k.clone(), v.clone());
861        }
862
863        // 3. Schema defaults (only for unset fields).
864        if let Some(schema) = &self.schema {
865            for (name, fs) in &schema.fields {
866                if fields.contains_key(name) {
867                    continue;
868                }
869                if let Some(default) = &fs.default {
870                    fields.insert(name.clone(), default.clone());
871                } else if let Some(expr) = &fs.default_expr {
872                    match crate::schema::resolve_default_expr(expr) {
873                        Ok(v) => {
874                            fields.insert(name.clone(), v);
875                        }
876                        Err(e) => {
877                            errors.push(MutationError {
878                                path: dest.clone(),
879                                message: format!("resolving default_expr for '{}': {}", name, e),
880                            });
881                        }
882                    }
883                }
884            }
885
886            // 4. Required-field check, AFTER defaults have been applied.
887            for req in &schema.required {
888                let satisfied = matches!(fields.get(req), Some(v) if !matches!(v, Value::Null));
889                if !satisfied {
890                    errors.push(MutationError {
891                        path: dest.clone(),
892                        message: format!("required field missing: '{}'", req),
893                    });
894                }
895            }
896        }
897
898        if !errors.is_empty() {
899            return Ok((MutationReport { changes, errors }, None));
900        }
901
902        // 5. Render frontmatter + body. serde_yaml emits an unprefixed
903        //    mapping; we wrap with `---` delimiters. Empty field map
904        //    still gets `---\n---\n` so consumers can find a parse anchor.
905        let frontmatter_yaml = if fields.is_empty() {
906            String::new()
907        } else {
908            serde_yaml::to_string(&fields)
909                .map_err(|e| VaultdbError::SchemaError(format!("rendering frontmatter: {}", e)))?
910        };
911        let content = if frontmatter_yaml.is_empty() {
912            format!("---\n---\n{}", body)
913        } else {
914            format!("---\n{}---\n{}", frontmatter_yaml, body)
915        };
916
917        let field_count = fields.len();
918        let field_summary: String = fields.keys().cloned().collect::<Vec<_>>().join(", ");
919        let description = if field_count == 0 {
920            "create (no frontmatter fields)".to_string()
921        } else {
922            format!("create with {} field(s): {}", field_count, field_summary)
923        };
924
925        changes.push(PlannedChange {
926            path: dest.clone(),
927            description,
928        });
929
930        let write = WriteResult {
931            path: dest,
932            original_content: String::new(),
933            modified_content: content,
934            changes: Vec::new(),
935        };
936
937        Ok((MutationReport { changes, errors }, Some(write)))
938    }
939}
940
941/// Parse a template's frontmatter + body into a typed field map and
942/// the verbatim body string. Returns an empty map + the whole raw
943/// content as body when the template has no frontmatter delimiters.
944fn split_template(raw: &str) -> (std::collections::BTreeMap<String, Value>, String) {
945    use crate::frontmatter::{extract_frontmatter, parse_frontmatter};
946    match extract_frontmatter(raw) {
947        Some((yaml_text, body_start)) => {
948            let fields = parse_frontmatter(yaml_text).unwrap_or_default();
949            let body = raw[body_start..].to_string();
950            (fields, body)
951        }
952        None => (std::collections::BTreeMap::new(), raw.to_string()),
953    }
954}
955
956/// Rewrite `[[from]]` (and `[[from|alias]]`, `[[from#section]]`,
957/// `[[from#section|alias]]`) to point at `to`.
958pub(crate) fn rewrite_wikilinks(content: &str, from: &str, to: &str) -> String {
959    content
960        .replace(&format!("[[{}]]", from), &format!("[[{}]]", to))
961        .replace(&format!("[[{}|", from), &format!("[[{}|", to))
962        .replace(&format!("[[{}#", from), &format!("[[{}#", to))
963}
964
965/// Render a `Value` as a YAML scalar suitable for inline frontmatter.
966///
967/// String values are quoted via `writer::quote_value` to match the existing
968/// writer's escape rules. Lists and maps fall back to `serde_yaml::to_string`
969/// (trimmed) for a flow-style representation.
970fn render_value_for_yaml(v: &Value) -> String {
971    match v {
972        Value::Null => "null".to_string(),
973        Value::Bool(b) => b.to_string(),
974        Value::Integer(i) => i.to_string(),
975        Value::Float(f) => f.to_string(),
976        Value::String(s) => writer::quote_value(s),
977        Value::List(_) | Value::Map(_) => {
978            let yaml = serde_yaml::to_string(v).unwrap_or_default();
979            yaml.trim_end().to_string()
980        }
981    }
982}
983
984#[cfg(test)]
985mod tests {
986    use super::*;
987    use crate::query::Predicate;
988
989    #[test]
990    fn update_builder_chains() {
991        let filter = Expr::Predicate(Predicate::Equals {
992            field: "status".into(),
993            value: Value::String("active".into()),
994        });
995        let b = UpdateBuilder::new("notes", filter)
996            .set("priority", Value::Integer(1))
997            .unset("draft")
998            .add_tag("urgent")
999            .remove_tag("stale");
1000        assert_eq!(b.set_fields.len(), 1);
1001        assert_eq!(b.unset_fields.len(), 1);
1002        assert_eq!(b.add_tags.len(), 1);
1003        assert_eq!(b.remove_tags.len(), 1);
1004    }
1005
1006    #[test]
1007    fn delete_builder_trash_moves_to_dot_trash() {
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        fs::write(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
1014        let vault = Vault::with_root(dir.path().to_path_buf());
1015        let filter = Expr::Predicate(Predicate::Equals {
1016            field: "status".into(),
1017            value: Value::String("stale".into()),
1018        });
1019        let builder = DeleteBuilder::new("notes", filter);
1020        let report = builder.execute(&vault).unwrap();
1021        assert_eq!(report.changes.len(), 1);
1022        assert_eq!(report.errors.len(), 0);
1023        assert!(!dir.path().join("notes/a.md").exists());
1024        assert!(dir.path().join(".trash/a.md").exists());
1025    }
1026
1027    #[test]
1028    fn delete_builder_permanent_removes_file() {
1029        use std::fs;
1030        use tempfile::TempDir;
1031        let dir = TempDir::new().unwrap();
1032        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1033        fs::create_dir(dir.path().join("notes")).unwrap();
1034        fs::write(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
1035        let vault = Vault::with_root(dir.path().to_path_buf());
1036        let filter = Expr::Predicate(Predicate::Equals {
1037            field: "status".into(),
1038            value: Value::String("stale".into()),
1039        });
1040        let builder = DeleteBuilder::new("notes", filter).permanent(true);
1041        builder.execute(&vault).unwrap();
1042        assert!(!dir.path().join("notes/a.md").exists());
1043        assert!(!dir.path().join(".trash/a.md").exists());
1044    }
1045
1046    #[test]
1047    fn move_builder_relocates_files() {
1048        use std::fs;
1049        use tempfile::TempDir;
1050        let dir = TempDir::new().unwrap();
1051        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1052        fs::create_dir(dir.path().join("notes")).unwrap();
1053        fs::write(
1054            dir.path().join("notes/a.md"),
1055            "---\nstatus: archived\n---\n",
1056        )
1057        .unwrap();
1058        let vault = Vault::with_root(dir.path().to_path_buf());
1059        let filter = Expr::Predicate(Predicate::Equals {
1060            field: "status".into(),
1061            value: Value::String("archived".into()),
1062        });
1063        let builder = MoveBuilder::new("notes", "archive", filter);
1064        let report = builder.execute(&vault).unwrap();
1065        assert_eq!(report.changes.len(), 1);
1066        assert_eq!(report.errors.len(), 0);
1067        assert!(!dir.path().join("notes/a.md").exists());
1068        assert!(dir.path().join("archive/a.md").exists());
1069    }
1070
1071    #[test]
1072    fn rename_builder_renames_and_rewrites_links() {
1073        use std::fs;
1074        use tempfile::TempDir;
1075        let dir = TempDir::new().unwrap();
1076        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1077        fs::create_dir(dir.path().join("notes")).unwrap();
1078        fs::write(
1079            dir.path().join("notes/old.md"),
1080            "---\nstatus: x\n---\nBody\n",
1081        )
1082        .unwrap();
1083        fs::write(
1084            dir.path().join("notes/source.md"),
1085            "---\nstatus: y\n---\nLinks to [[old]] and [[old|alias]] and [[old#section]].\n",
1086        )
1087        .unwrap();
1088        let vault = Vault::with_root(dir.path().to_path_buf());
1089
1090        let builder = RenameBuilder::new("notes", "old", "new");
1091        let report = builder.execute(&vault).unwrap();
1092        // 1 rename + 1 backlink rewrite = 2 changes
1093        assert_eq!(report.changes.len(), 2);
1094        assert_eq!(report.errors.len(), 0);
1095        assert!(!dir.path().join("notes/old.md").exists());
1096        assert!(dir.path().join("notes/new.md").exists());
1097        let source_after = fs::read_to_string(dir.path().join("notes/source.md")).unwrap();
1098        assert!(source_after.contains("[[new]]"));
1099        assert!(source_after.contains("[[new|alias]]"));
1100        assert!(source_after.contains("[[new#section]]"));
1101        assert!(!source_after.contains("[[old"));
1102    }
1103
1104    #[test]
1105    fn rename_builder_target_conflict_returns_error() {
1106        use std::fs;
1107        use tempfile::TempDir;
1108        let dir = TempDir::new().unwrap();
1109        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1110        fs::create_dir(dir.path().join("notes")).unwrap();
1111        fs::write(dir.path().join("notes/old.md"), "---\nstatus: x\n---\n").unwrap();
1112        fs::write(dir.path().join("notes/new.md"), "---\nstatus: y\n---\n").unwrap();
1113        let vault = Vault::with_root(dir.path().to_path_buf());
1114        let report = RenameBuilder::new("notes", "old", "new")
1115            .execute(&vault)
1116            .unwrap();
1117        assert_eq!(report.changes.len(), 0);
1118        assert_eq!(report.errors.len(), 1);
1119        // Source file should be untouched
1120        assert!(dir.path().join("notes/old.md").exists());
1121    }
1122
1123    #[test]
1124    fn update_builder_plan_and_execute_against_a_temp_vault() {
1125        use std::fs;
1126        use tempfile::TempDir;
1127
1128        let dir = TempDir::new().unwrap();
1129        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1130        fs::create_dir(dir.path().join("notes")).unwrap();
1131        fs::write(
1132            dir.path().join("notes/a.md"),
1133            "---\nstatus: active\n---\nBody A\n",
1134        )
1135        .unwrap();
1136        fs::write(
1137            dir.path().join("notes/b.md"),
1138            "---\nstatus: pending\n---\nBody B\n",
1139        )
1140        .unwrap();
1141
1142        let vault = Vault::with_root(dir.path().to_path_buf());
1143
1144        let filter = Expr::Predicate(Predicate::Equals {
1145            field: "status".into(),
1146            value: Value::String("active".into()),
1147        });
1148        let builder = UpdateBuilder::new("notes", filter).set("priority", Value::Integer(1));
1149
1150        // plan() does not touch disk
1151        let plan_report = builder.plan(&vault).unwrap();
1152        assert_eq!(plan_report.changes.len(), 1);
1153        assert_eq!(plan_report.errors.len(), 0);
1154        assert!(plan_report.changes[0].path.ends_with("a.md"));
1155        let before = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
1156        assert!(!before.contains("priority"));
1157
1158        // execute() applies the change
1159        let exec_report = builder.execute(&vault).unwrap();
1160        assert_eq!(exec_report.changes.len(), 1);
1161        let after = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
1162        assert!(after.contains("priority"));
1163        // b.md was NOT touched (its status is pending, doesn't match the filter)
1164        let b_after = fs::read_to_string(dir.path().join("notes/b.md")).unwrap();
1165        assert!(!b_after.contains("priority"));
1166    }
1167
1168    #[test]
1169    fn write_options_fsync_propagates_through_update_builder() {
1170        // We can't easily test that fsync was actually called (no kernel
1171        // hook from user space), but we can test that:
1172        // 1. The fsync(true) builder method round-trips through to
1173        //    write_options.fsync = true.
1174        // 2. An update with fsync(true) still produces correct content
1175        //    on disk.
1176        // 3. WriteOptions::durable() is a shorthand for { fsync: true }.
1177        use std::fs;
1178        use tempfile::TempDir;
1179
1180        // 1. The fluent API.
1181        let f1 = Expr::Predicate(Predicate::Equals {
1182            field: "x".into(),
1183            value: Value::Integer(1),
1184        });
1185        let b = UpdateBuilder::new("notes", f1).fsync(true);
1186        assert!(b.write_options.fsync);
1187
1188        let f2 = Expr::Predicate(Predicate::Equals {
1189            field: "x".into(),
1190            value: Value::Integer(1),
1191        });
1192        let b =
1193            UpdateBuilder::new("notes", f2).write_options(crate::writer::WriteOptions::durable());
1194        assert!(b.write_options.fsync);
1195
1196        // 2. Real execute with fsync=true still produces correct content.
1197        let dir = TempDir::new().unwrap();
1198        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1199        fs::create_dir(dir.path().join("notes")).unwrap();
1200        fs::write(
1201            dir.path().join("notes/durable.md"),
1202            "---\nstatus: active\n---\nBody.\n",
1203        )
1204        .unwrap();
1205        let vault = Vault::with_root(dir.path().to_path_buf());
1206
1207        let f3 = Expr::Predicate(Predicate::Equals {
1208            field: "status".into(),
1209            value: Value::String("active".into()),
1210        });
1211        let report = UpdateBuilder::new("notes", f3)
1212            .set("priority", Value::Integer(99))
1213            .fsync(true)
1214            .execute(&vault)
1215            .unwrap();
1216        assert_eq!(report.changes.len(), 1);
1217        assert_eq!(report.errors.len(), 0);
1218
1219        let after = fs::read_to_string(dir.path().join("notes/durable.md")).unwrap();
1220        assert!(after.contains("priority: 99"));
1221        assert!(after.contains("status: active"));
1222    }
1223
1224    #[test]
1225    fn rename_clean_run_leaves_no_journal_behind() {
1226        // Successful rename writes a journal then deletes it. After the
1227        // call, the journal directory should be empty (or absent).
1228        use std::fs;
1229        use tempfile::TempDir;
1230        let dir = TempDir::new().unwrap();
1231        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1232        fs::create_dir(dir.path().join("notes")).unwrap();
1233        fs::write(
1234            dir.path().join("notes/old.md"),
1235            "---\nstatus: x\n---\nBody\n",
1236        )
1237        .unwrap();
1238        fs::write(
1239            dir.path().join("notes/source.md"),
1240            "---\nstatus: y\n---\nLinks to [[old]].\n",
1241        )
1242        .unwrap();
1243        let vault = Vault::with_root(dir.path().to_path_buf());
1244
1245        RenameBuilder::new("notes", "old", "new")
1246            .execute(&vault)
1247            .unwrap();
1248
1249        let pending = crate::journal::list_pending(dir.path()).unwrap();
1250        assert!(
1251            pending.is_empty(),
1252            "successful rename must not leave journals behind: {:?}",
1253            pending
1254        );
1255    }
1256
1257    #[test]
1258    fn rename_recovers_from_pre_existing_journal() {
1259        // Simulate a crashed rename by hand-writing a journal that points
1260        // at an unrenamed source file with stale backlinks. Vault::recover
1261        // should pick it up and complete the work.
1262        use std::fs;
1263        use tempfile::TempDir;
1264        let dir = TempDir::new().unwrap();
1265        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1266        fs::create_dir(dir.path().join("notes")).unwrap();
1267        let source = dir.path().join("notes/Stanford.md");
1268        let dest = dir.path().join("notes/Stanford University.md");
1269        let backlink = dir.path().join("notes/Application.md");
1270        fs::write(&source, "---\nkind: university\n---\nMain note.\n").unwrap();
1271        fs::write(
1272            &backlink,
1273            "---\nkind: application\n---\nApplied to [[Stanford]].\n",
1274        )
1275        .unwrap();
1276
1277        // Plant a journal as if a previous rename had crashed mid-flight.
1278        let journal = crate::journal::RenameJournal {
1279            source: source.clone(),
1280            dest: dest.clone(),
1281            from_name: "Stanford".into(),
1282            to_name: "Stanford University".into(),
1283            backlinks: vec![backlink.clone()],
1284        };
1285        crate::journal::write(dir.path(), &journal).unwrap();
1286
1287        let vault = Vault::with_root(dir.path().to_path_buf());
1288        let recovered = vault.recover().unwrap();
1289        assert_eq!(recovered, 1, "expected exactly one journal replayed");
1290
1291        // Source renamed, backlink rewritten, journal gone.
1292        assert!(!source.exists());
1293        assert!(dest.is_file());
1294        let backlink_content = fs::read_to_string(&backlink).unwrap();
1295        assert!(backlink_content.contains("[[Stanford University]]"));
1296        assert!(!backlink_content.contains("[[Stanford]]"));
1297        assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
1298    }
1299
1300    #[test]
1301    fn rename_replays_pending_journal_before_starting_new_rename() {
1302        // A pending journal from a previous crashed rename must be
1303        // replayed before the next mutation starts. Otherwise we'd be
1304        // operating on a stale view of the vault.
1305        use std::fs;
1306        use tempfile::TempDir;
1307        let dir = TempDir::new().unwrap();
1308        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1309        fs::create_dir(dir.path().join("notes")).unwrap();
1310
1311        // Pre-existing files: A.md (will be renamed to B.md by the
1312        // pending journal), and C.md (will be renamed to D.md by the
1313        // new RenameBuilder call).
1314        let a = dir.path().join("notes/A.md");
1315        let b = dir.path().join("notes/B.md");
1316        let c = dir.path().join("notes/C.md");
1317        let d = dir.path().join("notes/D.md");
1318        fs::write(&a, "---\n---\nA body.\n").unwrap();
1319        fs::write(&c, "---\n---\nC body.\n").unwrap();
1320
1321        // Plant a pending journal that says "rename A -> B".
1322        crate::journal::write(
1323            dir.path(),
1324            &crate::journal::RenameJournal {
1325                source: a.clone(),
1326                dest: b.clone(),
1327                from_name: "A".into(),
1328                to_name: "B".into(),
1329                backlinks: vec![],
1330            },
1331        )
1332        .unwrap();
1333
1334        // Now call execute() on a separate rename C -> D.
1335        let vault = Vault::with_root(dir.path().to_path_buf());
1336        RenameBuilder::new("notes", "C", "D")
1337            .execute(&vault)
1338            .unwrap();
1339
1340        // BOTH renames must have completed: the journal-replay before the
1341        // new rename did A -> B, and the new RenameBuilder did C -> D.
1342        assert!(!a.exists(), "A.md should be gone (replayed journal)");
1343        assert!(b.is_file(), "B.md should exist (replayed journal)");
1344        assert!(!c.exists(), "C.md should be gone (new rename)");
1345        assert!(d.is_file(), "D.md should exist (new rename)");
1346
1347        // No leftover journals.
1348        assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
1349    }
1350
1351    #[test]
1352    fn concurrent_updates_serialize_via_vault_lock() {
1353        // Two threads each run an UpdateBuilder against the same vault.
1354        // Without the lock, both would read a.md, both would compute their
1355        // edit against the same baseline, and the second writer would
1356        // clobber the first's change. With the lock, the second thread
1357        // waits for the first to finish, re-reads the (now-updated) file,
1358        // and applies its change on top. The result should reflect both
1359        // edits, in some serial order.
1360        use std::fs;
1361        use std::sync::Arc;
1362        use std::thread;
1363        use tempfile::TempDir;
1364
1365        let dir = TempDir::new().unwrap();
1366        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1367        fs::create_dir(dir.path().join("notes")).unwrap();
1368        fs::write(
1369            dir.path().join("notes/race.md"),
1370            "---\nstatus: active\n---\nBody.\n",
1371        )
1372        .unwrap();
1373
1374        let vault_path = Arc::new(dir.path().to_path_buf());
1375
1376        let p1 = Arc::clone(&vault_path);
1377        let t1 = thread::spawn(move || {
1378            let vault = Vault::with_root((*p1).clone());
1379            let filter = Expr::Predicate(Predicate::Equals {
1380                field: "status".into(),
1381                value: Value::String("active".into()),
1382            });
1383            UpdateBuilder::new("notes", filter)
1384                .set("touched_by_t1", Value::Integer(1))
1385                .execute(&vault)
1386                .expect("t1 execute")
1387        });
1388
1389        let p2 = Arc::clone(&vault_path);
1390        let t2 = thread::spawn(move || {
1391            let vault = Vault::with_root((*p2).clone());
1392            let filter = Expr::Predicate(Predicate::Equals {
1393                field: "status".into(),
1394                value: Value::String("active".into()),
1395            });
1396            UpdateBuilder::new("notes", filter)
1397                .set("touched_by_t2", Value::Integer(1))
1398                .execute(&vault)
1399                .expect("t2 execute")
1400        });
1401
1402        let r1 = t1.join().unwrap();
1403        let r2 = t2.join().unwrap();
1404        assert_eq!(r1.errors.len(), 0);
1405        assert_eq!(r2.errors.len(), 0);
1406
1407        // The final file content must contain BOTH edits. If the lock
1408        // failed, exactly one would survive (whichever thread wrote last)
1409        // because the slower thread's snapshot would be stale.
1410        let final_content = fs::read_to_string(dir.path().join("notes/race.md")).unwrap();
1411        assert!(
1412            final_content.contains("touched_by_t1"),
1413            "t1's edit lost; concurrent writer race: {}",
1414            final_content
1415        );
1416        assert!(
1417            final_content.contains("touched_by_t2"),
1418            "t2's edit lost; concurrent writer race: {}",
1419            final_content
1420        );
1421    }
1422
1423    #[test]
1424    fn atomic_write_does_not_leave_partial_files_on_failed_writes() {
1425        // Direct test of the writer::atomic_write contract: if the write
1426        // is interrupted, readers should never observe partial content.
1427        // We simulate a failed write by trying to atomic_write to a path
1428        // whose parent doesn't exist — that fails cleanly, and the
1429        // ORIGINAL file (if any) should be untouched.
1430        use std::fs;
1431        use tempfile::TempDir;
1432
1433        let dir = TempDir::new().unwrap();
1434        let target = dir.path().join("subdir/that-does-not-exist/x.md");
1435        // Pre-existing target shouldn't be touched (there is none here, but
1436        // the atomic_write itself must fail cleanly without side effects).
1437        let result = crate::writer::atomic_write(&target, "new content");
1438        assert!(
1439            result.is_err(),
1440            "expected atomic_write to fail when parent dir doesn't exist"
1441        );
1442
1443        // Now: create a target with original content, then try a write
1444        // that succeeds. Verify the file is fully replaced (no merge).
1445        let real_dir = dir.path().join("real");
1446        fs::create_dir(&real_dir).unwrap();
1447        let real_target = real_dir.join("x.md");
1448        fs::write(&real_target, "original").unwrap();
1449        crate::writer::atomic_write(&real_target, "replacement").unwrap();
1450        let after = fs::read_to_string(&real_target).unwrap();
1451        assert_eq!(after, "replacement");
1452
1453        // No leftover tempfile in the directory.
1454        let leftovers: Vec<_> = fs::read_dir(&real_dir)
1455            .unwrap()
1456            .flatten()
1457            .filter(|e| e.file_name().to_string_lossy().starts_with(".tmp"))
1458            .collect();
1459        assert!(
1460            leftovers.is_empty(),
1461            "expected no tempfile leftovers, found: {:?}",
1462            leftovers.iter().map(|e| e.path()).collect::<Vec<_>>()
1463        );
1464    }
1465
1466    // ── Phase 3: CreateBuilder ────────────────────────────────────────
1467
1468    use crate::schema::{CollectionSchema, FieldSchema};
1469
1470    fn movie_schema() -> CollectionSchema {
1471        let mut fields = std::collections::BTreeMap::new();
1472        fields.insert(
1473            "db-table".into(),
1474            FieldSchema {
1475                field_type: "string".into(),
1476                enum_values: vec![Value::String("movie".into())],
1477                min: None,
1478                max: None,
1479                default: Some(Value::String("movie".into())),
1480                default_expr: None,
1481            },
1482        );
1483        fields.insert(
1484            "status".into(),
1485            FieldSchema {
1486                field_type: "string".into(),
1487                enum_values: vec![
1488                    Value::String("to-watch".into()),
1489                    Value::String("watched".into()),
1490                ],
1491                min: None,
1492                max: None,
1493                default: Some(Value::String("to-watch".into())),
1494                default_expr: None,
1495            },
1496        );
1497        fields.insert(
1498            "director".into(),
1499            FieldSchema {
1500                field_type: "string".into(),
1501                enum_values: vec![],
1502                min: None,
1503                max: None,
1504                default: None,
1505                default_expr: None,
1506            },
1507        );
1508        fields.insert(
1509            "year".into(),
1510            FieldSchema {
1511                field_type: "integer".into(),
1512                enum_values: vec![],
1513                min: None,
1514                max: None,
1515                default: None,
1516                default_expr: None,
1517            },
1518        );
1519        CollectionSchema {
1520            description: None,
1521            folder: "Notes/movie".into(),
1522            filter: vec![],
1523            required: vec![
1524                "db-table".into(),
1525                "director".into(),
1526                "status".into(),
1527                "year".into(),
1528            ],
1529            fields,
1530        }
1531    }
1532
1533    fn vault_with_obsidian() -> tempfile::TempDir {
1534        let dir = tempfile::TempDir::new().unwrap();
1535        std::fs::create_dir(dir.path().join(".obsidian")).unwrap();
1536        dir
1537    }
1538
1539    #[test]
1540    fn create_without_schema_writes_minimal_file() {
1541        let dir = vault_with_obsidian();
1542        let vault = Vault::with_root(dir.path().to_path_buf());
1543        let report = CreateBuilder::new("Notes/movie", "Dune")
1544            .execute(&vault)
1545            .unwrap();
1546        assert_eq!(report.errors.len(), 0);
1547        assert_eq!(report.changes.len(), 1);
1548        let written = dir.path().join("Notes/movie/Dune.md");
1549        assert!(written.is_file());
1550        let content = std::fs::read_to_string(&written).unwrap();
1551        // Empty frontmatter + "# Dune" body.
1552        assert!(content.contains("---\n---"));
1553        assert!(content.contains("# Dune"));
1554    }
1555
1556    #[test]
1557    fn create_with_set_writes_typed_frontmatter() {
1558        let dir = vault_with_obsidian();
1559        let vault = Vault::with_root(dir.path().to_path_buf());
1560        CreateBuilder::new("Notes/movie", "Dune")
1561            .set("director", Value::String("Denis Villeneuve".into()))
1562            .set("year", Value::Integer(2021))
1563            .execute(&vault)
1564            .unwrap();
1565        let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1566        assert!(content.contains("director: Denis Villeneuve"));
1567        // YAML int rendering — no quotes.
1568        assert!(content.contains("year: 2021"));
1569    }
1570
1571    #[test]
1572    fn create_fills_schema_defaults() {
1573        let dir = vault_with_obsidian();
1574        let vault = Vault::with_root(dir.path().to_path_buf());
1575        CreateBuilder::new("Notes/movie", "Dune")
1576            .with_schema(movie_schema())
1577            .set("director", Value::String("Denis Villeneuve".into()))
1578            .set("year", Value::Integer(2021))
1579            .execute(&vault)
1580            .unwrap();
1581        let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1582        // Defaults applied.
1583        assert!(content.contains("db-table: movie"));
1584        assert!(content.contains("status: to-watch"));
1585        // User-supplied values preserved.
1586        assert!(content.contains("director: Denis Villeneuve"));
1587        assert!(content.contains("year: 2021"));
1588    }
1589
1590    #[test]
1591    fn create_set_overrides_default() {
1592        let dir = vault_with_obsidian();
1593        let vault = Vault::with_root(dir.path().to_path_buf());
1594        CreateBuilder::new("Notes/movie", "Watched")
1595            .with_schema(movie_schema())
1596            .set("director", Value::String("X".into()))
1597            .set("year", Value::Integer(2020))
1598            .set("status", Value::String("watched".into()))
1599            .execute(&vault)
1600            .unwrap();
1601        let content = std::fs::read_to_string(dir.path().join("Notes/movie/Watched.md")).unwrap();
1602        assert!(content.contains("status: watched"));
1603        assert!(!content.contains("status: to-watch"));
1604    }
1605
1606    #[test]
1607    fn create_rejects_missing_required_before_writing() {
1608        let dir = vault_with_obsidian();
1609        let vault = Vault::with_root(dir.path().to_path_buf());
1610        // No director / year → required check should fail.
1611        let report = CreateBuilder::new("Notes/movie", "Blank")
1612            .with_schema(movie_schema())
1613            .execute(&vault)
1614            .unwrap();
1615        assert!(!report.errors.is_empty());
1616        assert!(report.errors.iter().any(|e| e.message.contains("director")));
1617        assert!(report.errors.iter().any(|e| e.message.contains("year")));
1618        // File must NOT have been written.
1619        assert!(!dir.path().join("Notes/movie/Blank.md").exists());
1620    }
1621
1622    #[test]
1623    fn create_rejects_existing_file() {
1624        let dir = vault_with_obsidian();
1625        std::fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
1626        std::fs::write(dir.path().join("Notes/movie/Dune.md"), "existing\n").unwrap();
1627        let vault = Vault::with_root(dir.path().to_path_buf());
1628        let report = CreateBuilder::new("Notes/movie", "Dune")
1629            .execute(&vault)
1630            .unwrap();
1631        assert_eq!(report.errors.len(), 1);
1632        assert!(report.errors[0].message.contains("already exists"));
1633        // Original file preserved.
1634        let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1635        assert_eq!(content, "existing\n");
1636    }
1637
1638    #[test]
1639    fn create_resolves_default_expr_today() {
1640        let dir = vault_with_obsidian();
1641        let vault = Vault::with_root(dir.path().to_path_buf());
1642        let mut fields = std::collections::BTreeMap::new();
1643        fields.insert(
1644            "due".into(),
1645            FieldSchema {
1646                field_type: "date".into(),
1647                enum_values: vec![],
1648                min: None,
1649                max: None,
1650                default: None,
1651                default_expr: Some("today".into()),
1652            },
1653        );
1654        let schema = CollectionSchema {
1655            description: None,
1656            folder: "tasks".into(),
1657            filter: vec![],
1658            required: vec![],
1659            fields,
1660        };
1661        CreateBuilder::new("tasks", "t1")
1662            .with_schema(schema)
1663            .execute(&vault)
1664            .unwrap();
1665        let content = std::fs::read_to_string(dir.path().join("tasks/t1.md")).unwrap();
1666        // Match shape, not exact value (date depends on when test runs).
1667        let today = crate::record::today_string();
1668        assert!(
1669            content.contains(&format!("due: {}", today)),
1670            "expected due={} in: {}",
1671            today,
1672            content
1673        );
1674    }
1675
1676    #[test]
1677    fn create_plan_does_not_touch_disk() {
1678        let dir = vault_with_obsidian();
1679        let vault = Vault::with_root(dir.path().to_path_buf());
1680        let (report, content) = CreateBuilder::new("Notes/movie", "Dune")
1681            .with_schema(movie_schema())
1682            .set("director", Value::String("DV".into()))
1683            .set("year", Value::Integer(2021))
1684            .plan_with_content(&vault)
1685            .unwrap();
1686        assert_eq!(report.errors.len(), 0);
1687        assert_eq!(report.changes.len(), 1);
1688        assert!(!dir.path().join("Notes/movie/Dune.md").exists());
1689        let c = content.unwrap();
1690        assert!(c.contains("director: DV"));
1691        assert!(c.contains("db-table: movie")); // default applied even in plan
1692    }
1693
1694    #[test]
1695    fn create_from_template_preserves_body_and_merges_frontmatter() {
1696        let dir = vault_with_obsidian();
1697        std::fs::create_dir_all(dir.path().join("templates")).unwrap();
1698        std::fs::write(
1699            dir.path().join("templates/movie.md"),
1700            "---\nstatus: to-watch\naliases: []\n---\n\n# Title\n\nReview goes here.\n",
1701        )
1702        .unwrap();
1703        let vault = Vault::with_root(dir.path().to_path_buf());
1704        CreateBuilder::new("Notes/movie", "Dune")
1705            .template("templates/movie.md")
1706            .set("year", Value::Integer(2021))
1707            .execute(&vault)
1708            .unwrap();
1709        let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1710        // Template field preserved.
1711        assert!(content.contains("status: to-watch"));
1712        // --set added.
1713        assert!(content.contains("year: 2021"));
1714        // Body preserved.
1715        assert!(content.contains("Review goes here"));
1716    }
1717}