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