Skip to main content

vaultdb_core/
mutation.rs

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