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