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    vault_schema: Option<crate::schema::VaultSchema>,
56    write_options: writer::WriteOptions,
57}
58
59impl UpdateBuilder {
60    pub fn new(folder: impl Into<String>, filter: Expr) -> Self {
61        Self {
62            filter,
63            folder: folder.into(),
64            set_fields: Vec::new(),
65            unset_fields: Vec::new(),
66            add_tags: Vec::new(),
67            remove_tags: Vec::new(),
68            vault_schema: None,
69            write_options: writer::WriteOptions::default(),
70        }
71    }
72
73    pub fn set(mut self, field: impl Into<String>, value: Value) -> Self {
74        self.set_fields.push((field.into(), value));
75        self
76    }
77
78    pub fn unset(mut self, field: impl Into<String>) -> Self {
79        self.unset_fields.push(field.into());
80        self
81    }
82
83    pub fn add_tag(mut self, tag: impl Into<String>) -> Self {
84        self.add_tags.push(tag.into());
85        self
86    }
87
88    pub fn remove_tag(mut self, tag: impl Into<String>) -> Self {
89        self.remove_tags.push(tag.into());
90        self
91    }
92
93    /// Attach the vault-wide schema. When set, every matched record's
94    /// **post-update** field map is validated against all applicable
95    /// collections (folder ancestor + filter match) before the writer
96    /// runs. A record whose projected state would violate any
97    /// applicable collection is reported as a `MutationError` and
98    /// skipped — the rest of the batch still proceeds. Strict mode:
99    /// the whole projected record must validate, so pre-existing
100    /// violations surface on the first update touching the record.
101    pub fn with_vault_schema(mut self, schema: crate::schema::VaultSchema) -> Self {
102        self.vault_schema = Some(schema);
103        self
104    }
105
106    /// Replace the [`writer::WriteOptions`] used by `execute`. See its
107    /// docs for what each field controls. Defaults to fast (no fsync).
108    pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
109        self.write_options = opts;
110        self
111    }
112
113    /// Convenience: enable durable writes (fsync data + parent dir).
114    /// Equivalent to `.write_options(WriteOptions::durable())`.
115    pub fn fsync(mut self, yes: bool) -> Self {
116        self.write_options.fsync = yes;
117        self
118    }
119
120    /// Compute the report without writing.
121    pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
122        let (report, _writes) = self.compute(vault)?;
123        Ok(report)
124    }
125
126    /// Plan, then apply each computed `WriteResult` to disk.
127    ///
128    /// Holds the vault-scoped exclusive lock (see [`crate::lock`]) for the
129    /// entire duration of compute + writes, so concurrent mutations from
130    /// other vaultdb-core consumers serialize cleanly. Each individual
131    /// file write is atomic via tempfile+rename — readers never see a
132    /// partial write.
133    pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
134        crate::lock::with_lock(&vault.root, || {
135            let (report, writes) = self.compute(vault)?;
136            for w in &writes {
137                writer::apply_with(w, self.write_options).map_err(VaultdbError::Io)?;
138            }
139            Ok(report)
140        })
141    }
142
143    fn compute(&self, vault: &Vault) -> Result<(MutationReport, Vec<WriteResult>)> {
144        let folder_path = vault.resolve_folder(&self.folder)?;
145        let load = vault.load_records_with_content(&folder_path, false, false)?;
146        let needs_links = crate::filter::expr_uses_links(&self.filter);
147        let link_index = if needs_links {
148            Some(crate::links::LinkGraph::build_with_root(
149                &load.records,
150                Some(&vault.root),
151            ))
152        } else {
153            None
154        };
155
156        let mut changes = Vec::new();
157        let mut errors = Vec::new();
158        let mut writes = Vec::new();
159
160        for record in &load.records {
161            if !crate::filter::evaluate_expr(&self.filter, record, &vault.root, link_index.as_ref())
162            {
163                continue;
164            }
165
166            // Strict schema check, if attached. Projects the mutation
167            // onto a typed copy of the record's fields and validates
168            // the post-update state against every applicable
169            // collection. Violations are reported per-record; the
170            // record is skipped (no writes for it) but the batch
171            // continues.
172            if let Some(vault_schema) = &self.vault_schema {
173                let projected_fields = self.project_fields(&record.fields);
174                let projected_record = crate::record::Record {
175                    path: record.path.clone(),
176                    fields: projected_fields.clone(),
177                    raw_content: record.raw_content.clone(),
178                };
179                let applicable = match vault_schema.applicable_collections(
180                    &self.folder,
181                    &projected_record,
182                    &vault.root,
183                ) {
184                    Ok(cols) => cols,
185                    Err(e) => {
186                        errors.push(MutationError {
187                            path: record.path.clone(),
188                            message: format!("evaluating schema applicability: {}", e),
189                        });
190                        continue;
191                    }
192                };
193                let mut seen = std::collections::BTreeSet::<(String, String)>::new();
194                let mut had_violation = false;
195                let filename = record.virtual_name();
196                for col in &applicable {
197                    for v in crate::schema::validate_record(&filename, &projected_fields, col) {
198                        if seen.insert((v.field.clone(), v.message.clone())) {
199                            errors.push(MutationError {
200                                path: record.path.clone(),
201                                message: format!("schema: {} — {}", v.field, v.message),
202                            });
203                            had_violation = true;
204                        }
205                    }
206                }
207                if had_violation {
208                    continue;
209                }
210            }
211
212            // `load_records_with_content` (called above) guarantees raw_content
213            // is populated. A None here means the loader's invariant was
214            // violated — surface as Internal so the caller sees a real bug
215            // rather than a per-record "soft" error.
216            let mut content = record
217                .raw_content
218                .as_ref()
219                .ok_or_else(|| {
220                    VaultdbError::Internal(format!(
221                        "record at {} has no raw_content; UpdateBuilder loaded without content",
222                        record.path.display()
223                    ))
224                })?
225                .clone();
226            let original_content = content.clone();
227            let mut wr_changes = Vec::new();
228            let mut description_parts: Vec<String> = Vec::new();
229
230            let result: Result<()> = (|| {
231                for (field, value) in &self.set_fields {
232                    // Scalars go through set_field_preformatted because
233                    // render_value_for_yaml already produces a valid YAML
234                    // scalar (calling writer::quote_value internally for
235                    // strings). Routing through plain set_field would
236                    // double-quote on disk — a URL value `https://x.com`
237                    // would land as `"'https://x.com'"`. See 1.3.1 changelog.
238                    // Lists and maps go through set_field_block so the
239                    // typed shape is preserved as block-style YAML rather
240                    // than flattened into a quoted scalar (1.2.1 fix).
241                    let (new_content, change) = match value {
242                        Value::List(_) | Value::Map(_) => {
243                            writer::set_field_block(&content, field, value)?
244                        }
245                        _ => {
246                            let value_str = render_value_for_yaml(value);
247                            writer::set_field_preformatted(&content, field, &value_str)?
248                        }
249                    };
250                    description_parts.push(format!("{}", change));
251                    wr_changes.push(change);
252                    content = new_content;
253                }
254                for field in &self.unset_fields {
255                    let (new_content, change) = writer::unset_field(&content, field)?;
256                    description_parts.push(format!("{}", change));
257                    wr_changes.push(change);
258                    content = new_content;
259                }
260                for tag in &self.add_tags {
261                    let (new_content, change) = writer::add_tag(&content, tag)?;
262                    description_parts.push(format!("{}", change));
263                    wr_changes.push(change);
264                    content = new_content;
265                }
266                for tag in &self.remove_tags {
267                    let (new_content, change) = writer::remove_tag(&content, tag)?;
268                    description_parts.push(format!("{}", change));
269                    wr_changes.push(change);
270                    content = new_content;
271                }
272                Ok(())
273            })();
274
275            match result {
276                Ok(_) => {
277                    if !wr_changes.is_empty() {
278                        writes.push(WriteResult {
279                            path: record.path.clone(),
280                            original_content,
281                            modified_content: content,
282                            changes: wr_changes,
283                        });
284                        changes.push(PlannedChange {
285                            path: record.path.clone(),
286                            description: description_parts.join("; "),
287                        });
288                    }
289                }
290                Err(e) => errors.push(MutationError {
291                    path: record.path.clone(),
292                    message: e.to_string(),
293                }),
294            }
295        }
296
297        Ok((MutationReport { changes, errors }, writes))
298    }
299
300    /// Produce the typed post-update field map for a single record,
301    /// in the same order writer ops will eventually apply: set first
302    /// (replaces / inserts), then unset, then add_tag, then remove_tag.
303    /// Tag ops mutate the `tags` field as `Value::List<Value::String>`
304    /// — duplicates are preserved (matching writer::add_tag, which
305    /// doesn't dedup) and a missing tag on remove is silently skipped
306    /// (the writer itself will surface that error at write time).
307    fn project_fields(
308        &self,
309        original: &std::collections::BTreeMap<String, Value>,
310    ) -> std::collections::BTreeMap<String, Value> {
311        let mut fields = original.clone();
312        for (k, v) in &self.set_fields {
313            fields.insert(k.clone(), v.clone());
314        }
315        for k in &self.unset_fields {
316            fields.remove(k);
317        }
318        if !self.add_tags.is_empty() || !self.remove_tags.is_empty() {
319            let mut tags_list: Vec<Value> = match fields.get("tags") {
320                Some(Value::List(l)) => l.clone(),
321                _ => Vec::new(),
322            };
323            for t in &self.add_tags {
324                tags_list.push(Value::String(t.clone()));
325            }
326            for t in &self.remove_tags {
327                if let Some(idx) = tags_list
328                    .iter()
329                    .position(|v| matches!(v, Value::String(s) if s == t))
330                {
331                    tags_list.remove(idx);
332                }
333            }
334            fields.insert("tags".to_string(), Value::List(tags_list));
335        }
336        fields
337    }
338}
339
340// ── DeleteBuilder ──────────────────────────────────────────────────────────
341
342/// Build a delete mutation. Records matching `filter` are moved to
343/// `<vault>/.trash/` by default (collision-safe). With `permanent(true)`,
344/// files are removed entirely.
345#[derive(Debug, Clone)]
346pub struct DeleteBuilder {
347    filter: Expr,
348    folder: String,
349    permanent: bool,
350    write_options: writer::WriteOptions,
351}
352
353impl DeleteBuilder {
354    pub fn new(folder: impl Into<String>, filter: Expr) -> Self {
355        Self {
356            filter,
357            folder: folder.into(),
358            permanent: false,
359            write_options: writer::WriteOptions::default(),
360        }
361    }
362
363    pub fn permanent(mut self, yes: bool) -> Self {
364        self.permanent = yes;
365        self
366    }
367
368    /// Replace the [`writer::WriteOptions`] used by `execute`.
369    pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
370        self.write_options = opts;
371        self
372    }
373
374    /// Convenience: enable durable deletes (fsync the parent dir so the
375    /// dirent change is on disk before this returns).
376    pub fn fsync(mut self, yes: bool) -> Self {
377        self.write_options.fsync = yes;
378        self
379    }
380
381    pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
382        let folder_path = vault.resolve_folder(&self.folder)?;
383        let load = vault.load_records(&folder_path, false, false)?;
384        let needs_links = crate::filter::expr_uses_links(&self.filter);
385        let link_index = if needs_links {
386            Some(crate::links::LinkGraph::build_with_root(
387                &load.records,
388                Some(&vault.root),
389            ))
390        } else {
391            None
392        };
393
394        let mut changes = Vec::new();
395        for r in &load.records {
396            if !crate::filter::evaluate_expr(&self.filter, r, &vault.root, link_index.as_ref()) {
397                continue;
398            }
399            changes.push(PlannedChange {
400                path: r.path.clone(),
401                description: if self.permanent {
402                    "delete (permanent)".to_string()
403                } else {
404                    "move to .trash/".to_string()
405                },
406            });
407        }
408        Ok(MutationReport {
409            changes,
410            errors: Vec::new(),
411        })
412    }
413
414    pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
415        crate::lock::with_lock(&vault.root, || {
416            let report = self.plan(vault)?;
417            let mut errors = Vec::new();
418            // Track parents to fsync at the end if durability requested.
419            let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
420                std::collections::BTreeSet::new();
421
422            if self.permanent {
423                for change in &report.changes {
424                    if let Err(e) = std::fs::remove_file(&change.path) {
425                        errors.push(MutationError {
426                            path: change.path.clone(),
427                            message: format!("remove failed: {}", e),
428                        });
429                    } else if let Some(parent) = change.path.parent() {
430                        dirs_to_fsync.insert(parent.to_path_buf());
431                    }
432                }
433            } else {
434                let trash_dir = vault.root.join(".trash");
435                if !report.changes.is_empty() {
436                    std::fs::create_dir_all(&trash_dir).map_err(VaultdbError::Io)?;
437                }
438                for change in &report.changes {
439                    let dest = unique_in_dir(&trash_dir, &change.path);
440                    if let Err(e) = std::fs::rename(&change.path, &dest) {
441                        errors.push(MutationError {
442                            path: change.path.clone(),
443                            message: format!("trash failed: {}", e),
444                        });
445                    } else {
446                        if let Some(parent) = change.path.parent() {
447                            dirs_to_fsync.insert(parent.to_path_buf());
448                        }
449                        dirs_to_fsync.insert(trash_dir.clone());
450                    }
451                }
452            }
453
454            // Honour the fsync write option: flush every modified parent
455            // directory's dirent so the metadata changes are durable.
456            if self.write_options.fsync {
457                for d in &dirs_to_fsync {
458                    if let Err(e) = writer::fsync_dir(d) {
459                        errors.push(MutationError {
460                            path: d.clone(),
461                            message: format!("fsync_dir failed: {}", e),
462                        });
463                    }
464                }
465            }
466
467            Ok(MutationReport {
468                changes: report.changes,
469                errors,
470            })
471        })
472    }
473}
474
475fn unique_in_dir(dir: &std::path::Path, src: &std::path::Path) -> PathBuf {
476    let filename = src.file_name().and_then(|n| n.to_str()).unwrap_or("file");
477    let candidate = dir.join(filename);
478    if !candidate.exists() {
479        return candidate;
480    }
481    let stem = src.file_stem().and_then(|n| n.to_str()).unwrap_or("file");
482    let ext = src.extension().and_then(|n| n.to_str()).unwrap_or("md");
483    let mut i = 1;
484    loop {
485        let c = dir.join(format!("{}-{}.{}", stem, i, ext));
486        if !c.exists() {
487            return c;
488        }
489        i += 1;
490    }
491}
492
493// ── MoveBuilder ────────────────────────────────────────────────────────────
494
495/// Build a move mutation. Records matching `filter` are relocated into
496/// `to_folder` (created if needed). Filename collisions at the destination
497/// surface as `MutationError`s in the report.
498#[derive(Debug, Clone)]
499pub struct MoveBuilder {
500    filter: Expr,
501    folder: String,
502    to_folder: String,
503    write_options: writer::WriteOptions,
504}
505
506impl MoveBuilder {
507    pub fn new(folder: impl Into<String>, to_folder: impl Into<String>, filter: Expr) -> Self {
508        Self {
509            filter,
510            folder: folder.into(),
511            to_folder: to_folder.into(),
512            write_options: writer::WriteOptions::default(),
513        }
514    }
515
516    /// Replace the [`writer::WriteOptions`] used by `execute`.
517    pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
518        self.write_options = opts;
519        self
520    }
521
522    /// Convenience: enable durable moves (fsync source + dest dirents).
523    pub fn fsync(mut self, yes: bool) -> Self {
524        self.write_options.fsync = yes;
525        self
526    }
527
528    pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
529        let folder_path = vault.resolve_folder(&self.folder)?;
530        let to_path = vault.root.join(&self.to_folder);
531        let load = vault.load_records(&folder_path, false, false)?;
532        let needs_links = crate::filter::expr_uses_links(&self.filter);
533        let link_index = if needs_links {
534            Some(crate::links::LinkGraph::build_with_root(
535                &load.records,
536                Some(&vault.root),
537            ))
538        } else {
539            None
540        };
541
542        let mut changes = Vec::new();
543        let mut errors = Vec::new();
544
545        for r in &load.records {
546            if !crate::filter::evaluate_expr(&self.filter, r, &vault.root, link_index.as_ref()) {
547                continue;
548            }
549            let filename = match r.path.file_name() {
550                Some(n) => n,
551                None => continue,
552            };
553            let dest = to_path.join(filename);
554            if dest.exists() {
555                errors.push(MutationError {
556                    path: r.path.clone(),
557                    message: format!(
558                        "move conflict: {} already exists in {}",
559                        filename.to_string_lossy(),
560                        self.to_folder
561                    ),
562                });
563                continue;
564            }
565            changes.push(PlannedChange {
566                path: r.path.clone(),
567                description: format!("move to {}", dest.display()),
568            });
569        }
570        Ok(MutationReport { changes, errors })
571    }
572
573    pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
574        crate::lock::with_lock(&vault.root, || {
575            let to_path = vault.root.join(&self.to_folder);
576            let report = self.plan(vault)?;
577            if !report.changes.is_empty() {
578                std::fs::create_dir_all(&to_path).map_err(VaultdbError::Io)?;
579            }
580            let mut errors = report.errors;
581            let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
582                std::collections::BTreeSet::new();
583            for change in &report.changes {
584                let filename = match change.path.file_name() {
585                    Some(n) => n,
586                    None => continue,
587                };
588                let dest = to_path.join(filename);
589                if let Err(e) = std::fs::rename(&change.path, &dest) {
590                    errors.push(MutationError {
591                        path: change.path.clone(),
592                        message: format!("rename failed: {}", e),
593                    });
594                } else {
595                    if let Some(parent) = change.path.parent() {
596                        dirs_to_fsync.insert(parent.to_path_buf());
597                    }
598                    dirs_to_fsync.insert(to_path.clone());
599                }
600            }
601
602            if self.write_options.fsync {
603                for d in &dirs_to_fsync {
604                    if let Err(e) = writer::fsync_dir(d) {
605                        errors.push(MutationError {
606                            path: d.clone(),
607                            message: format!("fsync_dir failed: {}", e),
608                        });
609                    }
610                }
611            }
612
613            Ok(MutationReport {
614                changes: report.changes,
615                errors,
616            })
617        })
618    }
619}
620
621// ── RenameBuilder ──────────────────────────────────────────────────────────
622
623/// Build a rename mutation. The single record at `<folder>/<from>.md` is
624/// renamed to `<folder>/<to>.md`, and every `[[wikilink]]` across the vault
625/// pointing at `from` is rewritten to point at `to`.
626///
627/// Handled wikilink shapes: `[[from]]`, `[[from|alias]]`, `[[from#section]]`,
628/// `[[from#section|alias]]`.
629#[derive(Debug, Clone)]
630pub struct RenameBuilder {
631    folder: String,
632    from: String,
633    to: String,
634    write_options: writer::WriteOptions,
635}
636
637impl RenameBuilder {
638    pub fn new(folder: impl Into<String>, from: impl Into<String>, to: impl Into<String>) -> Self {
639        Self {
640            folder: folder.into(),
641            from: from.into(),
642            to: to.into(),
643            write_options: writer::WriteOptions::default(),
644        }
645    }
646
647    /// Replace the [`writer::WriteOptions`] used by `execute`.
648    pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
649        self.write_options = opts;
650        self
651    }
652
653    /// Convenience: enable durable rename + backlink rewrites (fsync
654    /// every modified file's data and every modified directory's dirent).
655    pub fn fsync(mut self, yes: bool) -> Self {
656        self.write_options.fsync = yes;
657        self
658    }
659
660    pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
661        let folder_path = vault.resolve_folder(&self.folder)?;
662        let source = folder_path.join(format!("{}.md", self.from));
663        let dest = folder_path.join(format!("{}.md", self.to));
664
665        let mut changes = Vec::new();
666        let mut errors = Vec::new();
667
668        if !source.is_file() {
669            errors.push(MutationError {
670                path: source.clone(),
671                message: format!("source `{}` not found", self.from),
672            });
673            return Ok(MutationReport { changes, errors });
674        }
675        if dest.exists() {
676            errors.push(MutationError {
677                path: dest.clone(),
678                message: format!("target `{}.md` already exists", self.to),
679            });
680            return Ok(MutationReport { changes, errors });
681        }
682
683        changes.push(PlannedChange {
684            path: source.clone(),
685            description: format!("rename to {}", dest.display()),
686        });
687
688        // Find every record that links to `self.from` and add a planned
689        // backlink-rewrite change.
690        let all = vault.load_records_with_content(&vault.root, true, false)?;
691        let graph = crate::links::LinkGraph::build_with_root(&all.records, Some(&vault.root));
692        for source_name in graph.incoming_links(&self.from) {
693            if let Some(record) = graph.record_by_name(source_name) {
694                changes.push(PlannedChange {
695                    path: record.path.clone(),
696                    description: format!("rewrite [[{}]] -> [[{}]]", self.from, self.to),
697                });
698            }
699        }
700
701        Ok(MutationReport { changes, errors })
702    }
703
704    pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
705        crate::lock::with_lock(&vault.root, || {
706            // Recover any pending journals from previous crashed renames
707            // before we start a new one. Without this, a stale journal
708            // could be replayed *after* our new rename, producing
709            // surprising results (e.g. backlink rewrites for an old
710            // rename happening on top of a new one). Replay first means
711            // the vault is in a clean known state when we start.
712            crate::journal::replay_all(&vault.root)?;
713
714            let folder_path = vault.resolve_folder(&self.folder)?;
715            let source = folder_path.join(format!("{}.md", self.from));
716            let dest = folder_path.join(format!("{}.md", self.to));
717
718            let report = self.plan(vault)?;
719            // If the plan reported errors at the source/dest stage, don't proceed.
720            if !report.errors.is_empty() {
721                return Ok(report);
722            }
723
724            // Build and write the journal BEFORE any disk-modifying
725            // step. If the process dies between this point and the
726            // final delete-journal call, the next mutation (or an
727            // explicit `Vault::recover` call) will replay it.
728            let backlinks: Vec<PathBuf> = report
729                .changes
730                .iter()
731                .skip(1) // first change is the rename itself
732                .map(|c| c.path.clone())
733                .collect();
734            let journal = crate::journal::RenameJournal {
735                source: source.clone(),
736                dest: dest.clone(),
737                from_name: self.from.clone(),
738                to_name: self.to.clone(),
739                backlinks,
740            };
741            let journal_path = crate::journal::write(&vault.root, &journal)?;
742
743            // Now do the rename. If this fails, drop the journal — the
744            // vault is unchanged, no recovery work to do.
745            if let Err(e) = std::fs::rename(&source, &dest) {
746                crate::journal::delete(&journal_path);
747                return Ok(MutationReport {
748                    changes: report.changes,
749                    errors: vec![MutationError {
750                        path: source,
751                        message: format!("rename failed: {}", e),
752                    }],
753                });
754            }
755
756            // Rewrite incoming wikilinks atomically (tempfile + rename
757            // per file). Each rewrite is itself idempotent so a partial
758            // run + journal replay reaches the same end state.
759            let mut errors = Vec::new();
760            let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
761                std::collections::BTreeSet::new();
762            if let Some(parent) = source.parent() {
763                dirs_to_fsync.insert(parent.to_path_buf());
764            }
765            if let Some(parent) = dest.parent() {
766                dirs_to_fsync.insert(parent.to_path_buf());
767            }
768            for change in report.changes.iter().skip(1) {
769                let path = &change.path;
770                let content = match std::fs::read_to_string(path) {
771                    Ok(c) => c,
772                    Err(e) => {
773                        errors.push(MutationError {
774                            path: path.clone(),
775                            message: format!("read failed: {}", e),
776                        });
777                        continue;
778                    }
779                };
780                let new_content = rewrite_wikilinks(&content, &self.from, &self.to);
781                if new_content == content {
782                    continue;
783                }
784                if let Err(e) = writer::atomic_write_with(path, &new_content, self.write_options) {
785                    errors.push(MutationError {
786                        path: path.clone(),
787                        message: format!("write failed: {}", e),
788                    });
789                }
790            }
791
792            // Fsync every dir whose dirent changed (the source's parent
793            // for the removed entry, the dest's parent for the added
794            // entry). atomic_write_with already fsynced the backlinks'
795            // parents internally when fsync was on.
796            if self.write_options.fsync {
797                for d in &dirs_to_fsync {
798                    if let Err(e) = writer::fsync_dir(d) {
799                        errors.push(MutationError {
800                            path: d.clone(),
801                            message: format!("fsync_dir failed: {}", e),
802                        });
803                    }
804                }
805            }
806
807            // All rewrites attempted. If any failed, leave the journal
808            // so the next replay sweep retries them. If everything
809            // succeeded, drop the journal — recovery has nothing to do.
810            if errors.is_empty() {
811                crate::journal::delete(&journal_path);
812            }
813
814            Ok(MutationReport {
815                changes: report.changes,
816                errors,
817            })
818        })
819    }
820}
821
822// ── CreateBuilder ──────────────────────────────────────────────────────────
823
824/// Build a create mutation. Writes a new `.md` file under `folder` with
825/// name `name` (the `.md` extension is appended automatically).
826///
827/// Frontmatter is composed in three layers, in order of precedence:
828///
829/// 1. Template — if `template(path)` is set, the template's frontmatter
830///    is parsed as the base. Template body is preserved.
831/// 2. `--set` overrides — anything set via `set()` overrides matching
832///    template fields.
833/// 3. Schema defaults — when `with_schema(schema)` is supplied, every
834///    `default:` / `default_expr:` whose field isn't already set by
835///    the template or `--set` is applied.
836///
837/// After composition, schema's `required:` list is checked. Missing
838/// required fields surface as `MutationError`s in the report, with the
839/// file NOT written.
840#[derive(Debug, Clone)]
841pub struct CreateBuilder {
842    folder: String,
843    name: String,
844    template: Option<String>,
845    set_fields: Vec<(String, Value)>,
846    vault_schema: Option<crate::schema::VaultSchema>,
847    write_options: writer::WriteOptions,
848}
849
850impl CreateBuilder {
851    pub fn new(folder: impl Into<String>, name: impl Into<String>) -> Self {
852        Self {
853            folder: folder.into(),
854            name: name.into(),
855            template: None,
856            set_fields: Vec::new(),
857            vault_schema: None,
858            write_options: writer::WriteOptions::default(),
859        }
860    }
861
862    /// Path to a template file, relative to the vault root. The
863    /// template's frontmatter forms the base of the new note; its body
864    /// is preserved.
865    pub fn template(mut self, path: impl Into<String>) -> Self {
866        self.template = Some(path.into());
867        self
868    }
869
870    /// Set (or override) a frontmatter field. Layered on top of the
871    /// template; layered under schema defaults.
872    pub fn set(mut self, field: impl Into<String>, value: Value) -> Self {
873        self.set_fields.push((field.into(), value));
874        self
875    }
876
877    /// Attach a single collection schema. Convenience wrapper that
878    /// builds a one-collection [`VaultSchema`] and forwards to
879    /// [`Self::with_vault_schema`]. The compute path is identical —
880    /// `applicable_collections` will pick this collection iff its
881    /// `folder` is `==` or ancestor of the builder's target folder
882    /// and the filter (if any) matches the projected record.
883    pub fn with_schema(self, schema: crate::schema::CollectionSchema) -> Self {
884        let mut vs = crate::schema::VaultSchema {
885            collections: std::collections::BTreeMap::new(),
886        };
887        vs.collections.insert("__single__".to_string(), schema);
888        self.with_vault_schema(vs)
889    }
890
891    /// Attach the vault-wide schema. Every collection whose folder
892    /// covers (`==` or is an ancestor of) the builder's target folder
893    /// AND whose `filter:` matches the projected record contributes:
894    /// its `default:` / `default_expr:` fields layer in shallowest-
895    /// folder first (deeper folders override), and the post-defaults
896    /// record must satisfy *all* applicable collections — strict mode.
897    pub fn with_vault_schema(mut self, schema: crate::schema::VaultSchema) -> Self {
898        self.vault_schema = Some(schema);
899        self
900    }
901
902    pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
903        self.write_options = opts;
904        self
905    }
906
907    pub fn fsync(mut self, yes: bool) -> Self {
908        self.write_options.fsync = yes;
909        self
910    }
911
912    /// Compute the planned change without writing.
913    pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
914        let (report, _) = self.compute(vault)?;
915        Ok(report)
916    }
917
918    /// Plan and also return the file content that would be written.
919    /// Used by the CLI's `--dry-run` for create — the post-defaults
920    /// frontmatter is the value of the preview.
921    pub fn plan_with_content(&self, vault: &Vault) -> Result<(MutationReport, Option<String>)> {
922        let (report, write) = self.compute(vault)?;
923        Ok((report, write.map(|w| w.modified_content)))
924    }
925
926    /// Plan, then write the file atomically. Holds the vault-scoped
927    /// lock so concurrent creates against the same vault serialise
928    /// cleanly.
929    pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
930        crate::lock::with_lock(&vault.root, || {
931            let (report, write) = self.compute(vault)?;
932            if !report.errors.is_empty() {
933                return Ok(report);
934            }
935            if let Some(w) = write {
936                if let Some(parent) = w.path.parent()
937                    && !parent.exists()
938                {
939                    std::fs::create_dir_all(parent).map_err(VaultdbError::Io)?;
940                }
941                // `atomic_create_with` (not `atomic_write_with`) so that
942                // an external writer racing in between compute()'s
943                // `dest.exists()` check and the rename can't be silently
944                // overwritten — the rename refuses if the target now
945                // exists, surfacing as an Io error rather than a clobber.
946                writer::atomic_create_with(&w.path, &w.modified_content, self.write_options)
947                    .map_err(VaultdbError::Io)?;
948            }
949            Ok(report)
950        })
951    }
952
953    fn compute(&self, vault: &Vault) -> Result<(MutationReport, Option<WriteResult>)> {
954        // We DON'T require the folder to exist on disk — create can
955        // make it. `vault.resolve_folder` errors on missing dirs, so
956        // we build the path ourselves and let the writer create the
957        // parent at execute time.
958        let folder_path = vault.root.join(&self.folder);
959        let filename = format!("{}.md", self.name);
960        let dest = folder_path.join(&filename);
961
962        let mut changes = Vec::new();
963        let mut errors = Vec::new();
964
965        if dest.exists() {
966            errors.push(MutationError {
967                path: dest.clone(),
968                message: format!("file already exists: {}", dest.display()),
969            });
970            return Ok((MutationReport { changes, errors }, None));
971        }
972
973        // 1. Template — parse its frontmatter into a typed map, keep
974        //    its body verbatim. No template → empty map + minimal body
975        //    (`# {name}` so the note isn't blank).
976        let (mut fields, body) = match &self.template {
977            Some(tmpl) => {
978                let tmpl_path = vault.root.join(tmpl);
979                if !tmpl_path.is_file() {
980                    errors.push(MutationError {
981                        path: tmpl_path.clone(),
982                        message: format!("template not found: {}", tmpl_path.display()),
983                    });
984                    return Ok((MutationReport { changes, errors }, None));
985                }
986                let raw = std::fs::read_to_string(&tmpl_path).map_err(VaultdbError::Io)?;
987                split_template(&raw)
988            }
989            None => (
990                std::collections::BTreeMap::<String, Value>::new(),
991                format!("\n# {}\n", self.name),
992            ),
993        };
994
995        // 2. --set overrides.
996        for (k, v) in &self.set_fields {
997            fields.insert(k.clone(), v.clone());
998        }
999
1000        // 3. Schema enforcement — defaults + full record validation
1001        //    against every applicable collection. Applicability is
1002        //    decided ONCE from the post-(template+set) field map: if a
1003        //    later default would have triggered a different collection
1004        //    to apply, that's a degenerate schema design and we don't
1005        //    chase it.
1006        if let Some(vault_schema) = &self.vault_schema {
1007            let projected = crate::record::Record {
1008                path: dest.clone(),
1009                fields: fields.clone(),
1010                raw_content: None,
1011            };
1012            let applicable =
1013                match vault_schema.applicable_collections(&self.folder, &projected, &vault.root) {
1014                    Ok(cols) => cols,
1015                    Err(e) => {
1016                        errors.push(MutationError {
1017                            path: dest.clone(),
1018                            message: format!("evaluating schema applicability: {}", e),
1019                        });
1020                        return Ok((MutationReport { changes, errors }, None));
1021                    }
1022                };
1023
1024            // Layer defaults. `applicable_collections` sorts shallowest-
1025            // folder first, so deeper folders naturally overwrite when
1026            // we iterate forwards.
1027            for col in &applicable {
1028                for (fname, fs) in &col.fields {
1029                    if fields.contains_key(fname) {
1030                        continue;
1031                    }
1032                    if let Some(default) = &fs.default {
1033                        fields.insert(fname.clone(), default.clone());
1034                    } else if let Some(expr) = &fs.default_expr {
1035                        match crate::schema::resolve_default_expr(expr) {
1036                            Ok(v) => {
1037                                fields.insert(fname.clone(), v);
1038                            }
1039                            Err(e) => {
1040                                errors.push(MutationError {
1041                                    path: dest.clone(),
1042                                    message: format!(
1043                                        "resolving default_expr for '{}': {}",
1044                                        fname, e
1045                                    ),
1046                                });
1047                            }
1048                        }
1049                    }
1050                }
1051            }
1052
1053            // Validate post-default fields against every applicable
1054            // collection. Dedup (field, message) so the catch-all and a
1055            // sub-collection don't both report the same missing field
1056            // twice.
1057            let mut seen = std::collections::BTreeSet::<(String, String)>::new();
1058            for col in &applicable {
1059                for v in crate::schema::validate_record(&filename, &fields, col) {
1060                    if seen.insert((v.field.clone(), v.message.clone())) {
1061                        errors.push(MutationError {
1062                            path: dest.clone(),
1063                            message: format!("schema: {} — {}", v.field, v.message),
1064                        });
1065                    }
1066                }
1067            }
1068        }
1069
1070        if !errors.is_empty() {
1071            return Ok((MutationReport { changes, errors }, None));
1072        }
1073
1074        // 5. Render frontmatter + body. serde_yaml emits an unprefixed
1075        //    mapping; we wrap with `---` delimiters. Empty field map
1076        //    still gets `---\n---\n` so consumers can find a parse anchor.
1077        let frontmatter_yaml = if fields.is_empty() {
1078            String::new()
1079        } else {
1080            serde_yaml::to_string(&fields)
1081                .map_err(|e| VaultdbError::SchemaError(format!("rendering frontmatter: {}", e)))?
1082        };
1083        let content = if frontmatter_yaml.is_empty() {
1084            format!("---\n---\n{}", body)
1085        } else {
1086            format!("---\n{}---\n{}", frontmatter_yaml, body)
1087        };
1088
1089        let field_count = fields.len();
1090        let field_summary: String = fields.keys().cloned().collect::<Vec<_>>().join(", ");
1091        let description = if field_count == 0 {
1092            "create (no frontmatter fields)".to_string()
1093        } else {
1094            format!("create with {} field(s): {}", field_count, field_summary)
1095        };
1096
1097        changes.push(PlannedChange {
1098            path: dest.clone(),
1099            description,
1100        });
1101
1102        let write = WriteResult {
1103            path: dest,
1104            original_content: String::new(),
1105            modified_content: content,
1106            changes: Vec::new(),
1107        };
1108
1109        Ok((MutationReport { changes, errors }, Some(write)))
1110    }
1111}
1112
1113/// Parse a template's frontmatter + body into a typed field map and
1114/// the verbatim body string. Returns an empty map + the whole raw
1115/// content as body when the template has no frontmatter delimiters.
1116fn split_template(raw: &str) -> (std::collections::BTreeMap<String, Value>, String) {
1117    use crate::frontmatter::{extract_frontmatter, parse_frontmatter};
1118    match extract_frontmatter(raw) {
1119        Some((yaml_text, body_start)) => {
1120            let fields = parse_frontmatter(yaml_text).unwrap_or_default();
1121            let body = raw[body_start..].to_string();
1122            (fields, body)
1123        }
1124        None => (std::collections::BTreeMap::new(), raw.to_string()),
1125    }
1126}
1127
1128/// Rewrite `[[from]]` (and `[[from|alias]]`, `[[from#section]]`,
1129/// `[[from#section|alias]]`) to point at `to`.
1130pub(crate) fn rewrite_wikilinks(content: &str, from: &str, to: &str) -> String {
1131    content
1132        .replace(&format!("[[{}]]", from), &format!("[[{}]]", to))
1133        .replace(&format!("[[{}|", from), &format!("[[{}|", to))
1134        .replace(&format!("[[{}#", from), &format!("[[{}#", to))
1135}
1136
1137/// Render a `Value` as a YAML scalar suitable for inline frontmatter.
1138///
1139/// String values are quoted via `writer::quote_value` to match the existing
1140/// writer's escape rules. Lists and maps fall back to `serde_yaml::to_string`
1141/// (trimmed) for a flow-style representation.
1142fn render_value_for_yaml(v: &Value) -> String {
1143    match v {
1144        Value::Null => "null".to_string(),
1145        Value::Bool(b) => b.to_string(),
1146        Value::Integer(i) => i.to_string(),
1147        Value::Float(f) => f.to_string(),
1148        Value::String(s) => writer::quote_value(s),
1149        Value::List(_) | Value::Map(_) => {
1150            let yaml = serde_yaml::to_string(v).unwrap_or_default();
1151            yaml.trim_end().to_string()
1152        }
1153    }
1154}
1155
1156#[cfg(test)]
1157mod tests {
1158    use super::*;
1159    use crate::query::Predicate;
1160
1161    /// Regression test for the 1.3.0 double-quoting bug: setting a
1162    /// `Value::String` with characters that need YAML quoting (`:`,
1163    /// `&`, leading `-`, etc.) used to be quoted twice — first by
1164    /// `render_value_for_yaml` and then again inside `writer::set_field`
1165    /// — producing `url: "'https://x.com'"` instead of
1166    /// `url: 'https://x.com'`. 1.3.1 routes the scalar set through
1167    /// `writer::set_field_preformatted`, which writes the YAML-ready
1168    /// value verbatim.
1169    #[test]
1170    fn update_builder_writes_url_string_without_double_quoting() {
1171        use std::fs;
1172        use tempfile::TempDir;
1173        let dir = TempDir::new().unwrap();
1174        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1175        fs::create_dir(dir.path().join("notes")).unwrap();
1176        fs::write(
1177            dir.path().join("notes/product.md"),
1178            "---\nname: bialetti\nurl:\n---\n\n# Bialetti\n",
1179        )
1180        .unwrap();
1181        let vault = Vault::with_root(dir.path().to_path_buf());
1182
1183        let filter = Expr::Predicate(Predicate::Equals {
1184            field: "_name".into(),
1185            value: Value::String("product".into()),
1186        });
1187        let url = Value::String("https://www.amazon.com.tr/Bialetti/foo".into());
1188        UpdateBuilder::new("notes", filter)
1189            .set("url", url)
1190            .execute(&vault)
1191            .unwrap();
1192
1193        let written = fs::read_to_string(dir.path().join("notes/product.md")).unwrap();
1194        // Correct: single quotes, no enclosing double quotes.
1195        assert!(
1196            written.contains("url: 'https://www.amazon.com.tr/Bialetti/foo'"),
1197            "got:\n{}",
1198            written
1199        );
1200        // The pre-fix corrupt shape must not appear.
1201        assert!(!written.contains("url: \"'https://"), "got:\n{}", written);
1202
1203        // Re-read through Vault::load_records: the field must come back
1204        // as the bare URL, not a string containing literal quotes.
1205        let records = vault
1206            .load_records(&dir.path().join("notes"), false, false)
1207            .unwrap()
1208            .records;
1209        let product = &records[0];
1210        match product.fields.get("url") {
1211            Some(Value::String(s)) => {
1212                assert_eq!(s, "https://www.amazon.com.tr/Bialetti/foo");
1213            }
1214            other => panic!("expected Value::String(bare URL), got {:?}", other),
1215        }
1216    }
1217
1218    /// Type-preservation: `Value::String("true")` must round-trip as
1219    /// the string `"true"`, not the boolean `true`. The fix preserves
1220    /// the explicit quoting added by `render_value_for_yaml`, which
1221    /// disambiguates string-looking-like-boolean from real booleans.
1222    #[test]
1223    fn update_builder_preserves_string_that_looks_like_bool() {
1224        use std::fs;
1225        use tempfile::TempDir;
1226        let dir = TempDir::new().unwrap();
1227        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1228        fs::create_dir(dir.path().join("notes")).unwrap();
1229        fs::write(
1230            dir.path().join("notes/n.md"),
1231            "---\nname: n\nstatus:\n---\n",
1232        )
1233        .unwrap();
1234        let vault = Vault::with_root(dir.path().to_path_buf());
1235
1236        let filter = Expr::Predicate(Predicate::Equals {
1237            field: "_name".into(),
1238            value: Value::String("n".into()),
1239        });
1240        UpdateBuilder::new("notes", filter)
1241            .set("status", Value::String("true".into()))
1242            .execute(&vault)
1243            .unwrap();
1244
1245        let records = vault
1246            .load_records(&dir.path().join("notes"), false, false)
1247            .unwrap()
1248            .records;
1249        match records[0].fields.get("status") {
1250            Some(Value::String(s)) if s == "true" => {}
1251            other => panic!(
1252                "expected status to round-trip as Value::String(\"true\"), got {:?}",
1253                other
1254            ),
1255        }
1256    }
1257
1258    /// Regression test for the 1.1.0–1.2.0 typed-set bug: setting a
1259    /// `Value::List` via `UpdateBuilder::set` used to flatten the value
1260    /// through `render_value_for_yaml` and write it as a quoted scalar
1261    /// (`anlamlar: '- kedi'`). After 1.2.1, lists and maps go through
1262    /// `writer::set_field_block` and round-trip as proper block-style YAML.
1263    #[test]
1264    fn update_builder_writes_list_as_block_yaml() {
1265        use std::fs;
1266        use tempfile::TempDir;
1267        let dir = TempDir::new().unwrap();
1268        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1269        fs::create_dir(dir.path().join("notes")).unwrap();
1270        fs::write(
1271            dir.path().join("notes/cat.md"),
1272            "---\nanlam: kedi\n---\n\n# 猫\n",
1273        )
1274        .unwrap();
1275        let vault = Vault::with_root(dir.path().to_path_buf());
1276
1277        let filter = Expr::Predicate(Predicate::Equals {
1278            field: "_name".into(),
1279            value: Value::String("cat".into()),
1280        });
1281        let anlamlar = Value::List(vec![
1282            Value::String("kedi".into()),
1283            Value::String("pisi".into()),
1284        ]);
1285        let report = UpdateBuilder::new("notes", filter)
1286            .set("anlamlar", anlamlar)
1287            .execute(&vault)
1288            .unwrap();
1289        assert_eq!(report.errors.len(), 0);
1290        assert_eq!(report.changes.len(), 1);
1291
1292        let written = fs::read_to_string(dir.path().join("notes/cat.md")).unwrap();
1293        // Block-style: a `key:` line followed by `- item` lines.
1294        assert!(
1295            written.contains("anlamlar:\n- kedi\n- pisi"),
1296            "got:\n{}",
1297            written
1298        );
1299        // The pre-fix corrupt shape must not appear.
1300        assert!(!written.contains("anlamlar: '- kedi"), "got:\n{}", written);
1301
1302        // Re-read through Vault::query: the field must come back as a List,
1303        // not a String. This is the assertion that catches a regression of
1304        // the original bug end-to-end.
1305        let records = vault
1306            .load_records(&dir.path().join("notes"), false, false)
1307            .unwrap()
1308            .records;
1309        let cat = &records[0];
1310        match cat.fields.get("anlamlar") {
1311            Some(Value::List(items)) => {
1312                assert_eq!(items.len(), 2);
1313                assert!(matches!(&items[0], Value::String(s) if s == "kedi"));
1314                assert!(matches!(&items[1], Value::String(s) if s == "pisi"));
1315            }
1316            other => panic!("expected Value::List, got {:?}", other),
1317        }
1318    }
1319
1320    #[test]
1321    fn update_builder_chains() {
1322        let filter = Expr::Predicate(Predicate::Equals {
1323            field: "status".into(),
1324            value: Value::String("active".into()),
1325        });
1326        let b = UpdateBuilder::new("notes", filter)
1327            .set("priority", Value::Integer(1))
1328            .unset("draft")
1329            .add_tag("urgent")
1330            .remove_tag("stale");
1331        assert_eq!(b.set_fields.len(), 1);
1332        assert_eq!(b.unset_fields.len(), 1);
1333        assert_eq!(b.add_tags.len(), 1);
1334        assert_eq!(b.remove_tags.len(), 1);
1335    }
1336
1337    #[test]
1338    fn delete_builder_trash_moves_to_dot_trash() {
1339        use std::fs;
1340        use tempfile::TempDir;
1341        let dir = TempDir::new().unwrap();
1342        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1343        fs::create_dir(dir.path().join("notes")).unwrap();
1344        fs::write(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
1345        let vault = Vault::with_root(dir.path().to_path_buf());
1346        let filter = Expr::Predicate(Predicate::Equals {
1347            field: "status".into(),
1348            value: Value::String("stale".into()),
1349        });
1350        let builder = DeleteBuilder::new("notes", filter);
1351        let report = builder.execute(&vault).unwrap();
1352        assert_eq!(report.changes.len(), 1);
1353        assert_eq!(report.errors.len(), 0);
1354        assert!(!dir.path().join("notes/a.md").exists());
1355        assert!(dir.path().join(".trash/a.md").exists());
1356    }
1357
1358    #[test]
1359    fn delete_builder_permanent_removes_file() {
1360        use std::fs;
1361        use tempfile::TempDir;
1362        let dir = TempDir::new().unwrap();
1363        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1364        fs::create_dir(dir.path().join("notes")).unwrap();
1365        fs::write(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
1366        let vault = Vault::with_root(dir.path().to_path_buf());
1367        let filter = Expr::Predicate(Predicate::Equals {
1368            field: "status".into(),
1369            value: Value::String("stale".into()),
1370        });
1371        let builder = DeleteBuilder::new("notes", filter).permanent(true);
1372        builder.execute(&vault).unwrap();
1373        assert!(!dir.path().join("notes/a.md").exists());
1374        assert!(!dir.path().join(".trash/a.md").exists());
1375    }
1376
1377    #[test]
1378    fn move_builder_relocates_files() {
1379        use std::fs;
1380        use tempfile::TempDir;
1381        let dir = TempDir::new().unwrap();
1382        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1383        fs::create_dir(dir.path().join("notes")).unwrap();
1384        fs::write(
1385            dir.path().join("notes/a.md"),
1386            "---\nstatus: archived\n---\n",
1387        )
1388        .unwrap();
1389        let vault = Vault::with_root(dir.path().to_path_buf());
1390        let filter = Expr::Predicate(Predicate::Equals {
1391            field: "status".into(),
1392            value: Value::String("archived".into()),
1393        });
1394        let builder = MoveBuilder::new("notes", "archive", filter);
1395        let report = builder.execute(&vault).unwrap();
1396        assert_eq!(report.changes.len(), 1);
1397        assert_eq!(report.errors.len(), 0);
1398        assert!(!dir.path().join("notes/a.md").exists());
1399        assert!(dir.path().join("archive/a.md").exists());
1400    }
1401
1402    #[test]
1403    fn rename_builder_renames_and_rewrites_links() {
1404        use std::fs;
1405        use tempfile::TempDir;
1406        let dir = TempDir::new().unwrap();
1407        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1408        fs::create_dir(dir.path().join("notes")).unwrap();
1409        fs::write(
1410            dir.path().join("notes/old.md"),
1411            "---\nstatus: x\n---\nBody\n",
1412        )
1413        .unwrap();
1414        fs::write(
1415            dir.path().join("notes/source.md"),
1416            "---\nstatus: y\n---\nLinks to [[old]] and [[old|alias]] and [[old#section]].\n",
1417        )
1418        .unwrap();
1419        let vault = Vault::with_root(dir.path().to_path_buf());
1420
1421        let builder = RenameBuilder::new("notes", "old", "new");
1422        let report = builder.execute(&vault).unwrap();
1423        // 1 rename + 1 backlink rewrite = 2 changes
1424        assert_eq!(report.changes.len(), 2);
1425        assert_eq!(report.errors.len(), 0);
1426        assert!(!dir.path().join("notes/old.md").exists());
1427        assert!(dir.path().join("notes/new.md").exists());
1428        let source_after = fs::read_to_string(dir.path().join("notes/source.md")).unwrap();
1429        assert!(source_after.contains("[[new]]"));
1430        assert!(source_after.contains("[[new|alias]]"));
1431        assert!(source_after.contains("[[new#section]]"));
1432        assert!(!source_after.contains("[[old"));
1433    }
1434
1435    #[test]
1436    fn rename_builder_target_conflict_returns_error() {
1437        use std::fs;
1438        use tempfile::TempDir;
1439        let dir = TempDir::new().unwrap();
1440        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1441        fs::create_dir(dir.path().join("notes")).unwrap();
1442        fs::write(dir.path().join("notes/old.md"), "---\nstatus: x\n---\n").unwrap();
1443        fs::write(dir.path().join("notes/new.md"), "---\nstatus: y\n---\n").unwrap();
1444        let vault = Vault::with_root(dir.path().to_path_buf());
1445        let report = RenameBuilder::new("notes", "old", "new")
1446            .execute(&vault)
1447            .unwrap();
1448        assert_eq!(report.changes.len(), 0);
1449        assert_eq!(report.errors.len(), 1);
1450        // Source file should be untouched
1451        assert!(dir.path().join("notes/old.md").exists());
1452    }
1453
1454    #[test]
1455    fn update_builder_plan_and_execute_against_a_temp_vault() {
1456        use std::fs;
1457        use tempfile::TempDir;
1458
1459        let dir = TempDir::new().unwrap();
1460        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1461        fs::create_dir(dir.path().join("notes")).unwrap();
1462        fs::write(
1463            dir.path().join("notes/a.md"),
1464            "---\nstatus: active\n---\nBody A\n",
1465        )
1466        .unwrap();
1467        fs::write(
1468            dir.path().join("notes/b.md"),
1469            "---\nstatus: pending\n---\nBody B\n",
1470        )
1471        .unwrap();
1472
1473        let vault = Vault::with_root(dir.path().to_path_buf());
1474
1475        let filter = Expr::Predicate(Predicate::Equals {
1476            field: "status".into(),
1477            value: Value::String("active".into()),
1478        });
1479        let builder = UpdateBuilder::new("notes", filter).set("priority", Value::Integer(1));
1480
1481        // plan() does not touch disk
1482        let plan_report = builder.plan(&vault).unwrap();
1483        assert_eq!(plan_report.changes.len(), 1);
1484        assert_eq!(plan_report.errors.len(), 0);
1485        assert!(plan_report.changes[0].path.ends_with("a.md"));
1486        let before = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
1487        assert!(!before.contains("priority"));
1488
1489        // execute() applies the change
1490        let exec_report = builder.execute(&vault).unwrap();
1491        assert_eq!(exec_report.changes.len(), 1);
1492        let after = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
1493        assert!(after.contains("priority"));
1494        // b.md was NOT touched (its status is pending, doesn't match the filter)
1495        let b_after = fs::read_to_string(dir.path().join("notes/b.md")).unwrap();
1496        assert!(!b_after.contains("priority"));
1497    }
1498
1499    #[test]
1500    fn write_options_fsync_propagates_through_update_builder() {
1501        // We can't easily test that fsync was actually called (no kernel
1502        // hook from user space), but we can test that:
1503        // 1. The fsync(true) builder method round-trips through to
1504        //    write_options.fsync = true.
1505        // 2. An update with fsync(true) still produces correct content
1506        //    on disk.
1507        // 3. WriteOptions::durable() is a shorthand for { fsync: true }.
1508        use std::fs;
1509        use tempfile::TempDir;
1510
1511        // 1. The fluent API.
1512        let f1 = Expr::Predicate(Predicate::Equals {
1513            field: "x".into(),
1514            value: Value::Integer(1),
1515        });
1516        let b = UpdateBuilder::new("notes", f1).fsync(true);
1517        assert!(b.write_options.fsync);
1518
1519        let f2 = Expr::Predicate(Predicate::Equals {
1520            field: "x".into(),
1521            value: Value::Integer(1),
1522        });
1523        let b =
1524            UpdateBuilder::new("notes", f2).write_options(crate::writer::WriteOptions::durable());
1525        assert!(b.write_options.fsync);
1526
1527        // 2. Real execute with fsync=true still produces correct content.
1528        let dir = TempDir::new().unwrap();
1529        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1530        fs::create_dir(dir.path().join("notes")).unwrap();
1531        fs::write(
1532            dir.path().join("notes/durable.md"),
1533            "---\nstatus: active\n---\nBody.\n",
1534        )
1535        .unwrap();
1536        let vault = Vault::with_root(dir.path().to_path_buf());
1537
1538        let f3 = Expr::Predicate(Predicate::Equals {
1539            field: "status".into(),
1540            value: Value::String("active".into()),
1541        });
1542        let report = UpdateBuilder::new("notes", f3)
1543            .set("priority", Value::Integer(99))
1544            .fsync(true)
1545            .execute(&vault)
1546            .unwrap();
1547        assert_eq!(report.changes.len(), 1);
1548        assert_eq!(report.errors.len(), 0);
1549
1550        let after = fs::read_to_string(dir.path().join("notes/durable.md")).unwrap();
1551        assert!(after.contains("priority: 99"));
1552        assert!(after.contains("status: active"));
1553    }
1554
1555    #[test]
1556    fn rename_clean_run_leaves_no_journal_behind() {
1557        // Successful rename writes a journal then deletes it. After the
1558        // call, the journal directory should be empty (or absent).
1559        use std::fs;
1560        use tempfile::TempDir;
1561        let dir = TempDir::new().unwrap();
1562        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1563        fs::create_dir(dir.path().join("notes")).unwrap();
1564        fs::write(
1565            dir.path().join("notes/old.md"),
1566            "---\nstatus: x\n---\nBody\n",
1567        )
1568        .unwrap();
1569        fs::write(
1570            dir.path().join("notes/source.md"),
1571            "---\nstatus: y\n---\nLinks to [[old]].\n",
1572        )
1573        .unwrap();
1574        let vault = Vault::with_root(dir.path().to_path_buf());
1575
1576        RenameBuilder::new("notes", "old", "new")
1577            .execute(&vault)
1578            .unwrap();
1579
1580        let pending = crate::journal::list_pending(dir.path()).unwrap();
1581        assert!(
1582            pending.is_empty(),
1583            "successful rename must not leave journals behind: {:?}",
1584            pending
1585        );
1586    }
1587
1588    #[test]
1589    fn rename_recovers_from_pre_existing_journal() {
1590        // Simulate a crashed rename by hand-writing a journal that points
1591        // at an unrenamed source file with stale backlinks. Vault::recover
1592        // should pick it up and complete the work.
1593        use std::fs;
1594        use tempfile::TempDir;
1595        let dir = TempDir::new().unwrap();
1596        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1597        fs::create_dir(dir.path().join("notes")).unwrap();
1598        let source = dir.path().join("notes/Stanford.md");
1599        let dest = dir.path().join("notes/Stanford University.md");
1600        let backlink = dir.path().join("notes/Application.md");
1601        fs::write(&source, "---\nkind: university\n---\nMain note.\n").unwrap();
1602        fs::write(
1603            &backlink,
1604            "---\nkind: application\n---\nApplied to [[Stanford]].\n",
1605        )
1606        .unwrap();
1607
1608        // Plant a journal as if a previous rename had crashed mid-flight.
1609        let journal = crate::journal::RenameJournal {
1610            source: source.clone(),
1611            dest: dest.clone(),
1612            from_name: "Stanford".into(),
1613            to_name: "Stanford University".into(),
1614            backlinks: vec![backlink.clone()],
1615        };
1616        crate::journal::write(dir.path(), &journal).unwrap();
1617
1618        let vault = Vault::with_root(dir.path().to_path_buf());
1619        let recovered = vault.recover().unwrap();
1620        assert_eq!(recovered, 1, "expected exactly one journal replayed");
1621
1622        // Source renamed, backlink rewritten, journal gone.
1623        assert!(!source.exists());
1624        assert!(dest.is_file());
1625        let backlink_content = fs::read_to_string(&backlink).unwrap();
1626        assert!(backlink_content.contains("[[Stanford University]]"));
1627        assert!(!backlink_content.contains("[[Stanford]]"));
1628        assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
1629    }
1630
1631    #[test]
1632    fn rename_replays_pending_journal_before_starting_new_rename() {
1633        // A pending journal from a previous crashed rename must be
1634        // replayed before the next mutation starts. Otherwise we'd be
1635        // operating on a stale view of the vault.
1636        use std::fs;
1637        use tempfile::TempDir;
1638        let dir = TempDir::new().unwrap();
1639        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1640        fs::create_dir(dir.path().join("notes")).unwrap();
1641
1642        // Pre-existing files: A.md (will be renamed to B.md by the
1643        // pending journal), and C.md (will be renamed to D.md by the
1644        // new RenameBuilder call).
1645        let a = dir.path().join("notes/A.md");
1646        let b = dir.path().join("notes/B.md");
1647        let c = dir.path().join("notes/C.md");
1648        let d = dir.path().join("notes/D.md");
1649        fs::write(&a, "---\n---\nA body.\n").unwrap();
1650        fs::write(&c, "---\n---\nC body.\n").unwrap();
1651
1652        // Plant a pending journal that says "rename A -> B".
1653        crate::journal::write(
1654            dir.path(),
1655            &crate::journal::RenameJournal {
1656                source: a.clone(),
1657                dest: b.clone(),
1658                from_name: "A".into(),
1659                to_name: "B".into(),
1660                backlinks: vec![],
1661            },
1662        )
1663        .unwrap();
1664
1665        // Now call execute() on a separate rename C -> D.
1666        let vault = Vault::with_root(dir.path().to_path_buf());
1667        RenameBuilder::new("notes", "C", "D")
1668            .execute(&vault)
1669            .unwrap();
1670
1671        // BOTH renames must have completed: the journal-replay before the
1672        // new rename did A -> B, and the new RenameBuilder did C -> D.
1673        assert!(!a.exists(), "A.md should be gone (replayed journal)");
1674        assert!(b.is_file(), "B.md should exist (replayed journal)");
1675        assert!(!c.exists(), "C.md should be gone (new rename)");
1676        assert!(d.is_file(), "D.md should exist (new rename)");
1677
1678        // No leftover journals.
1679        assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
1680    }
1681
1682    #[test]
1683    fn concurrent_updates_serialize_via_vault_lock() {
1684        // Two threads each run an UpdateBuilder against the same vault.
1685        // Without the lock, both would read a.md, both would compute their
1686        // edit against the same baseline, and the second writer would
1687        // clobber the first's change. With the lock, the second thread
1688        // waits for the first to finish, re-reads the (now-updated) file,
1689        // and applies its change on top. The result should reflect both
1690        // edits, in some serial order.
1691        use std::fs;
1692        use std::sync::Arc;
1693        use std::thread;
1694        use tempfile::TempDir;
1695
1696        let dir = TempDir::new().unwrap();
1697        fs::create_dir(dir.path().join(".obsidian")).unwrap();
1698        fs::create_dir(dir.path().join("notes")).unwrap();
1699        fs::write(
1700            dir.path().join("notes/race.md"),
1701            "---\nstatus: active\n---\nBody.\n",
1702        )
1703        .unwrap();
1704
1705        let vault_path = Arc::new(dir.path().to_path_buf());
1706
1707        let p1 = Arc::clone(&vault_path);
1708        let t1 = thread::spawn(move || {
1709            let vault = Vault::with_root((*p1).clone());
1710            let filter = Expr::Predicate(Predicate::Equals {
1711                field: "status".into(),
1712                value: Value::String("active".into()),
1713            });
1714            UpdateBuilder::new("notes", filter)
1715                .set("touched_by_t1", Value::Integer(1))
1716                .execute(&vault)
1717                .expect("t1 execute")
1718        });
1719
1720        let p2 = Arc::clone(&vault_path);
1721        let t2 = thread::spawn(move || {
1722            let vault = Vault::with_root((*p2).clone());
1723            let filter = Expr::Predicate(Predicate::Equals {
1724                field: "status".into(),
1725                value: Value::String("active".into()),
1726            });
1727            UpdateBuilder::new("notes", filter)
1728                .set("touched_by_t2", Value::Integer(1))
1729                .execute(&vault)
1730                .expect("t2 execute")
1731        });
1732
1733        let r1 = t1.join().unwrap();
1734        let r2 = t2.join().unwrap();
1735        assert_eq!(r1.errors.len(), 0);
1736        assert_eq!(r2.errors.len(), 0);
1737
1738        // The final file content must contain BOTH edits. If the lock
1739        // failed, exactly one would survive (whichever thread wrote last)
1740        // because the slower thread's snapshot would be stale.
1741        let final_content = fs::read_to_string(dir.path().join("notes/race.md")).unwrap();
1742        assert!(
1743            final_content.contains("touched_by_t1"),
1744            "t1's edit lost; concurrent writer race: {}",
1745            final_content
1746        );
1747        assert!(
1748            final_content.contains("touched_by_t2"),
1749            "t2's edit lost; concurrent writer race: {}",
1750            final_content
1751        );
1752    }
1753
1754    #[test]
1755    fn atomic_write_does_not_leave_partial_files_on_failed_writes() {
1756        // Direct test of the writer::atomic_write contract: if the write
1757        // is interrupted, readers should never observe partial content.
1758        // We simulate a failed write by trying to atomic_write to a path
1759        // whose parent doesn't exist — that fails cleanly, and the
1760        // ORIGINAL file (if any) should be untouched.
1761        use std::fs;
1762        use tempfile::TempDir;
1763
1764        let dir = TempDir::new().unwrap();
1765        let target = dir.path().join("subdir/that-does-not-exist/x.md");
1766        // Pre-existing target shouldn't be touched (there is none here, but
1767        // the atomic_write itself must fail cleanly without side effects).
1768        let result = crate::writer::atomic_write(&target, "new content");
1769        assert!(
1770            result.is_err(),
1771            "expected atomic_write to fail when parent dir doesn't exist"
1772        );
1773
1774        // Now: create a target with original content, then try a write
1775        // that succeeds. Verify the file is fully replaced (no merge).
1776        let real_dir = dir.path().join("real");
1777        fs::create_dir(&real_dir).unwrap();
1778        let real_target = real_dir.join("x.md");
1779        fs::write(&real_target, "original").unwrap();
1780        crate::writer::atomic_write(&real_target, "replacement").unwrap();
1781        let after = fs::read_to_string(&real_target).unwrap();
1782        assert_eq!(after, "replacement");
1783
1784        // No leftover tempfile in the directory.
1785        let leftovers: Vec<_> = fs::read_dir(&real_dir)
1786            .unwrap()
1787            .flatten()
1788            .filter(|e| e.file_name().to_string_lossy().starts_with(".tmp"))
1789            .collect();
1790        assert!(
1791            leftovers.is_empty(),
1792            "expected no tempfile leftovers, found: {:?}",
1793            leftovers.iter().map(|e| e.path()).collect::<Vec<_>>()
1794        );
1795    }
1796
1797    // ── Phase 3: CreateBuilder ────────────────────────────────────────
1798
1799    use crate::schema::{CollectionSchema, FieldSchema};
1800
1801    fn movie_schema() -> CollectionSchema {
1802        let mut fields = std::collections::BTreeMap::new();
1803        fields.insert(
1804            "db-table".into(),
1805            FieldSchema {
1806                field_type: "string".into(),
1807                enum_values: vec![Value::String("movie".into())],
1808                min: None,
1809                max: None,
1810                default: Some(Value::String("movie".into())),
1811                default_expr: None,
1812            },
1813        );
1814        fields.insert(
1815            "status".into(),
1816            FieldSchema {
1817                field_type: "string".into(),
1818                enum_values: vec![
1819                    Value::String("to-watch".into()),
1820                    Value::String("watched".into()),
1821                ],
1822                min: None,
1823                max: None,
1824                default: Some(Value::String("to-watch".into())),
1825                default_expr: None,
1826            },
1827        );
1828        fields.insert(
1829            "director".into(),
1830            FieldSchema {
1831                field_type: "string".into(),
1832                enum_values: vec![],
1833                min: None,
1834                max: None,
1835                default: None,
1836                default_expr: None,
1837            },
1838        );
1839        fields.insert(
1840            "year".into(),
1841            FieldSchema {
1842                field_type: "integer".into(),
1843                enum_values: vec![],
1844                min: None,
1845                max: None,
1846                default: None,
1847                default_expr: None,
1848            },
1849        );
1850        CollectionSchema {
1851            description: None,
1852            folder: "Notes/movie".into(),
1853            filter: vec![],
1854            required: vec![
1855                "db-table".into(),
1856                "director".into(),
1857                "status".into(),
1858                "year".into(),
1859            ],
1860            fields,
1861        }
1862    }
1863
1864    fn vault_with_obsidian() -> tempfile::TempDir {
1865        let dir = tempfile::TempDir::new().unwrap();
1866        std::fs::create_dir(dir.path().join(".obsidian")).unwrap();
1867        dir
1868    }
1869
1870    #[test]
1871    fn create_without_schema_writes_minimal_file() {
1872        let dir = vault_with_obsidian();
1873        let vault = Vault::with_root(dir.path().to_path_buf());
1874        let report = CreateBuilder::new("Notes/movie", "Dune")
1875            .execute(&vault)
1876            .unwrap();
1877        assert_eq!(report.errors.len(), 0);
1878        assert_eq!(report.changes.len(), 1);
1879        let written = dir.path().join("Notes/movie/Dune.md");
1880        assert!(written.is_file());
1881        let content = std::fs::read_to_string(&written).unwrap();
1882        // Empty frontmatter + "# Dune" body.
1883        assert!(content.contains("---\n---"));
1884        assert!(content.contains("# Dune"));
1885    }
1886
1887    #[test]
1888    fn create_with_set_writes_typed_frontmatter() {
1889        let dir = vault_with_obsidian();
1890        let vault = Vault::with_root(dir.path().to_path_buf());
1891        CreateBuilder::new("Notes/movie", "Dune")
1892            .set("director", Value::String("Denis Villeneuve".into()))
1893            .set("year", Value::Integer(2021))
1894            .execute(&vault)
1895            .unwrap();
1896        let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1897        assert!(content.contains("director: Denis Villeneuve"));
1898        // YAML int rendering — no quotes.
1899        assert!(content.contains("year: 2021"));
1900    }
1901
1902    #[test]
1903    fn create_fills_schema_defaults() {
1904        let dir = vault_with_obsidian();
1905        let vault = Vault::with_root(dir.path().to_path_buf());
1906        CreateBuilder::new("Notes/movie", "Dune")
1907            .with_schema(movie_schema())
1908            .set("director", Value::String("Denis Villeneuve".into()))
1909            .set("year", Value::Integer(2021))
1910            .execute(&vault)
1911            .unwrap();
1912        let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1913        // Defaults applied.
1914        assert!(content.contains("db-table: movie"));
1915        assert!(content.contains("status: to-watch"));
1916        // User-supplied values preserved.
1917        assert!(content.contains("director: Denis Villeneuve"));
1918        assert!(content.contains("year: 2021"));
1919    }
1920
1921    #[test]
1922    fn create_set_overrides_default() {
1923        let dir = vault_with_obsidian();
1924        let vault = Vault::with_root(dir.path().to_path_buf());
1925        CreateBuilder::new("Notes/movie", "Watched")
1926            .with_schema(movie_schema())
1927            .set("director", Value::String("X".into()))
1928            .set("year", Value::Integer(2020))
1929            .set("status", Value::String("watched".into()))
1930            .execute(&vault)
1931            .unwrap();
1932        let content = std::fs::read_to_string(dir.path().join("Notes/movie/Watched.md")).unwrap();
1933        assert!(content.contains("status: watched"));
1934        assert!(!content.contains("status: to-watch"));
1935    }
1936
1937    #[test]
1938    fn create_rejects_missing_required_before_writing() {
1939        let dir = vault_with_obsidian();
1940        let vault = Vault::with_root(dir.path().to_path_buf());
1941        // No director / year → required check should fail.
1942        let report = CreateBuilder::new("Notes/movie", "Blank")
1943            .with_schema(movie_schema())
1944            .execute(&vault)
1945            .unwrap();
1946        assert!(!report.errors.is_empty());
1947        assert!(report.errors.iter().any(|e| e.message.contains("director")));
1948        assert!(report.errors.iter().any(|e| e.message.contains("year")));
1949        // File must NOT have been written.
1950        assert!(!dir.path().join("Notes/movie/Blank.md").exists());
1951    }
1952
1953    #[test]
1954    fn create_rejects_existing_file() {
1955        let dir = vault_with_obsidian();
1956        std::fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
1957        std::fs::write(dir.path().join("Notes/movie/Dune.md"), "existing\n").unwrap();
1958        let vault = Vault::with_root(dir.path().to_path_buf());
1959        let report = CreateBuilder::new("Notes/movie", "Dune")
1960            .execute(&vault)
1961            .unwrap();
1962        assert_eq!(report.errors.len(), 1);
1963        assert!(report.errors[0].message.contains("already exists"));
1964        // Original file preserved.
1965        let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1966        assert_eq!(content, "existing\n");
1967    }
1968
1969    #[test]
1970    fn create_resolves_default_expr_today() {
1971        let dir = vault_with_obsidian();
1972        let vault = Vault::with_root(dir.path().to_path_buf());
1973        let mut fields = std::collections::BTreeMap::new();
1974        fields.insert(
1975            "due".into(),
1976            FieldSchema {
1977                field_type: "date".into(),
1978                enum_values: vec![],
1979                min: None,
1980                max: None,
1981                default: None,
1982                default_expr: Some("today".into()),
1983            },
1984        );
1985        let schema = CollectionSchema {
1986            description: None,
1987            folder: "tasks".into(),
1988            filter: vec![],
1989            required: vec![],
1990            fields,
1991        };
1992        CreateBuilder::new("tasks", "t1")
1993            .with_schema(schema)
1994            .execute(&vault)
1995            .unwrap();
1996        let content = std::fs::read_to_string(dir.path().join("tasks/t1.md")).unwrap();
1997        // Match shape, not exact value (date depends on when test runs).
1998        let today = crate::record::today_string();
1999        assert!(
2000            content.contains(&format!("due: {}", today)),
2001            "expected due={} in: {}",
2002            today,
2003            content
2004        );
2005    }
2006
2007    #[test]
2008    fn create_plan_does_not_touch_disk() {
2009        let dir = vault_with_obsidian();
2010        let vault = Vault::with_root(dir.path().to_path_buf());
2011        let (report, content) = CreateBuilder::new("Notes/movie", "Dune")
2012            .with_schema(movie_schema())
2013            .set("director", Value::String("DV".into()))
2014            .set("year", Value::Integer(2021))
2015            .plan_with_content(&vault)
2016            .unwrap();
2017        assert_eq!(report.errors.len(), 0);
2018        assert_eq!(report.changes.len(), 1);
2019        assert!(!dir.path().join("Notes/movie/Dune.md").exists());
2020        let c = content.unwrap();
2021        assert!(c.contains("director: DV"));
2022        assert!(c.contains("db-table: movie")); // default applied even in plan
2023    }
2024
2025    #[test]
2026    fn create_from_template_preserves_body_and_merges_frontmatter() {
2027        let dir = vault_with_obsidian();
2028        std::fs::create_dir_all(dir.path().join("templates")).unwrap();
2029        std::fs::write(
2030            dir.path().join("templates/movie.md"),
2031            "---\nstatus: to-watch\naliases: []\n---\n\n# Title\n\nReview goes here.\n",
2032        )
2033        .unwrap();
2034        let vault = Vault::with_root(dir.path().to_path_buf());
2035        CreateBuilder::new("Notes/movie", "Dune")
2036            .template("templates/movie.md")
2037            .set("year", Value::Integer(2021))
2038            .execute(&vault)
2039            .unwrap();
2040        let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2041        // Template field preserved.
2042        assert!(content.contains("status: to-watch"));
2043        // --set added.
2044        assert!(content.contains("year: 2021"));
2045        // Body preserved.
2046        assert!(content.contains("Review goes here"));
2047    }
2048
2049    // ── Strict-schema enforcement (CreateBuilder + UpdateBuilder) ─────────
2050    //
2051    // Cover the multi-collection scenarios the real-world schema
2052    // exercises (a catch-all whose folder is an ancestor of a more
2053    // specific collection, the specific collection's filter using a
2054    // field value to opt-in). Builds an in-code VaultSchema fixture
2055    // rather than going through serde_yaml.
2056
2057    use crate::schema::VaultSchema;
2058
2059    fn vault_schema_movies() -> VaultSchema {
2060        // Wraps movie_schema as a single-collection VaultSchema —
2061        // exercises the same code path with_schema goes through.
2062        let mut vs = VaultSchema {
2063            collections: std::collections::BTreeMap::new(),
2064        };
2065        vs.collections.insert("movies".into(), movie_schema());
2066        vs
2067    }
2068
2069    fn vault_schema_catchall_and_movies() -> VaultSchema {
2070        // Mirrors the real-vault shape: `Notes` catch-all (folder is
2071        // an ancestor of every subcollection, no filter) + `movies`
2072        // (folder Notes/movie, filter db-table = movie).
2073        let mut collections = std::collections::BTreeMap::new();
2074
2075        let mut catchall_fields = std::collections::BTreeMap::new();
2076        catchall_fields.insert(
2077            "db-table".into(),
2078            FieldSchema {
2079                field_type: "string".into(),
2080                enum_values: vec![Value::String("movie".into()), Value::String("book".into())],
2081                min: None,
2082                max: None,
2083                default: None,
2084                default_expr: None,
2085            },
2086        );
2087        collections.insert(
2088            "Notes".into(),
2089            CollectionSchema {
2090                description: None,
2091                folder: "Notes".into(),
2092                filter: vec![],
2093                required: vec!["db-table".into()],
2094                fields: catchall_fields,
2095            },
2096        );
2097        collections.insert("movies".into(), {
2098            let mut m = movie_schema();
2099            m.filter = vec!["db-table = movie".into()];
2100            m
2101        });
2102
2103        VaultSchema { collections }
2104    }
2105
2106    #[test]
2107    fn update_rejects_type_mismatch() {
2108        use std::fs;
2109        let dir = vault_with_obsidian();
2110        fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2111        fs::write(
2112            dir.path().join("Notes/movie/Dune.md"),
2113            "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\nBody\n",
2114        )
2115        .unwrap();
2116        let vault = Vault::with_root(dir.path().to_path_buf());
2117
2118        let filter = Expr::Predicate(crate::query::Predicate::Equals {
2119            field: "director".into(),
2120            value: Value::String("DV".into()),
2121        });
2122        let report = UpdateBuilder::new("Notes/movie", filter)
2123            .set("year", Value::String("nope".into()))
2124            .with_vault_schema(vault_schema_movies())
2125            .execute(&vault)
2126            .unwrap();
2127
2128        assert!(report.changes.is_empty(), "no write should be reported");
2129        assert!(
2130            report
2131                .errors
2132                .iter()
2133                .any(|e| e.message.contains("year") && e.message.contains("integer")),
2134            "expected year/integer type-mismatch error, got: {:?}",
2135            report.errors
2136        );
2137        // File on disk is unchanged.
2138        let content = fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2139        assert!(content.contains("year: 2021"));
2140        assert!(!content.contains("year: nope"));
2141    }
2142
2143    #[test]
2144    fn update_rejects_enum_violation() {
2145        use std::fs;
2146        let dir = vault_with_obsidian();
2147        fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2148        fs::write(
2149            dir.path().join("Notes/movie/Dune.md"),
2150            "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\n",
2151        )
2152        .unwrap();
2153        let vault = Vault::with_root(dir.path().to_path_buf());
2154
2155        let filter = Expr::Predicate(crate::query::Predicate::Equals {
2156            field: "director".into(),
2157            value: Value::String("DV".into()),
2158        });
2159        let report = UpdateBuilder::new("Notes/movie", filter)
2160            .set("status", Value::String("watching".into()))
2161            .with_vault_schema(vault_schema_movies())
2162            .execute(&vault)
2163            .unwrap();
2164
2165        assert!(report.changes.is_empty());
2166        assert!(
2167            report
2168                .errors
2169                .iter()
2170                .any(|e| e.message.contains("status") && e.message.contains("watching")),
2171            "expected status enum violation, got: {:?}",
2172            report.errors
2173        );
2174    }
2175
2176    #[test]
2177    fn update_passes_when_unconstrained_field_changes() {
2178        // Schema doesn't declare `notes-to-self`; setting it should be
2179        // allowed without complaint. The other required/typed fields
2180        // must already be valid for the post-state to pass strict mode.
2181        use std::fs;
2182        let dir = vault_with_obsidian();
2183        fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2184        fs::write(
2185            dir.path().join("Notes/movie/Dune.md"),
2186            "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\n",
2187        )
2188        .unwrap();
2189        let vault = Vault::with_root(dir.path().to_path_buf());
2190
2191        let filter = Expr::Predicate(crate::query::Predicate::Equals {
2192            field: "director".into(),
2193            value: Value::String("DV".into()),
2194        });
2195        let report = UpdateBuilder::new("Notes/movie", filter)
2196            .set("notes-to-self", Value::String("rewatch".into()))
2197            .with_vault_schema(vault_schema_movies())
2198            .execute(&vault)
2199            .unwrap();
2200        assert!(
2201            report.errors.is_empty(),
2202            "no errors expected, got: {:?}",
2203            report.errors
2204        );
2205        assert_eq!(report.changes.len(), 1);
2206        let content = fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2207        assert!(content.contains("notes-to-self: rewatch"));
2208    }
2209
2210    #[test]
2211    fn update_surfaces_preexisting_violation() {
2212        // Strict mode (chosen explicitly): a file already missing a
2213        // required field is unwritable through vaultdb until the field
2214        // is supplied — even if the user's update touches a totally
2215        // unrelated field.
2216        use std::fs;
2217        let dir = vault_with_obsidian();
2218        fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2219        fs::write(
2220            dir.path().join("Notes/movie/Old.md"),
2221            // Note: no `director`, which movies.required demands.
2222            "---\ndb-table: movie\nstatus: to-watch\nyear: 2021\n---\n",
2223        )
2224        .unwrap();
2225        let vault = Vault::with_root(dir.path().to_path_buf());
2226
2227        let filter = Expr::Predicate(crate::query::Predicate::Equals {
2228            field: "db-table".into(),
2229            value: Value::String("movie".into()),
2230        });
2231        let report = UpdateBuilder::new("Notes/movie", filter)
2232            .set("year", Value::Integer(2022))
2233            .with_vault_schema(vault_schema_movies())
2234            .execute(&vault)
2235            .unwrap();
2236        assert!(
2237            report.errors.iter().any(|e| e.message.contains("director")),
2238            "expected pre-existing required-field violation, got: {:?}",
2239            report.errors
2240        );
2241        // File untouched.
2242        let content = fs::read_to_string(dir.path().join("Notes/movie/Old.md")).unwrap();
2243        assert!(content.contains("year: 2021"));
2244    }
2245
2246    #[test]
2247    fn update_skips_one_blocks_one_in_batch() {
2248        // Two matched records: one update is valid (year change within
2249        // range), the other (also matched) wasn't even attempted but
2250        // demonstrates per-record isolation by being in a separate test
2251        // alongside. Real per-record skip-vs-write batching covered by
2252        // this scenario: matched records both processed; valid one
2253        // writes, invalid one errors.
2254        use std::fs;
2255        let dir = vault_with_obsidian();
2256        fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2257        fs::write(
2258            dir.path().join("Notes/movie/Good.md"),
2259            "---\ndb-table: movie\ndirector: A\nstatus: to-watch\nyear: 2021\n---\n",
2260        )
2261        .unwrap();
2262        fs::write(
2263            dir.path().join("Notes/movie/Bad.md"),
2264            // Missing director — pre-existing violation surfaces on update.
2265            "---\ndb-table: movie\nstatus: to-watch\nyear: 2021\n---\n",
2266        )
2267        .unwrap();
2268        let vault = Vault::with_root(dir.path().to_path_buf());
2269
2270        let filter = Expr::Predicate(crate::query::Predicate::Equals {
2271            field: "db-table".into(),
2272            value: Value::String("movie".into()),
2273        });
2274        let report = UpdateBuilder::new("Notes/movie", filter)
2275            .set("year", Value::Integer(2022))
2276            .with_vault_schema(vault_schema_movies())
2277            .execute(&vault)
2278            .unwrap();
2279        assert_eq!(report.changes.len(), 1, "exactly one record should write");
2280        assert!(report.changes[0].path.ends_with("Good.md"));
2281        assert!(report.errors.iter().any(|e| e.path.ends_with("Bad.md")));
2282        // Disk: Good updated, Bad untouched.
2283        assert!(
2284            fs::read_to_string(dir.path().join("Notes/movie/Good.md"))
2285                .unwrap()
2286                .contains("year: 2022")
2287        );
2288        assert!(
2289            fs::read_to_string(dir.path().join("Notes/movie/Bad.md"))
2290                .unwrap()
2291                .contains("year: 2021")
2292        );
2293    }
2294
2295    #[test]
2296    fn update_validates_against_catchall_and_subfolder() {
2297        // The `Notes` catch-all requires `db-table`; `movies`
2298        // additionally requires director, year. A movie record at
2299        // Notes/movie/ activates BOTH. Unsetting db-table fails the
2300        // catch-all; the test confirms the catch-all is actually
2301        // evaluated for sub-folder records.
2302        use std::fs;
2303        let dir = vault_with_obsidian();
2304        fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2305        fs::write(
2306            dir.path().join("Notes/movie/Dune.md"),
2307            "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\n",
2308        )
2309        .unwrap();
2310        let vault = Vault::with_root(dir.path().to_path_buf());
2311
2312        let filter = Expr::Predicate(crate::query::Predicate::Equals {
2313            field: "director".into(),
2314            value: Value::String("DV".into()),
2315        });
2316        let report = UpdateBuilder::new("Notes/movie", filter)
2317            .unset("db-table")
2318            .with_vault_schema(vault_schema_catchall_and_movies())
2319            .execute(&vault)
2320            .unwrap();
2321
2322        // db-table being unset removes the `movies` filter match too,
2323        // so only the catch-all still applies — and the catch-all
2324        // requires db-table. So we get a required-field error from the
2325        // catch-all (proving it was evaluated).
2326        assert!(report.changes.is_empty());
2327        assert!(
2328            report.errors.iter().any(|e| e.message.contains("db-table")),
2329            "expected db-table missing error from catch-all, got: {:?}",
2330            report.errors
2331        );
2332    }
2333
2334    #[test]
2335    fn create_rejects_type_mismatch() {
2336        let dir = vault_with_obsidian();
2337        let vault = Vault::with_root(dir.path().to_path_buf());
2338        let report = CreateBuilder::new("Notes/movie", "Dune")
2339            .with_vault_schema(vault_schema_movies())
2340            .set("director", Value::String("DV".into()))
2341            .set("year", Value::String("not-a-year".into()))
2342            .execute(&vault)
2343            .unwrap();
2344        assert!(
2345            report
2346                .errors
2347                .iter()
2348                .any(|e| e.message.contains("year") && e.message.contains("integer")),
2349            "expected year/integer type error, got: {:?}",
2350            report.errors
2351        );
2352        assert!(!dir.path().join("Notes/movie/Dune.md").exists());
2353    }
2354
2355    #[test]
2356    fn create_validates_against_multiple_applicable_collections() {
2357        // Creating a movie note: both `Notes` catch-all and `movies`
2358        // apply. Required: db-table (both), director + year (movies).
2359        // Set only db-table → catch-all passes, movies fails on
2360        // missing director + year.
2361        let dir = vault_with_obsidian();
2362        let vault = Vault::with_root(dir.path().to_path_buf());
2363        let report = CreateBuilder::new("Notes/movie", "X")
2364            .with_vault_schema(vault_schema_catchall_and_movies())
2365            .set("db-table", Value::String("movie".into()))
2366            .execute(&vault)
2367            .unwrap();
2368        assert!(report.errors.iter().any(|e| e.message.contains("director")));
2369        assert!(report.errors.iter().any(|e| e.message.contains("year")));
2370        assert!(!dir.path().join("Notes/movie/X.md").exists());
2371    }
2372}