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