1use 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23pub struct MutationReport {
24 pub changes: Vec<PlannedChange>,
25 pub errors: Vec<MutationError>,
26}
27
28#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
30pub struct PlannedChange {
31 pub path: PathBuf,
32 pub description: String,
33}
34
35#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
37pub struct MutationError {
38 pub path: PathBuf,
39 pub message: String,
40}
41
42#[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 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
94 self.write_options = opts;
95 self
96 }
97
98 pub fn fsync(mut self, yes: bool) -> Self {
101 self.write_options.fsync = yes;
102 self
103 }
104
105 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
107 let (report, _writes) = self.compute(vault)?;
108 Ok(report)
109 }
110
111 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 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 let value_str = render_value_for_yaml(value);
172 let (new_content, change) = writer::set_field(&content, field, &value_str)?;
173 description_parts.push(format!("{}", change));
174 wr_changes.push(change);
175 content = new_content;
176 }
177 for field in &self.unset_fields {
178 let (new_content, change) = writer::unset_field(&content, field)?;
179 description_parts.push(format!("{}", change));
180 wr_changes.push(change);
181 content = new_content;
182 }
183 for tag in &self.add_tags {
184 let (new_content, change) = writer::add_tag(&content, tag)?;
185 description_parts.push(format!("{}", change));
186 wr_changes.push(change);
187 content = new_content;
188 }
189 for tag in &self.remove_tags {
190 let (new_content, change) = writer::remove_tag(&content, tag)?;
191 description_parts.push(format!("{}", change));
192 wr_changes.push(change);
193 content = new_content;
194 }
195 Ok(())
196 })();
197
198 match result {
199 Ok(_) => {
200 if !wr_changes.is_empty() {
201 writes.push(WriteResult {
202 path: record.path.clone(),
203 original_content,
204 modified_content: content,
205 changes: wr_changes,
206 });
207 changes.push(PlannedChange {
208 path: record.path.clone(),
209 description: description_parts.join("; "),
210 });
211 }
212 }
213 Err(e) => errors.push(MutationError {
214 path: record.path.clone(),
215 message: e.to_string(),
216 }),
217 }
218 }
219
220 Ok((MutationReport { changes, errors }, writes))
221 }
222}
223
224#[derive(Debug, Clone)]
230pub struct DeleteBuilder {
231 filter: Expr,
232 folder: String,
233 permanent: bool,
234 write_options: writer::WriteOptions,
235}
236
237impl DeleteBuilder {
238 pub fn new(folder: impl Into<String>, filter: Expr) -> Self {
239 Self {
240 filter,
241 folder: folder.into(),
242 permanent: false,
243 write_options: writer::WriteOptions::default(),
244 }
245 }
246
247 pub fn permanent(mut self, yes: bool) -> Self {
248 self.permanent = yes;
249 self
250 }
251
252 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
254 self.write_options = opts;
255 self
256 }
257
258 pub fn fsync(mut self, yes: bool) -> Self {
261 self.write_options.fsync = yes;
262 self
263 }
264
265 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
266 let folder_path = vault.resolve_folder(&self.folder)?;
267 let load = vault.load_records(&folder_path, false, false)?;
268 let needs_links = crate::filter::expr_uses_links(&self.filter);
269 let link_index = if needs_links {
270 Some(crate::links::LinkGraph::build_with_root(
271 &load.records,
272 Some(&vault.root),
273 ))
274 } else {
275 None
276 };
277
278 let mut changes = Vec::new();
279 for r in &load.records {
280 if !crate::filter::evaluate_expr(&self.filter, r, &vault.root, link_index.as_ref()) {
281 continue;
282 }
283 changes.push(PlannedChange {
284 path: r.path.clone(),
285 description: if self.permanent {
286 "delete (permanent)".to_string()
287 } else {
288 "move to .trash/".to_string()
289 },
290 });
291 }
292 Ok(MutationReport {
293 changes,
294 errors: Vec::new(),
295 })
296 }
297
298 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
299 crate::lock::with_lock(&vault.root, || {
300 let report = self.plan(vault)?;
301 let mut errors = Vec::new();
302 let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
304 std::collections::BTreeSet::new();
305
306 if self.permanent {
307 for change in &report.changes {
308 if let Err(e) = std::fs::remove_file(&change.path) {
309 errors.push(MutationError {
310 path: change.path.clone(),
311 message: format!("remove failed: {}", e),
312 });
313 } else if let Some(parent) = change.path.parent() {
314 dirs_to_fsync.insert(parent.to_path_buf());
315 }
316 }
317 } else {
318 let trash_dir = vault.root.join(".trash");
319 if !report.changes.is_empty() {
320 std::fs::create_dir_all(&trash_dir).map_err(VaultdbError::Io)?;
321 }
322 for change in &report.changes {
323 let dest = unique_in_dir(&trash_dir, &change.path);
324 if let Err(e) = std::fs::rename(&change.path, &dest) {
325 errors.push(MutationError {
326 path: change.path.clone(),
327 message: format!("trash failed: {}", e),
328 });
329 } else {
330 if let Some(parent) = change.path.parent() {
331 dirs_to_fsync.insert(parent.to_path_buf());
332 }
333 dirs_to_fsync.insert(trash_dir.clone());
334 }
335 }
336 }
337
338 if self.write_options.fsync {
341 for d in &dirs_to_fsync {
342 if let Err(e) = writer::fsync_dir(d) {
343 errors.push(MutationError {
344 path: d.clone(),
345 message: format!("fsync_dir failed: {}", e),
346 });
347 }
348 }
349 }
350
351 Ok(MutationReport {
352 changes: report.changes,
353 errors,
354 })
355 })
356 }
357}
358
359fn unique_in_dir(dir: &std::path::Path, src: &std::path::Path) -> PathBuf {
360 let filename = src.file_name().and_then(|n| n.to_str()).unwrap_or("file");
361 let candidate = dir.join(filename);
362 if !candidate.exists() {
363 return candidate;
364 }
365 let stem = src.file_stem().and_then(|n| n.to_str()).unwrap_or("file");
366 let ext = src.extension().and_then(|n| n.to_str()).unwrap_or("md");
367 let mut i = 1;
368 loop {
369 let c = dir.join(format!("{}-{}.{}", stem, i, ext));
370 if !c.exists() {
371 return c;
372 }
373 i += 1;
374 }
375}
376
377#[derive(Debug, Clone)]
383pub struct MoveBuilder {
384 filter: Expr,
385 folder: String,
386 to_folder: String,
387 write_options: writer::WriteOptions,
388}
389
390impl MoveBuilder {
391 pub fn new(folder: impl Into<String>, to_folder: impl Into<String>, filter: Expr) -> Self {
392 Self {
393 filter,
394 folder: folder.into(),
395 to_folder: to_folder.into(),
396 write_options: writer::WriteOptions::default(),
397 }
398 }
399
400 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
402 self.write_options = opts;
403 self
404 }
405
406 pub fn fsync(mut self, yes: bool) -> Self {
408 self.write_options.fsync = yes;
409 self
410 }
411
412 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
413 let folder_path = vault.resolve_folder(&self.folder)?;
414 let to_path = vault.root.join(&self.to_folder);
415 let load = vault.load_records(&folder_path, false, false)?;
416 let needs_links = crate::filter::expr_uses_links(&self.filter);
417 let link_index = if needs_links {
418 Some(crate::links::LinkGraph::build_with_root(
419 &load.records,
420 Some(&vault.root),
421 ))
422 } else {
423 None
424 };
425
426 let mut changes = Vec::new();
427 let mut errors = Vec::new();
428
429 for r in &load.records {
430 if !crate::filter::evaluate_expr(&self.filter, r, &vault.root, link_index.as_ref()) {
431 continue;
432 }
433 let filename = match r.path.file_name() {
434 Some(n) => n,
435 None => continue,
436 };
437 let dest = to_path.join(filename);
438 if dest.exists() {
439 errors.push(MutationError {
440 path: r.path.clone(),
441 message: format!(
442 "move conflict: {} already exists in {}",
443 filename.to_string_lossy(),
444 self.to_folder
445 ),
446 });
447 continue;
448 }
449 changes.push(PlannedChange {
450 path: r.path.clone(),
451 description: format!("move to {}", dest.display()),
452 });
453 }
454 Ok(MutationReport { changes, errors })
455 }
456
457 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
458 crate::lock::with_lock(&vault.root, || {
459 let to_path = vault.root.join(&self.to_folder);
460 let report = self.plan(vault)?;
461 if !report.changes.is_empty() {
462 std::fs::create_dir_all(&to_path).map_err(VaultdbError::Io)?;
463 }
464 let mut errors = report.errors;
465 let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
466 std::collections::BTreeSet::new();
467 for change in &report.changes {
468 let filename = match change.path.file_name() {
469 Some(n) => n,
470 None => continue,
471 };
472 let dest = to_path.join(filename);
473 if let Err(e) = std::fs::rename(&change.path, &dest) {
474 errors.push(MutationError {
475 path: change.path.clone(),
476 message: format!("rename failed: {}", e),
477 });
478 } else {
479 if let Some(parent) = change.path.parent() {
480 dirs_to_fsync.insert(parent.to_path_buf());
481 }
482 dirs_to_fsync.insert(to_path.clone());
483 }
484 }
485
486 if self.write_options.fsync {
487 for d in &dirs_to_fsync {
488 if let Err(e) = writer::fsync_dir(d) {
489 errors.push(MutationError {
490 path: d.clone(),
491 message: format!("fsync_dir failed: {}", e),
492 });
493 }
494 }
495 }
496
497 Ok(MutationReport {
498 changes: report.changes,
499 errors,
500 })
501 })
502 }
503}
504
505#[derive(Debug, Clone)]
514pub struct RenameBuilder {
515 folder: String,
516 from: String,
517 to: String,
518 write_options: writer::WriteOptions,
519}
520
521impl RenameBuilder {
522 pub fn new(folder: impl Into<String>, from: impl Into<String>, to: impl Into<String>) -> Self {
523 Self {
524 folder: folder.into(),
525 from: from.into(),
526 to: to.into(),
527 write_options: writer::WriteOptions::default(),
528 }
529 }
530
531 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
533 self.write_options = opts;
534 self
535 }
536
537 pub fn fsync(mut self, yes: bool) -> Self {
540 self.write_options.fsync = yes;
541 self
542 }
543
544 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
545 let folder_path = vault.resolve_folder(&self.folder)?;
546 let source = folder_path.join(format!("{}.md", self.from));
547 let dest = folder_path.join(format!("{}.md", self.to));
548
549 let mut changes = Vec::new();
550 let mut errors = Vec::new();
551
552 if !source.is_file() {
553 errors.push(MutationError {
554 path: source.clone(),
555 message: format!("source `{}` not found", self.from),
556 });
557 return Ok(MutationReport { changes, errors });
558 }
559 if dest.exists() {
560 errors.push(MutationError {
561 path: dest.clone(),
562 message: format!("target `{}.md` already exists", self.to),
563 });
564 return Ok(MutationReport { changes, errors });
565 }
566
567 changes.push(PlannedChange {
568 path: source.clone(),
569 description: format!("rename to {}", dest.display()),
570 });
571
572 let all = vault.load_records_with_content(&vault.root, true, false)?;
575 let graph = crate::links::LinkGraph::build_with_root(&all.records, Some(&vault.root));
576 for source_name in graph.incoming_links(&self.from) {
577 if let Some(record) = graph.record_by_name(source_name) {
578 changes.push(PlannedChange {
579 path: record.path.clone(),
580 description: format!("rewrite [[{}]] -> [[{}]]", self.from, self.to),
581 });
582 }
583 }
584
585 Ok(MutationReport { changes, errors })
586 }
587
588 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
589 crate::lock::with_lock(&vault.root, || {
590 crate::journal::replay_all(&vault.root)?;
597
598 let folder_path = vault.resolve_folder(&self.folder)?;
599 let source = folder_path.join(format!("{}.md", self.from));
600 let dest = folder_path.join(format!("{}.md", self.to));
601
602 let report = self.plan(vault)?;
603 if !report.errors.is_empty() {
605 return Ok(report);
606 }
607
608 let backlinks: Vec<PathBuf> = report
613 .changes
614 .iter()
615 .skip(1) .map(|c| c.path.clone())
617 .collect();
618 let journal = crate::journal::RenameJournal {
619 source: source.clone(),
620 dest: dest.clone(),
621 from_name: self.from.clone(),
622 to_name: self.to.clone(),
623 backlinks,
624 };
625 let journal_path = crate::journal::write(&vault.root, &journal)?;
626
627 if let Err(e) = std::fs::rename(&source, &dest) {
630 crate::journal::delete(&journal_path);
631 return Ok(MutationReport {
632 changes: report.changes,
633 errors: vec![MutationError {
634 path: source,
635 message: format!("rename failed: {}", e),
636 }],
637 });
638 }
639
640 let mut errors = Vec::new();
644 let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
645 std::collections::BTreeSet::new();
646 if let Some(parent) = source.parent() {
647 dirs_to_fsync.insert(parent.to_path_buf());
648 }
649 if let Some(parent) = dest.parent() {
650 dirs_to_fsync.insert(parent.to_path_buf());
651 }
652 for change in report.changes.iter().skip(1) {
653 let path = &change.path;
654 let content = match std::fs::read_to_string(path) {
655 Ok(c) => c,
656 Err(e) => {
657 errors.push(MutationError {
658 path: path.clone(),
659 message: format!("read failed: {}", e),
660 });
661 continue;
662 }
663 };
664 let new_content = rewrite_wikilinks(&content, &self.from, &self.to);
665 if new_content == content {
666 continue;
667 }
668 if let Err(e) = writer::atomic_write_with(path, &new_content, self.write_options) {
669 errors.push(MutationError {
670 path: path.clone(),
671 message: format!("write failed: {}", e),
672 });
673 }
674 }
675
676 if self.write_options.fsync {
681 for d in &dirs_to_fsync {
682 if let Err(e) = writer::fsync_dir(d) {
683 errors.push(MutationError {
684 path: d.clone(),
685 message: format!("fsync_dir failed: {}", e),
686 });
687 }
688 }
689 }
690
691 if errors.is_empty() {
695 crate::journal::delete(&journal_path);
696 }
697
698 Ok(MutationReport {
699 changes: report.changes,
700 errors,
701 })
702 })
703 }
704}
705
706#[derive(Debug, Clone)]
725pub struct CreateBuilder {
726 folder: String,
727 name: String,
728 template: Option<String>,
729 set_fields: Vec<(String, Value)>,
730 schema: Option<crate::schema::CollectionSchema>,
731 write_options: writer::WriteOptions,
732}
733
734impl CreateBuilder {
735 pub fn new(folder: impl Into<String>, name: impl Into<String>) -> Self {
736 Self {
737 folder: folder.into(),
738 name: name.into(),
739 template: None,
740 set_fields: Vec::new(),
741 schema: None,
742 write_options: writer::WriteOptions::default(),
743 }
744 }
745
746 pub fn template(mut self, path: impl Into<String>) -> Self {
750 self.template = Some(path.into());
751 self
752 }
753
754 pub fn set(mut self, field: impl Into<String>, value: Value) -> Self {
757 self.set_fields.push((field.into(), value));
758 self
759 }
760
761 pub fn with_schema(mut self, schema: crate::schema::CollectionSchema) -> Self {
766 self.schema = Some(schema);
767 self
768 }
769
770 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
771 self.write_options = opts;
772 self
773 }
774
775 pub fn fsync(mut self, yes: bool) -> Self {
776 self.write_options.fsync = yes;
777 self
778 }
779
780 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
782 let (report, _) = self.compute(vault)?;
783 Ok(report)
784 }
785
786 pub fn plan_with_content(&self, vault: &Vault) -> Result<(MutationReport, Option<String>)> {
790 let (report, write) = self.compute(vault)?;
791 Ok((report, write.map(|w| w.modified_content)))
792 }
793
794 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
798 crate::lock::with_lock(&vault.root, || {
799 let (report, write) = self.compute(vault)?;
800 if !report.errors.is_empty() {
801 return Ok(report);
802 }
803 if let Some(w) = write {
804 if let Some(parent) = w.path.parent()
805 && !parent.exists()
806 {
807 std::fs::create_dir_all(parent).map_err(VaultdbError::Io)?;
808 }
809 writer::atomic_write_with(&w.path, &w.modified_content, self.write_options)
810 .map_err(VaultdbError::Io)?;
811 }
812 Ok(report)
813 })
814 }
815
816 fn compute(&self, vault: &Vault) -> Result<(MutationReport, Option<WriteResult>)> {
817 let folder_path = vault.root.join(&self.folder);
822 let filename = format!("{}.md", self.name);
823 let dest = folder_path.join(&filename);
824
825 let mut changes = Vec::new();
826 let mut errors = Vec::new();
827
828 if dest.exists() {
829 errors.push(MutationError {
830 path: dest.clone(),
831 message: format!("file already exists: {}", dest.display()),
832 });
833 return Ok((MutationReport { changes, errors }, None));
834 }
835
836 let (mut fields, body) = match &self.template {
840 Some(tmpl) => {
841 let tmpl_path = vault.root.join(tmpl);
842 if !tmpl_path.is_file() {
843 errors.push(MutationError {
844 path: tmpl_path.clone(),
845 message: format!("template not found: {}", tmpl_path.display()),
846 });
847 return Ok((MutationReport { changes, errors }, None));
848 }
849 let raw = std::fs::read_to_string(&tmpl_path).map_err(VaultdbError::Io)?;
850 split_template(&raw)
851 }
852 None => (
853 std::collections::BTreeMap::<String, Value>::new(),
854 format!("\n# {}\n", self.name),
855 ),
856 };
857
858 for (k, v) in &self.set_fields {
860 fields.insert(k.clone(), v.clone());
861 }
862
863 if let Some(schema) = &self.schema {
865 for (name, fs) in &schema.fields {
866 if fields.contains_key(name) {
867 continue;
868 }
869 if let Some(default) = &fs.default {
870 fields.insert(name.clone(), default.clone());
871 } else if let Some(expr) = &fs.default_expr {
872 match crate::schema::resolve_default_expr(expr) {
873 Ok(v) => {
874 fields.insert(name.clone(), v);
875 }
876 Err(e) => {
877 errors.push(MutationError {
878 path: dest.clone(),
879 message: format!("resolving default_expr for '{}': {}", name, e),
880 });
881 }
882 }
883 }
884 }
885
886 for req in &schema.required {
888 let satisfied = matches!(fields.get(req), Some(v) if !matches!(v, Value::Null));
889 if !satisfied {
890 errors.push(MutationError {
891 path: dest.clone(),
892 message: format!("required field missing: '{}'", req),
893 });
894 }
895 }
896 }
897
898 if !errors.is_empty() {
899 return Ok((MutationReport { changes, errors }, None));
900 }
901
902 let frontmatter_yaml = if fields.is_empty() {
906 String::new()
907 } else {
908 serde_yaml::to_string(&fields)
909 .map_err(|e| VaultdbError::SchemaError(format!("rendering frontmatter: {}", e)))?
910 };
911 let content = if frontmatter_yaml.is_empty() {
912 format!("---\n---\n{}", body)
913 } else {
914 format!("---\n{}---\n{}", frontmatter_yaml, body)
915 };
916
917 let field_count = fields.len();
918 let field_summary: String = fields.keys().cloned().collect::<Vec<_>>().join(", ");
919 let description = if field_count == 0 {
920 "create (no frontmatter fields)".to_string()
921 } else {
922 format!("create with {} field(s): {}", field_count, field_summary)
923 };
924
925 changes.push(PlannedChange {
926 path: dest.clone(),
927 description,
928 });
929
930 let write = WriteResult {
931 path: dest,
932 original_content: String::new(),
933 modified_content: content,
934 changes: Vec::new(),
935 };
936
937 Ok((MutationReport { changes, errors }, Some(write)))
938 }
939}
940
941fn split_template(raw: &str) -> (std::collections::BTreeMap<String, Value>, String) {
945 use crate::frontmatter::{extract_frontmatter, parse_frontmatter};
946 match extract_frontmatter(raw) {
947 Some((yaml_text, body_start)) => {
948 let fields = parse_frontmatter(yaml_text).unwrap_or_default();
949 let body = raw[body_start..].to_string();
950 (fields, body)
951 }
952 None => (std::collections::BTreeMap::new(), raw.to_string()),
953 }
954}
955
956pub(crate) fn rewrite_wikilinks(content: &str, from: &str, to: &str) -> String {
959 content
960 .replace(&format!("[[{}]]", from), &format!("[[{}]]", to))
961 .replace(&format!("[[{}|", from), &format!("[[{}|", to))
962 .replace(&format!("[[{}#", from), &format!("[[{}#", to))
963}
964
965fn render_value_for_yaml(v: &Value) -> String {
971 match v {
972 Value::Null => "null".to_string(),
973 Value::Bool(b) => b.to_string(),
974 Value::Integer(i) => i.to_string(),
975 Value::Float(f) => f.to_string(),
976 Value::String(s) => writer::quote_value(s),
977 Value::List(_) | Value::Map(_) => {
978 let yaml = serde_yaml::to_string(v).unwrap_or_default();
979 yaml.trim_end().to_string()
980 }
981 }
982}
983
984#[cfg(test)]
985mod tests {
986 use super::*;
987 use crate::query::Predicate;
988
989 #[test]
990 fn update_builder_chains() {
991 let filter = Expr::Predicate(Predicate::Equals {
992 field: "status".into(),
993 value: Value::String("active".into()),
994 });
995 let b = UpdateBuilder::new("notes", filter)
996 .set("priority", Value::Integer(1))
997 .unset("draft")
998 .add_tag("urgent")
999 .remove_tag("stale");
1000 assert_eq!(b.set_fields.len(), 1);
1001 assert_eq!(b.unset_fields.len(), 1);
1002 assert_eq!(b.add_tags.len(), 1);
1003 assert_eq!(b.remove_tags.len(), 1);
1004 }
1005
1006 #[test]
1007 fn delete_builder_trash_moves_to_dot_trash() {
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(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
1014 let vault = Vault::with_root(dir.path().to_path_buf());
1015 let filter = Expr::Predicate(Predicate::Equals {
1016 field: "status".into(),
1017 value: Value::String("stale".into()),
1018 });
1019 let builder = DeleteBuilder::new("notes", filter);
1020 let report = builder.execute(&vault).unwrap();
1021 assert_eq!(report.changes.len(), 1);
1022 assert_eq!(report.errors.len(), 0);
1023 assert!(!dir.path().join("notes/a.md").exists());
1024 assert!(dir.path().join(".trash/a.md").exists());
1025 }
1026
1027 #[test]
1028 fn delete_builder_permanent_removes_file() {
1029 use std::fs;
1030 use tempfile::TempDir;
1031 let dir = TempDir::new().unwrap();
1032 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1033 fs::create_dir(dir.path().join("notes")).unwrap();
1034 fs::write(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
1035 let vault = Vault::with_root(dir.path().to_path_buf());
1036 let filter = Expr::Predicate(Predicate::Equals {
1037 field: "status".into(),
1038 value: Value::String("stale".into()),
1039 });
1040 let builder = DeleteBuilder::new("notes", filter).permanent(true);
1041 builder.execute(&vault).unwrap();
1042 assert!(!dir.path().join("notes/a.md").exists());
1043 assert!(!dir.path().join(".trash/a.md").exists());
1044 }
1045
1046 #[test]
1047 fn move_builder_relocates_files() {
1048 use std::fs;
1049 use tempfile::TempDir;
1050 let dir = TempDir::new().unwrap();
1051 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1052 fs::create_dir(dir.path().join("notes")).unwrap();
1053 fs::write(
1054 dir.path().join("notes/a.md"),
1055 "---\nstatus: archived\n---\n",
1056 )
1057 .unwrap();
1058 let vault = Vault::with_root(dir.path().to_path_buf());
1059 let filter = Expr::Predicate(Predicate::Equals {
1060 field: "status".into(),
1061 value: Value::String("archived".into()),
1062 });
1063 let builder = MoveBuilder::new("notes", "archive", filter);
1064 let report = builder.execute(&vault).unwrap();
1065 assert_eq!(report.changes.len(), 1);
1066 assert_eq!(report.errors.len(), 0);
1067 assert!(!dir.path().join("notes/a.md").exists());
1068 assert!(dir.path().join("archive/a.md").exists());
1069 }
1070
1071 #[test]
1072 fn rename_builder_renames_and_rewrites_links() {
1073 use std::fs;
1074 use tempfile::TempDir;
1075 let dir = TempDir::new().unwrap();
1076 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1077 fs::create_dir(dir.path().join("notes")).unwrap();
1078 fs::write(
1079 dir.path().join("notes/old.md"),
1080 "---\nstatus: x\n---\nBody\n",
1081 )
1082 .unwrap();
1083 fs::write(
1084 dir.path().join("notes/source.md"),
1085 "---\nstatus: y\n---\nLinks to [[old]] and [[old|alias]] and [[old#section]].\n",
1086 )
1087 .unwrap();
1088 let vault = Vault::with_root(dir.path().to_path_buf());
1089
1090 let builder = RenameBuilder::new("notes", "old", "new");
1091 let report = builder.execute(&vault).unwrap();
1092 assert_eq!(report.changes.len(), 2);
1094 assert_eq!(report.errors.len(), 0);
1095 assert!(!dir.path().join("notes/old.md").exists());
1096 assert!(dir.path().join("notes/new.md").exists());
1097 let source_after = fs::read_to_string(dir.path().join("notes/source.md")).unwrap();
1098 assert!(source_after.contains("[[new]]"));
1099 assert!(source_after.contains("[[new|alias]]"));
1100 assert!(source_after.contains("[[new#section]]"));
1101 assert!(!source_after.contains("[[old"));
1102 }
1103
1104 #[test]
1105 fn rename_builder_target_conflict_returns_error() {
1106 use std::fs;
1107 use tempfile::TempDir;
1108 let dir = TempDir::new().unwrap();
1109 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1110 fs::create_dir(dir.path().join("notes")).unwrap();
1111 fs::write(dir.path().join("notes/old.md"), "---\nstatus: x\n---\n").unwrap();
1112 fs::write(dir.path().join("notes/new.md"), "---\nstatus: y\n---\n").unwrap();
1113 let vault = Vault::with_root(dir.path().to_path_buf());
1114 let report = RenameBuilder::new("notes", "old", "new")
1115 .execute(&vault)
1116 .unwrap();
1117 assert_eq!(report.changes.len(), 0);
1118 assert_eq!(report.errors.len(), 1);
1119 assert!(dir.path().join("notes/old.md").exists());
1121 }
1122
1123 #[test]
1124 fn update_builder_plan_and_execute_against_a_temp_vault() {
1125 use std::fs;
1126 use tempfile::TempDir;
1127
1128 let dir = TempDir::new().unwrap();
1129 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1130 fs::create_dir(dir.path().join("notes")).unwrap();
1131 fs::write(
1132 dir.path().join("notes/a.md"),
1133 "---\nstatus: active\n---\nBody A\n",
1134 )
1135 .unwrap();
1136 fs::write(
1137 dir.path().join("notes/b.md"),
1138 "---\nstatus: pending\n---\nBody B\n",
1139 )
1140 .unwrap();
1141
1142 let vault = Vault::with_root(dir.path().to_path_buf());
1143
1144 let filter = Expr::Predicate(Predicate::Equals {
1145 field: "status".into(),
1146 value: Value::String("active".into()),
1147 });
1148 let builder = UpdateBuilder::new("notes", filter).set("priority", Value::Integer(1));
1149
1150 let plan_report = builder.plan(&vault).unwrap();
1152 assert_eq!(plan_report.changes.len(), 1);
1153 assert_eq!(plan_report.errors.len(), 0);
1154 assert!(plan_report.changes[0].path.ends_with("a.md"));
1155 let before = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
1156 assert!(!before.contains("priority"));
1157
1158 let exec_report = builder.execute(&vault).unwrap();
1160 assert_eq!(exec_report.changes.len(), 1);
1161 let after = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
1162 assert!(after.contains("priority"));
1163 let b_after = fs::read_to_string(dir.path().join("notes/b.md")).unwrap();
1165 assert!(!b_after.contains("priority"));
1166 }
1167
1168 #[test]
1169 fn write_options_fsync_propagates_through_update_builder() {
1170 use std::fs;
1178 use tempfile::TempDir;
1179
1180 let f1 = Expr::Predicate(Predicate::Equals {
1182 field: "x".into(),
1183 value: Value::Integer(1),
1184 });
1185 let b = UpdateBuilder::new("notes", f1).fsync(true);
1186 assert!(b.write_options.fsync);
1187
1188 let f2 = Expr::Predicate(Predicate::Equals {
1189 field: "x".into(),
1190 value: Value::Integer(1),
1191 });
1192 let b =
1193 UpdateBuilder::new("notes", f2).write_options(crate::writer::WriteOptions::durable());
1194 assert!(b.write_options.fsync);
1195
1196 let dir = TempDir::new().unwrap();
1198 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1199 fs::create_dir(dir.path().join("notes")).unwrap();
1200 fs::write(
1201 dir.path().join("notes/durable.md"),
1202 "---\nstatus: active\n---\nBody.\n",
1203 )
1204 .unwrap();
1205 let vault = Vault::with_root(dir.path().to_path_buf());
1206
1207 let f3 = Expr::Predicate(Predicate::Equals {
1208 field: "status".into(),
1209 value: Value::String("active".into()),
1210 });
1211 let report = UpdateBuilder::new("notes", f3)
1212 .set("priority", Value::Integer(99))
1213 .fsync(true)
1214 .execute(&vault)
1215 .unwrap();
1216 assert_eq!(report.changes.len(), 1);
1217 assert_eq!(report.errors.len(), 0);
1218
1219 let after = fs::read_to_string(dir.path().join("notes/durable.md")).unwrap();
1220 assert!(after.contains("priority: 99"));
1221 assert!(after.contains("status: active"));
1222 }
1223
1224 #[test]
1225 fn rename_clean_run_leaves_no_journal_behind() {
1226 use std::fs;
1229 use tempfile::TempDir;
1230 let dir = TempDir::new().unwrap();
1231 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1232 fs::create_dir(dir.path().join("notes")).unwrap();
1233 fs::write(
1234 dir.path().join("notes/old.md"),
1235 "---\nstatus: x\n---\nBody\n",
1236 )
1237 .unwrap();
1238 fs::write(
1239 dir.path().join("notes/source.md"),
1240 "---\nstatus: y\n---\nLinks to [[old]].\n",
1241 )
1242 .unwrap();
1243 let vault = Vault::with_root(dir.path().to_path_buf());
1244
1245 RenameBuilder::new("notes", "old", "new")
1246 .execute(&vault)
1247 .unwrap();
1248
1249 let pending = crate::journal::list_pending(dir.path()).unwrap();
1250 assert!(
1251 pending.is_empty(),
1252 "successful rename must not leave journals behind: {:?}",
1253 pending
1254 );
1255 }
1256
1257 #[test]
1258 fn rename_recovers_from_pre_existing_journal() {
1259 use std::fs;
1263 use tempfile::TempDir;
1264 let dir = TempDir::new().unwrap();
1265 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1266 fs::create_dir(dir.path().join("notes")).unwrap();
1267 let source = dir.path().join("notes/Stanford.md");
1268 let dest = dir.path().join("notes/Stanford University.md");
1269 let backlink = dir.path().join("notes/Application.md");
1270 fs::write(&source, "---\nkind: university\n---\nMain note.\n").unwrap();
1271 fs::write(
1272 &backlink,
1273 "---\nkind: application\n---\nApplied to [[Stanford]].\n",
1274 )
1275 .unwrap();
1276
1277 let journal = crate::journal::RenameJournal {
1279 source: source.clone(),
1280 dest: dest.clone(),
1281 from_name: "Stanford".into(),
1282 to_name: "Stanford University".into(),
1283 backlinks: vec![backlink.clone()],
1284 };
1285 crate::journal::write(dir.path(), &journal).unwrap();
1286
1287 let vault = Vault::with_root(dir.path().to_path_buf());
1288 let recovered = vault.recover().unwrap();
1289 assert_eq!(recovered, 1, "expected exactly one journal replayed");
1290
1291 assert!(!source.exists());
1293 assert!(dest.is_file());
1294 let backlink_content = fs::read_to_string(&backlink).unwrap();
1295 assert!(backlink_content.contains("[[Stanford University]]"));
1296 assert!(!backlink_content.contains("[[Stanford]]"));
1297 assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
1298 }
1299
1300 #[test]
1301 fn rename_replays_pending_journal_before_starting_new_rename() {
1302 use std::fs;
1306 use tempfile::TempDir;
1307 let dir = TempDir::new().unwrap();
1308 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1309 fs::create_dir(dir.path().join("notes")).unwrap();
1310
1311 let a = dir.path().join("notes/A.md");
1315 let b = dir.path().join("notes/B.md");
1316 let c = dir.path().join("notes/C.md");
1317 let d = dir.path().join("notes/D.md");
1318 fs::write(&a, "---\n---\nA body.\n").unwrap();
1319 fs::write(&c, "---\n---\nC body.\n").unwrap();
1320
1321 crate::journal::write(
1323 dir.path(),
1324 &crate::journal::RenameJournal {
1325 source: a.clone(),
1326 dest: b.clone(),
1327 from_name: "A".into(),
1328 to_name: "B".into(),
1329 backlinks: vec![],
1330 },
1331 )
1332 .unwrap();
1333
1334 let vault = Vault::with_root(dir.path().to_path_buf());
1336 RenameBuilder::new("notes", "C", "D")
1337 .execute(&vault)
1338 .unwrap();
1339
1340 assert!(!a.exists(), "A.md should be gone (replayed journal)");
1343 assert!(b.is_file(), "B.md should exist (replayed journal)");
1344 assert!(!c.exists(), "C.md should be gone (new rename)");
1345 assert!(d.is_file(), "D.md should exist (new rename)");
1346
1347 assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
1349 }
1350
1351 #[test]
1352 fn concurrent_updates_serialize_via_vault_lock() {
1353 use std::fs;
1361 use std::sync::Arc;
1362 use std::thread;
1363 use tempfile::TempDir;
1364
1365 let dir = TempDir::new().unwrap();
1366 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1367 fs::create_dir(dir.path().join("notes")).unwrap();
1368 fs::write(
1369 dir.path().join("notes/race.md"),
1370 "---\nstatus: active\n---\nBody.\n",
1371 )
1372 .unwrap();
1373
1374 let vault_path = Arc::new(dir.path().to_path_buf());
1375
1376 let p1 = Arc::clone(&vault_path);
1377 let t1 = thread::spawn(move || {
1378 let vault = Vault::with_root((*p1).clone());
1379 let filter = Expr::Predicate(Predicate::Equals {
1380 field: "status".into(),
1381 value: Value::String("active".into()),
1382 });
1383 UpdateBuilder::new("notes", filter)
1384 .set("touched_by_t1", Value::Integer(1))
1385 .execute(&vault)
1386 .expect("t1 execute")
1387 });
1388
1389 let p2 = Arc::clone(&vault_path);
1390 let t2 = thread::spawn(move || {
1391 let vault = Vault::with_root((*p2).clone());
1392 let filter = Expr::Predicate(Predicate::Equals {
1393 field: "status".into(),
1394 value: Value::String("active".into()),
1395 });
1396 UpdateBuilder::new("notes", filter)
1397 .set("touched_by_t2", Value::Integer(1))
1398 .execute(&vault)
1399 .expect("t2 execute")
1400 });
1401
1402 let r1 = t1.join().unwrap();
1403 let r2 = t2.join().unwrap();
1404 assert_eq!(r1.errors.len(), 0);
1405 assert_eq!(r2.errors.len(), 0);
1406
1407 let final_content = fs::read_to_string(dir.path().join("notes/race.md")).unwrap();
1411 assert!(
1412 final_content.contains("touched_by_t1"),
1413 "t1's edit lost; concurrent writer race: {}",
1414 final_content
1415 );
1416 assert!(
1417 final_content.contains("touched_by_t2"),
1418 "t2's edit lost; concurrent writer race: {}",
1419 final_content
1420 );
1421 }
1422
1423 #[test]
1424 fn atomic_write_does_not_leave_partial_files_on_failed_writes() {
1425 use std::fs;
1431 use tempfile::TempDir;
1432
1433 let dir = TempDir::new().unwrap();
1434 let target = dir.path().join("subdir/that-does-not-exist/x.md");
1435 let result = crate::writer::atomic_write(&target, "new content");
1438 assert!(
1439 result.is_err(),
1440 "expected atomic_write to fail when parent dir doesn't exist"
1441 );
1442
1443 let real_dir = dir.path().join("real");
1446 fs::create_dir(&real_dir).unwrap();
1447 let real_target = real_dir.join("x.md");
1448 fs::write(&real_target, "original").unwrap();
1449 crate::writer::atomic_write(&real_target, "replacement").unwrap();
1450 let after = fs::read_to_string(&real_target).unwrap();
1451 assert_eq!(after, "replacement");
1452
1453 let leftovers: Vec<_> = fs::read_dir(&real_dir)
1455 .unwrap()
1456 .flatten()
1457 .filter(|e| e.file_name().to_string_lossy().starts_with(".tmp"))
1458 .collect();
1459 assert!(
1460 leftovers.is_empty(),
1461 "expected no tempfile leftovers, found: {:?}",
1462 leftovers.iter().map(|e| e.path()).collect::<Vec<_>>()
1463 );
1464 }
1465
1466 use crate::schema::{CollectionSchema, FieldSchema};
1469
1470 fn movie_schema() -> CollectionSchema {
1471 let mut fields = std::collections::BTreeMap::new();
1472 fields.insert(
1473 "db-table".into(),
1474 FieldSchema {
1475 field_type: "string".into(),
1476 enum_values: vec![Value::String("movie".into())],
1477 min: None,
1478 max: None,
1479 default: Some(Value::String("movie".into())),
1480 default_expr: None,
1481 },
1482 );
1483 fields.insert(
1484 "status".into(),
1485 FieldSchema {
1486 field_type: "string".into(),
1487 enum_values: vec![
1488 Value::String("to-watch".into()),
1489 Value::String("watched".into()),
1490 ],
1491 min: None,
1492 max: None,
1493 default: Some(Value::String("to-watch".into())),
1494 default_expr: None,
1495 },
1496 );
1497 fields.insert(
1498 "director".into(),
1499 FieldSchema {
1500 field_type: "string".into(),
1501 enum_values: vec![],
1502 min: None,
1503 max: None,
1504 default: None,
1505 default_expr: None,
1506 },
1507 );
1508 fields.insert(
1509 "year".into(),
1510 FieldSchema {
1511 field_type: "integer".into(),
1512 enum_values: vec![],
1513 min: None,
1514 max: None,
1515 default: None,
1516 default_expr: None,
1517 },
1518 );
1519 CollectionSchema {
1520 description: None,
1521 folder: "Notes/movie".into(),
1522 filter: vec![],
1523 required: vec![
1524 "db-table".into(),
1525 "director".into(),
1526 "status".into(),
1527 "year".into(),
1528 ],
1529 fields,
1530 }
1531 }
1532
1533 fn vault_with_obsidian() -> tempfile::TempDir {
1534 let dir = tempfile::TempDir::new().unwrap();
1535 std::fs::create_dir(dir.path().join(".obsidian")).unwrap();
1536 dir
1537 }
1538
1539 #[test]
1540 fn create_without_schema_writes_minimal_file() {
1541 let dir = vault_with_obsidian();
1542 let vault = Vault::with_root(dir.path().to_path_buf());
1543 let report = CreateBuilder::new("Notes/movie", "Dune")
1544 .execute(&vault)
1545 .unwrap();
1546 assert_eq!(report.errors.len(), 0);
1547 assert_eq!(report.changes.len(), 1);
1548 let written = dir.path().join("Notes/movie/Dune.md");
1549 assert!(written.is_file());
1550 let content = std::fs::read_to_string(&written).unwrap();
1551 assert!(content.contains("---\n---"));
1553 assert!(content.contains("# Dune"));
1554 }
1555
1556 #[test]
1557 fn create_with_set_writes_typed_frontmatter() {
1558 let dir = vault_with_obsidian();
1559 let vault = Vault::with_root(dir.path().to_path_buf());
1560 CreateBuilder::new("Notes/movie", "Dune")
1561 .set("director", Value::String("Denis Villeneuve".into()))
1562 .set("year", Value::Integer(2021))
1563 .execute(&vault)
1564 .unwrap();
1565 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1566 assert!(content.contains("director: Denis Villeneuve"));
1567 assert!(content.contains("year: 2021"));
1569 }
1570
1571 #[test]
1572 fn create_fills_schema_defaults() {
1573 let dir = vault_with_obsidian();
1574 let vault = Vault::with_root(dir.path().to_path_buf());
1575 CreateBuilder::new("Notes/movie", "Dune")
1576 .with_schema(movie_schema())
1577 .set("director", Value::String("Denis Villeneuve".into()))
1578 .set("year", Value::Integer(2021))
1579 .execute(&vault)
1580 .unwrap();
1581 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1582 assert!(content.contains("db-table: movie"));
1584 assert!(content.contains("status: to-watch"));
1585 assert!(content.contains("director: Denis Villeneuve"));
1587 assert!(content.contains("year: 2021"));
1588 }
1589
1590 #[test]
1591 fn create_set_overrides_default() {
1592 let dir = vault_with_obsidian();
1593 let vault = Vault::with_root(dir.path().to_path_buf());
1594 CreateBuilder::new("Notes/movie", "Watched")
1595 .with_schema(movie_schema())
1596 .set("director", Value::String("X".into()))
1597 .set("year", Value::Integer(2020))
1598 .set("status", Value::String("watched".into()))
1599 .execute(&vault)
1600 .unwrap();
1601 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Watched.md")).unwrap();
1602 assert!(content.contains("status: watched"));
1603 assert!(!content.contains("status: to-watch"));
1604 }
1605
1606 #[test]
1607 fn create_rejects_missing_required_before_writing() {
1608 let dir = vault_with_obsidian();
1609 let vault = Vault::with_root(dir.path().to_path_buf());
1610 let report = CreateBuilder::new("Notes/movie", "Blank")
1612 .with_schema(movie_schema())
1613 .execute(&vault)
1614 .unwrap();
1615 assert!(!report.errors.is_empty());
1616 assert!(report.errors.iter().any(|e| e.message.contains("director")));
1617 assert!(report.errors.iter().any(|e| e.message.contains("year")));
1618 assert!(!dir.path().join("Notes/movie/Blank.md").exists());
1620 }
1621
1622 #[test]
1623 fn create_rejects_existing_file() {
1624 let dir = vault_with_obsidian();
1625 std::fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
1626 std::fs::write(dir.path().join("Notes/movie/Dune.md"), "existing\n").unwrap();
1627 let vault = Vault::with_root(dir.path().to_path_buf());
1628 let report = CreateBuilder::new("Notes/movie", "Dune")
1629 .execute(&vault)
1630 .unwrap();
1631 assert_eq!(report.errors.len(), 1);
1632 assert!(report.errors[0].message.contains("already exists"));
1633 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1635 assert_eq!(content, "existing\n");
1636 }
1637
1638 #[test]
1639 fn create_resolves_default_expr_today() {
1640 let dir = vault_with_obsidian();
1641 let vault = Vault::with_root(dir.path().to_path_buf());
1642 let mut fields = std::collections::BTreeMap::new();
1643 fields.insert(
1644 "due".into(),
1645 FieldSchema {
1646 field_type: "date".into(),
1647 enum_values: vec![],
1648 min: None,
1649 max: None,
1650 default: None,
1651 default_expr: Some("today".into()),
1652 },
1653 );
1654 let schema = CollectionSchema {
1655 description: None,
1656 folder: "tasks".into(),
1657 filter: vec![],
1658 required: vec![],
1659 fields,
1660 };
1661 CreateBuilder::new("tasks", "t1")
1662 .with_schema(schema)
1663 .execute(&vault)
1664 .unwrap();
1665 let content = std::fs::read_to_string(dir.path().join("tasks/t1.md")).unwrap();
1666 let today = crate::record::today_string();
1668 assert!(
1669 content.contains(&format!("due: {}", today)),
1670 "expected due={} in: {}",
1671 today,
1672 content
1673 );
1674 }
1675
1676 #[test]
1677 fn create_plan_does_not_touch_disk() {
1678 let dir = vault_with_obsidian();
1679 let vault = Vault::with_root(dir.path().to_path_buf());
1680 let (report, content) = CreateBuilder::new("Notes/movie", "Dune")
1681 .with_schema(movie_schema())
1682 .set("director", Value::String("DV".into()))
1683 .set("year", Value::Integer(2021))
1684 .plan_with_content(&vault)
1685 .unwrap();
1686 assert_eq!(report.errors.len(), 0);
1687 assert_eq!(report.changes.len(), 1);
1688 assert!(!dir.path().join("Notes/movie/Dune.md").exists());
1689 let c = content.unwrap();
1690 assert!(c.contains("director: DV"));
1691 assert!(c.contains("db-table: movie")); }
1693
1694 #[test]
1695 fn create_from_template_preserves_body_and_merges_frontmatter() {
1696 let dir = vault_with_obsidian();
1697 std::fs::create_dir_all(dir.path().join("templates")).unwrap();
1698 std::fs::write(
1699 dir.path().join("templates/movie.md"),
1700 "---\nstatus: to-watch\naliases: []\n---\n\n# Title\n\nReview goes here.\n",
1701 )
1702 .unwrap();
1703 let vault = Vault::with_root(dir.path().to_path_buf());
1704 CreateBuilder::new("Notes/movie", "Dune")
1705 .template("templates/movie.md")
1706 .set("year", Value::Integer(2021))
1707 .execute(&vault)
1708 .unwrap();
1709 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1710 assert!(content.contains("status: to-watch"));
1712 assert!(content.contains("year: 2021"));
1714 assert!(content.contains("Review goes here"));
1716 }
1717}