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 vault_schema: Option<crate::schema::VaultSchema>,
56 write_options: writer::WriteOptions,
57 recursive: bool,
58}
59
60impl UpdateBuilder {
61 pub fn new(folder: impl Into<String>, filter: Expr) -> Self {
62 Self {
63 filter,
64 folder: folder.into(),
65 set_fields: Vec::new(),
66 unset_fields: Vec::new(),
67 add_tags: Vec::new(),
68 remove_tags: Vec::new(),
69 vault_schema: None,
70 write_options: writer::WriteOptions::default(),
71 recursive: false,
72 }
73 }
74
75 pub fn recursive(mut self, yes: bool) -> Self {
80 self.recursive = yes;
81 self
82 }
83
84 pub fn set(mut self, field: impl Into<String>, value: Value) -> Self {
85 self.set_fields.push((field.into(), value));
86 self
87 }
88
89 pub fn unset(mut self, field: impl Into<String>) -> Self {
90 self.unset_fields.push(field.into());
91 self
92 }
93
94 pub fn add_tag(mut self, tag: impl Into<String>) -> Self {
95 self.add_tags.push(tag.into());
96 self
97 }
98
99 pub fn remove_tag(mut self, tag: impl Into<String>) -> Self {
100 self.remove_tags.push(tag.into());
101 self
102 }
103
104 pub fn with_vault_schema(mut self, schema: crate::schema::VaultSchema) -> Self {
113 self.vault_schema = Some(schema);
114 self
115 }
116
117 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
120 self.write_options = opts;
121 self
122 }
123
124 pub fn fsync(mut self, yes: bool) -> Self {
127 self.write_options.fsync = yes;
128 self
129 }
130
131 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
133 let (report, _writes) = self.compute(vault)?;
134 Ok(report)
135 }
136
137 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
145 crate::lock::with_lock(&vault.root, || {
146 let (report, writes) = self.compute(vault)?;
147 for w in &writes {
148 writer::apply_with(w, self.write_options).map_err(VaultdbError::Io)?;
149 }
150 Ok(report)
151 })
152 }
153
154 fn compute(&self, vault: &Vault) -> Result<(MutationReport, Vec<WriteResult>)> {
155 let folder_path = vault.resolve_folder(&self.folder)?;
156 let load = vault.load_records_with_content(&folder_path, self.recursive, false)?;
157 let needs_links = crate::filter::expr_uses_links(&self.filter);
158 let link_index = if needs_links {
159 Some(crate::links::LinkGraph::build_with_root(
160 &load.records,
161 Some(&vault.root),
162 ))
163 } else {
164 None
165 };
166
167 let mut changes = Vec::new();
168 let mut errors = Vec::new();
169 let mut writes = Vec::new();
170
171 for record in &load.records {
172 if !crate::filter::evaluate_expr(&self.filter, record, &vault.root, link_index.as_ref())
173 {
174 continue;
175 }
176
177 if let Some(vault_schema) = &self.vault_schema {
184 let projected_fields = self.project_fields(&record.fields);
185 let projected_record = crate::record::Record {
186 path: record.path.clone(),
187 fields: projected_fields.clone(),
188 raw_content: record.raw_content.clone(),
189 };
190 let applicable = match vault_schema.applicable_collections(
191 &self.folder,
192 &projected_record,
193 &vault.root,
194 ) {
195 Ok(cols) => cols,
196 Err(e) => {
197 errors.push(MutationError {
198 path: record.path.clone(),
199 message: format!("evaluating schema applicability: {}", e),
200 });
201 continue;
202 }
203 };
204 let mut seen = std::collections::BTreeSet::<(String, String)>::new();
205 let mut had_violation = false;
206 let filename = record.virtual_name();
207 for col in &applicable {
208 for v in crate::schema::validate_record(&filename, &projected_fields, col) {
209 if seen.insert((v.field.clone(), v.message.clone())) {
210 errors.push(MutationError {
211 path: record.path.clone(),
212 message: format!("schema: {} — {}", v.field, v.message),
213 });
214 had_violation = true;
215 }
216 }
217 }
218 if had_violation {
219 continue;
220 }
221 }
222
223 let mut content = record
228 .raw_content
229 .as_ref()
230 .ok_or_else(|| {
231 VaultdbError::Internal(format!(
232 "record at {} has no raw_content; UpdateBuilder loaded without content",
233 record.path.display()
234 ))
235 })?
236 .clone();
237 let original_content = content.clone();
238 let mut wr_changes = Vec::new();
239 let mut description_parts: Vec<String> = Vec::new();
240
241 let result: Result<()> = (|| {
242 for (field, value) in &self.set_fields {
243 let (new_content, change) = match value {
253 Value::List(_) | Value::Map(_) => {
254 writer::set_field_block(&content, field, value)?
255 }
256 _ => {
257 let value_str = render_value_for_yaml(value);
258 writer::set_field_preformatted(&content, field, &value_str)?
259 }
260 };
261 description_parts.push(format!("{}", change));
262 wr_changes.push(change);
263 content = new_content;
264 }
265 for field in &self.unset_fields {
266 let (new_content, change) = writer::unset_field(&content, field)?;
267 description_parts.push(format!("{}", change));
268 wr_changes.push(change);
269 content = new_content;
270 }
271 for tag in &self.add_tags {
272 let (new_content, change) = writer::add_tag(&content, tag)?;
273 description_parts.push(format!("{}", change));
274 wr_changes.push(change);
275 content = new_content;
276 }
277 for tag in &self.remove_tags {
278 let (new_content, change) = writer::remove_tag(&content, tag)?;
279 description_parts.push(format!("{}", change));
280 wr_changes.push(change);
281 content = new_content;
282 }
283 Ok(())
284 })();
285
286 match result {
287 Ok(_) => {
288 if !wr_changes.is_empty() && content != original_content {
292 writes.push(WriteResult {
293 path: record.path.clone(),
294 original_content,
295 modified_content: content,
296 changes: wr_changes,
297 });
298 changes.push(PlannedChange {
299 path: record.path.clone(),
300 description: description_parts.join("; "),
301 });
302 }
303 }
304 Err(e) => errors.push(MutationError {
305 path: record.path.clone(),
306 message: e.to_string(),
307 }),
308 }
309 }
310
311 Ok((MutationReport { changes, errors }, writes))
312 }
313
314 fn project_fields(
322 &self,
323 original: &std::collections::BTreeMap<String, Value>,
324 ) -> std::collections::BTreeMap<String, Value> {
325 let mut fields = original.clone();
326 for (k, v) in &self.set_fields {
327 fields.insert(k.clone(), v.clone());
328 }
329 for k in &self.unset_fields {
330 fields.remove(k);
331 }
332 if !self.add_tags.is_empty() || !self.remove_tags.is_empty() {
333 let mut tags_list: Vec<Value> = match fields.get("tags") {
334 Some(Value::List(l)) => l.clone(),
335 _ => Vec::new(),
336 };
337 for t in &self.add_tags {
338 tags_list.push(Value::String(t.clone()));
339 }
340 for t in &self.remove_tags {
341 if let Some(idx) = tags_list
342 .iter()
343 .position(|v| matches!(v, Value::String(s) if s == t))
344 {
345 tags_list.remove(idx);
346 }
347 }
348 fields.insert("tags".to_string(), Value::List(tags_list));
349 }
350 fields
351 }
352}
353
354#[derive(Debug, Clone)]
360pub struct DeleteBuilder {
361 filter: Expr,
362 folder: String,
363 permanent: bool,
364 write_options: writer::WriteOptions,
365 recursive: bool,
366}
367
368impl DeleteBuilder {
369 pub fn new(folder: impl Into<String>, filter: Expr) -> Self {
370 Self {
371 filter,
372 folder: folder.into(),
373 permanent: false,
374 write_options: writer::WriteOptions::default(),
375 recursive: false,
376 }
377 }
378
379 pub fn recursive(mut self, yes: bool) -> Self {
382 self.recursive = yes;
383 self
384 }
385
386 pub fn permanent(mut self, yes: bool) -> Self {
387 self.permanent = yes;
388 self
389 }
390
391 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
393 self.write_options = opts;
394 self
395 }
396
397 pub fn fsync(mut self, yes: bool) -> Self {
400 self.write_options.fsync = yes;
401 self
402 }
403
404 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
405 let folder_path = vault.resolve_folder(&self.folder)?;
406 let load = vault.load_records(&folder_path, self.recursive, false)?;
407 let needs_links = crate::filter::expr_uses_links(&self.filter);
408 let link_index = if needs_links {
409 Some(crate::links::LinkGraph::build_with_root(
410 &load.records,
411 Some(&vault.root),
412 ))
413 } else {
414 None
415 };
416
417 let mut changes = Vec::new();
418 for r in &load.records {
419 if !crate::filter::evaluate_expr(&self.filter, r, &vault.root, link_index.as_ref()) {
420 continue;
421 }
422 changes.push(PlannedChange {
423 path: r.path.clone(),
424 description: if self.permanent {
425 "delete (permanent)".to_string()
426 } else {
427 "move to .trash/".to_string()
428 },
429 });
430 }
431 Ok(MutationReport {
432 changes,
433 errors: Vec::new(),
434 })
435 }
436
437 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
438 crate::lock::with_lock(&vault.root, || {
439 let report = self.plan(vault)?;
440 let mut errors = Vec::new();
441 let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
443 std::collections::BTreeSet::new();
444
445 if self.permanent {
446 for change in &report.changes {
447 if let Err(e) = std::fs::remove_file(&change.path) {
448 errors.push(MutationError {
449 path: change.path.clone(),
450 message: format!("remove failed: {}", e),
451 });
452 } else if let Some(parent) = change.path.parent() {
453 dirs_to_fsync.insert(parent.to_path_buf());
454 }
455 }
456 } else {
457 let trash_dir = vault.root.join(".trash");
458 if !report.changes.is_empty() {
459 std::fs::create_dir_all(&trash_dir).map_err(VaultdbError::Io)?;
460 }
461 for change in &report.changes {
462 let dest = unique_in_dir(&trash_dir, &change.path);
463 if let Err(e) = std::fs::rename(&change.path, &dest) {
464 errors.push(MutationError {
465 path: change.path.clone(),
466 message: format!("trash failed: {}", e),
467 });
468 } else {
469 if let Some(parent) = change.path.parent() {
470 dirs_to_fsync.insert(parent.to_path_buf());
471 }
472 dirs_to_fsync.insert(trash_dir.clone());
473 }
474 }
475 }
476
477 if self.write_options.fsync {
480 for d in &dirs_to_fsync {
481 if let Err(e) = writer::fsync_dir(d) {
482 errors.push(MutationError {
483 path: d.clone(),
484 message: format!("fsync_dir failed: {}", e),
485 });
486 }
487 }
488 }
489
490 Ok(MutationReport {
491 changes: report.changes,
492 errors,
493 })
494 })
495 }
496}
497
498fn unique_in_dir(dir: &std::path::Path, src: &std::path::Path) -> PathBuf {
499 let filename = src.file_name().and_then(|n| n.to_str()).unwrap_or("file");
500 let candidate = dir.join(filename);
501 if !candidate.exists() {
502 return candidate;
503 }
504 let stem = src.file_stem().and_then(|n| n.to_str()).unwrap_or("file");
505 let ext = src.extension().and_then(|n| n.to_str()).unwrap_or("md");
506 let mut i = 1;
507 loop {
508 let c = dir.join(format!("{}-{}.{}", stem, i, ext));
509 if !c.exists() {
510 return c;
511 }
512 i += 1;
513 }
514}
515
516#[derive(Debug, Clone)]
522pub struct MoveBuilder {
523 filter: Expr,
524 folder: String,
525 to_folder: String,
526 write_options: writer::WriteOptions,
527 recursive: bool,
528}
529
530impl MoveBuilder {
531 pub fn new(folder: impl Into<String>, to_folder: impl Into<String>, filter: Expr) -> Self {
532 Self {
533 filter,
534 folder: folder.into(),
535 to_folder: to_folder.into(),
536 write_options: writer::WriteOptions::default(),
537 recursive: false,
538 }
539 }
540
541 pub fn recursive(mut self, yes: bool) -> Self {
544 self.recursive = yes;
545 self
546 }
547
548 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
550 self.write_options = opts;
551 self
552 }
553
554 pub fn fsync(mut self, yes: bool) -> Self {
556 self.write_options.fsync = yes;
557 self
558 }
559
560 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
561 let folder_path = vault.resolve_folder(&self.folder)?;
562 let to_path = vault.root.join(&self.to_folder);
563 let load = vault.load_records(&folder_path, self.recursive, false)?;
564 let needs_links = crate::filter::expr_uses_links(&self.filter);
565 let link_index = if needs_links {
566 Some(crate::links::LinkGraph::build_with_root(
567 &load.records,
568 Some(&vault.root),
569 ))
570 } else {
571 None
572 };
573
574 let mut changes = Vec::new();
575 let mut errors = Vec::new();
576
577 for r in &load.records {
578 if !crate::filter::evaluate_expr(&self.filter, r, &vault.root, link_index.as_ref()) {
579 continue;
580 }
581 let filename = match r.path.file_name() {
582 Some(n) => n,
583 None => continue,
584 };
585 let dest = to_path.join(filename);
586 if dest.exists() {
587 errors.push(MutationError {
588 path: r.path.clone(),
589 message: format!(
590 "move conflict: {} already exists in {}",
591 filename.to_string_lossy(),
592 self.to_folder
593 ),
594 });
595 continue;
596 }
597 changes.push(PlannedChange {
598 path: r.path.clone(),
599 description: format!("move to {}", dest.display()),
600 });
601 }
602 Ok(MutationReport { changes, errors })
603 }
604
605 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
606 crate::lock::with_lock(&vault.root, || {
607 let to_path = vault.root.join(&self.to_folder);
608 let report = self.plan(vault)?;
609 if !report.changes.is_empty() {
610 std::fs::create_dir_all(&to_path).map_err(VaultdbError::Io)?;
611 }
612 let mut errors = report.errors;
613 let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
614 std::collections::BTreeSet::new();
615 for change in &report.changes {
616 let filename = match change.path.file_name() {
617 Some(n) => n,
618 None => continue,
619 };
620 let dest = to_path.join(filename);
621 if let Err(e) = std::fs::rename(&change.path, &dest) {
622 errors.push(MutationError {
623 path: change.path.clone(),
624 message: format!("rename failed: {}", e),
625 });
626 } else {
627 if let Some(parent) = change.path.parent() {
628 dirs_to_fsync.insert(parent.to_path_buf());
629 }
630 dirs_to_fsync.insert(to_path.clone());
631 }
632 }
633
634 if self.write_options.fsync {
635 for d in &dirs_to_fsync {
636 if let Err(e) = writer::fsync_dir(d) {
637 errors.push(MutationError {
638 path: d.clone(),
639 message: format!("fsync_dir failed: {}", e),
640 });
641 }
642 }
643 }
644
645 Ok(MutationReport {
646 changes: report.changes,
647 errors,
648 })
649 })
650 }
651}
652
653#[derive(Debug, Clone)]
662pub struct RenameBuilder {
663 folder: String,
664 from: String,
665 to: String,
666 write_options: writer::WriteOptions,
667}
668
669impl RenameBuilder {
670 pub fn new(folder: impl Into<String>, from: impl Into<String>, to: impl Into<String>) -> Self {
671 Self {
672 folder: folder.into(),
673 from: from.into(),
674 to: to.into(),
675 write_options: writer::WriteOptions::default(),
676 }
677 }
678
679 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
681 self.write_options = opts;
682 self
683 }
684
685 pub fn fsync(mut self, yes: bool) -> Self {
688 self.write_options.fsync = yes;
689 self
690 }
691
692 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
693 let folder_path = vault.resolve_folder(&self.folder)?;
694 let source = folder_path.join(format!("{}.md", self.from));
695 let dest = folder_path.join(format!("{}.md", self.to));
696
697 let mut changes = Vec::new();
698 let mut errors = Vec::new();
699
700 if !source.is_file() {
701 errors.push(MutationError {
702 path: source.clone(),
703 message: format!("source `{}` not found", self.from),
704 });
705 return Ok(MutationReport { changes, errors });
706 }
707 if dest.exists() {
708 errors.push(MutationError {
709 path: dest.clone(),
710 message: format!("target `{}.md` already exists", self.to),
711 });
712 return Ok(MutationReport { changes, errors });
713 }
714
715 changes.push(PlannedChange {
716 path: source.clone(),
717 description: format!("rename to {}", dest.display()),
718 });
719
720 let all = vault.load_records_with_content(&vault.root, true, false)?;
723 let graph = crate::links::LinkGraph::build_with_root(&all.records, Some(&vault.root));
724 for source_name in graph.incoming_links(&self.from) {
725 if let Some(record) = graph.record_by_name(source_name) {
726 changes.push(PlannedChange {
727 path: record.path.clone(),
728 description: format!("rewrite [[{}]] -> [[{}]]", self.from, self.to),
729 });
730 }
731 }
732
733 Ok(MutationReport { changes, errors })
734 }
735
736 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
737 crate::lock::with_lock(&vault.root, || {
738 crate::journal::replay_all(&vault.root)?;
745
746 let folder_path = vault.resolve_folder(&self.folder)?;
747 let source = folder_path.join(format!("{}.md", self.from));
748 let dest = folder_path.join(format!("{}.md", self.to));
749
750 let report = self.plan(vault)?;
751 if !report.errors.is_empty() {
753 return Ok(report);
754 }
755
756 let backlinks: Vec<PathBuf> = report
761 .changes
762 .iter()
763 .skip(1) .map(|c| c.path.clone())
765 .collect();
766 let journal = crate::journal::RenameJournal {
767 source: source.clone(),
768 dest: dest.clone(),
769 from_name: self.from.clone(),
770 to_name: self.to.clone(),
771 backlinks,
772 };
773 let journal_path = crate::journal::write(&vault.root, &journal)?;
774
775 if let Err(e) = std::fs::rename(&source, &dest) {
778 crate::journal::delete(&journal_path);
779 return Ok(MutationReport {
780 changes: report.changes,
781 errors: vec![MutationError {
782 path: source,
783 message: format!("rename failed: {}", e),
784 }],
785 });
786 }
787
788 let mut errors = Vec::new();
792 let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
793 std::collections::BTreeSet::new();
794 if let Some(parent) = source.parent() {
795 dirs_to_fsync.insert(parent.to_path_buf());
796 }
797 if let Some(parent) = dest.parent() {
798 dirs_to_fsync.insert(parent.to_path_buf());
799 }
800 for change in report.changes.iter().skip(1) {
801 let path = &change.path;
802 let content = match std::fs::read_to_string(path) {
803 Ok(c) => c,
804 Err(e) => {
805 errors.push(MutationError {
806 path: path.clone(),
807 message: format!("read failed: {}", e),
808 });
809 continue;
810 }
811 };
812 let new_content = rewrite_wikilinks(&content, &self.from, &self.to);
813 if new_content == content {
814 continue;
815 }
816 if let Err(e) = writer::atomic_write_with(path, &new_content, self.write_options) {
817 errors.push(MutationError {
818 path: path.clone(),
819 message: format!("write failed: {}", e),
820 });
821 }
822 }
823
824 if self.write_options.fsync {
829 for d in &dirs_to_fsync {
830 if let Err(e) = writer::fsync_dir(d) {
831 errors.push(MutationError {
832 path: d.clone(),
833 message: format!("fsync_dir failed: {}", e),
834 });
835 }
836 }
837 }
838
839 if errors.is_empty() {
843 crate::journal::delete(&journal_path);
844 }
845
846 Ok(MutationReport {
847 changes: report.changes,
848 errors,
849 })
850 })
851 }
852}
853
854#[derive(Debug, Clone)]
873pub struct CreateBuilder {
874 folder: String,
875 name: String,
876 template: Option<String>,
877 set_fields: Vec<(String, Value)>,
878 vault_schema: Option<crate::schema::VaultSchema>,
879 write_options: writer::WriteOptions,
880}
881
882impl CreateBuilder {
883 pub fn new(folder: impl Into<String>, name: impl Into<String>) -> Self {
884 Self {
885 folder: folder.into(),
886 name: name.into(),
887 template: None,
888 set_fields: Vec::new(),
889 vault_schema: None,
890 write_options: writer::WriteOptions::default(),
891 }
892 }
893
894 pub fn template(mut self, path: impl Into<String>) -> Self {
898 self.template = Some(path.into());
899 self
900 }
901
902 pub fn set(mut self, field: impl Into<String>, value: Value) -> Self {
905 self.set_fields.push((field.into(), value));
906 self
907 }
908
909 pub fn with_schema(self, schema: crate::schema::CollectionSchema) -> Self {
916 let mut vs = crate::schema::VaultSchema {
917 collections: std::collections::BTreeMap::new(),
918 };
919 vs.collections.insert("__single__".to_string(), schema);
920 self.with_vault_schema(vs)
921 }
922
923 pub fn with_vault_schema(mut self, schema: crate::schema::VaultSchema) -> Self {
930 self.vault_schema = Some(schema);
931 self
932 }
933
934 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
935 self.write_options = opts;
936 self
937 }
938
939 pub fn fsync(mut self, yes: bool) -> Self {
940 self.write_options.fsync = yes;
941 self
942 }
943
944 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
946 let (report, _) = self.compute(vault)?;
947 Ok(report)
948 }
949
950 pub fn plan_with_content(&self, vault: &Vault) -> Result<(MutationReport, Option<String>)> {
954 let (report, write) = self.compute(vault)?;
955 Ok((report, write.map(|w| w.modified_content)))
956 }
957
958 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
962 crate::lock::with_lock(&vault.root, || {
963 let (report, write) = self.compute(vault)?;
964 if !report.errors.is_empty() {
965 return Ok(report);
966 }
967 if let Some(w) = write {
968 if let Some(parent) = w.path.parent()
969 && !parent.exists()
970 {
971 std::fs::create_dir_all(parent).map_err(VaultdbError::Io)?;
972 }
973 writer::atomic_create_with(&w.path, &w.modified_content, self.write_options)
979 .map_err(VaultdbError::Io)?;
980 }
981 Ok(report)
982 })
983 }
984
985 fn compute(&self, vault: &Vault) -> Result<(MutationReport, Option<WriteResult>)> {
986 let folder_path = vault.root.join(&self.folder);
991 let filename = format!("{}.md", self.name);
992 let dest = folder_path.join(&filename);
993
994 let mut changes = Vec::new();
995 let mut errors = Vec::new();
996
997 if dest.exists() {
998 errors.push(MutationError {
999 path: dest.clone(),
1000 message: format!("file already exists: {}", dest.display()),
1001 });
1002 return Ok((MutationReport { changes, errors }, None));
1003 }
1004
1005 let (mut fields, body) = match &self.template {
1009 Some(tmpl) => {
1010 let tmpl_path = vault.root.join(tmpl);
1011 if !tmpl_path.is_file() {
1012 errors.push(MutationError {
1013 path: tmpl_path.clone(),
1014 message: format!("template not found: {}", tmpl_path.display()),
1015 });
1016 return Ok((MutationReport { changes, errors }, None));
1017 }
1018 let raw = std::fs::read_to_string(&tmpl_path).map_err(VaultdbError::Io)?;
1019 split_template(&raw)
1020 }
1021 None => (
1022 std::collections::BTreeMap::<String, Value>::new(),
1023 format!("\n# {}\n", self.name),
1024 ),
1025 };
1026
1027 for (k, v) in &self.set_fields {
1029 fields.insert(k.clone(), v.clone());
1030 }
1031
1032 if let Some(vault_schema) = &self.vault_schema {
1039 let projected = crate::record::Record {
1040 path: dest.clone(),
1041 fields: fields.clone(),
1042 raw_content: None,
1043 };
1044 let applicable =
1045 match vault_schema.applicable_collections(&self.folder, &projected, &vault.root) {
1046 Ok(cols) => cols,
1047 Err(e) => {
1048 errors.push(MutationError {
1049 path: dest.clone(),
1050 message: format!("evaluating schema applicability: {}", e),
1051 });
1052 return Ok((MutationReport { changes, errors }, None));
1053 }
1054 };
1055
1056 for col in &applicable {
1060 for (fname, fs) in &col.fields {
1061 if fields.contains_key(fname) {
1062 continue;
1063 }
1064 if let Some(default) = &fs.default {
1065 fields.insert(fname.clone(), default.clone());
1066 } else if let Some(expr) = &fs.default_expr {
1067 match crate::schema::resolve_default_expr(expr) {
1068 Ok(v) => {
1069 fields.insert(fname.clone(), v);
1070 }
1071 Err(e) => {
1072 errors.push(MutationError {
1073 path: dest.clone(),
1074 message: format!(
1075 "resolving default_expr for '{}': {}",
1076 fname, e
1077 ),
1078 });
1079 }
1080 }
1081 }
1082 }
1083 }
1084
1085 let mut seen = std::collections::BTreeSet::<(String, String)>::new();
1090 for col in &applicable {
1091 for v in crate::schema::validate_record(&filename, &fields, col) {
1092 if seen.insert((v.field.clone(), v.message.clone())) {
1093 errors.push(MutationError {
1094 path: dest.clone(),
1095 message: format!("schema: {} — {}", v.field, v.message),
1096 });
1097 }
1098 }
1099 }
1100 }
1101
1102 if !errors.is_empty() {
1103 return Ok((MutationReport { changes, errors }, None));
1104 }
1105
1106 let frontmatter_yaml = if fields.is_empty() {
1110 String::new()
1111 } else {
1112 serde_yaml::to_string(&fields)
1113 .map_err(|e| VaultdbError::SchemaError(format!("rendering frontmatter: {}", e)))?
1114 };
1115 let content = if frontmatter_yaml.is_empty() {
1116 format!("---\n---\n{}", body)
1117 } else {
1118 format!("---\n{}---\n{}", frontmatter_yaml, body)
1119 };
1120
1121 let field_count = fields.len();
1122 let field_summary: String = fields.keys().cloned().collect::<Vec<_>>().join(", ");
1123 let description = if field_count == 0 {
1124 "create (no frontmatter fields)".to_string()
1125 } else {
1126 format!("create with {} field(s): {}", field_count, field_summary)
1127 };
1128
1129 changes.push(PlannedChange {
1130 path: dest.clone(),
1131 description,
1132 });
1133
1134 let write = WriteResult {
1135 path: dest,
1136 original_content: String::new(),
1137 modified_content: content,
1138 changes: Vec::new(),
1139 };
1140
1141 Ok((MutationReport { changes, errors }, Some(write)))
1142 }
1143}
1144
1145fn split_template(raw: &str) -> (std::collections::BTreeMap<String, Value>, String) {
1149 use crate::frontmatter::{extract_frontmatter, parse_frontmatter};
1150 match extract_frontmatter(raw) {
1151 Some((yaml_text, body_start)) => {
1152 let fields = parse_frontmatter(yaml_text).unwrap_or_default();
1153 let body = raw[body_start..].to_string();
1154 (fields, body)
1155 }
1156 None => (std::collections::BTreeMap::new(), raw.to_string()),
1157 }
1158}
1159
1160pub(crate) fn rewrite_wikilinks(content: &str, from: &str, to: &str) -> String {
1163 content
1164 .replace(&format!("[[{}]]", from), &format!("[[{}]]", to))
1165 .replace(&format!("[[{}|", from), &format!("[[{}|", to))
1166 .replace(&format!("[[{}#", from), &format!("[[{}#", to))
1167}
1168
1169fn render_value_for_yaml(v: &Value) -> String {
1175 match v {
1176 Value::Null => "null".to_string(),
1177 Value::Bool(b) => b.to_string(),
1178 Value::Integer(i) => i.to_string(),
1179 Value::Float(f) => f.to_string(),
1180 Value::String(s) => writer::quote_value(s),
1181 Value::List(_) | Value::Map(_) => {
1182 let yaml = serde_yaml::to_string(v).unwrap_or_default();
1183 yaml.trim_end().to_string()
1184 }
1185 }
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190 use super::*;
1191 use crate::query::Predicate;
1192
1193 #[test]
1202 fn update_builder_writes_url_string_without_double_quoting() {
1203 use std::fs;
1204 use tempfile::TempDir;
1205 let dir = TempDir::new().unwrap();
1206 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1207 fs::create_dir(dir.path().join("notes")).unwrap();
1208 fs::write(
1209 dir.path().join("notes/product.md"),
1210 "---\nname: bialetti\nurl:\n---\n\n# Bialetti\n",
1211 )
1212 .unwrap();
1213 let vault = Vault::with_root(dir.path().to_path_buf());
1214
1215 let filter = Expr::Predicate(Predicate::Equals {
1216 field: "_name".into(),
1217 value: Value::String("product".into()),
1218 });
1219 let url = Value::String("https://www.amazon.com.tr/Bialetti/foo".into());
1220 UpdateBuilder::new("notes", filter)
1221 .set("url", url)
1222 .execute(&vault)
1223 .unwrap();
1224
1225 let written = fs::read_to_string(dir.path().join("notes/product.md")).unwrap();
1226 assert!(
1228 written.contains("url: 'https://www.amazon.com.tr/Bialetti/foo'"),
1229 "got:\n{}",
1230 written
1231 );
1232 assert!(!written.contains("url: \"'https://"), "got:\n{}", written);
1234
1235 let records = vault
1238 .load_records(&dir.path().join("notes"), false, false)
1239 .unwrap()
1240 .records;
1241 let product = &records[0];
1242 match product.fields.get("url") {
1243 Some(Value::String(s)) => {
1244 assert_eq!(s, "https://www.amazon.com.tr/Bialetti/foo");
1245 }
1246 other => panic!("expected Value::String(bare URL), got {:?}", other),
1247 }
1248 }
1249
1250 #[test]
1255 fn update_builder_preserves_string_that_looks_like_bool() {
1256 use std::fs;
1257 use tempfile::TempDir;
1258 let dir = TempDir::new().unwrap();
1259 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1260 fs::create_dir(dir.path().join("notes")).unwrap();
1261 fs::write(
1262 dir.path().join("notes/n.md"),
1263 "---\nname: n\nstatus:\n---\n",
1264 )
1265 .unwrap();
1266 let vault = Vault::with_root(dir.path().to_path_buf());
1267
1268 let filter = Expr::Predicate(Predicate::Equals {
1269 field: "_name".into(),
1270 value: Value::String("n".into()),
1271 });
1272 UpdateBuilder::new("notes", filter)
1273 .set("status", Value::String("true".into()))
1274 .execute(&vault)
1275 .unwrap();
1276
1277 let records = vault
1278 .load_records(&dir.path().join("notes"), false, false)
1279 .unwrap()
1280 .records;
1281 match records[0].fields.get("status") {
1282 Some(Value::String(s)) if s == "true" => {}
1283 other => panic!(
1284 "expected status to round-trip as Value::String(\"true\"), got {:?}",
1285 other
1286 ),
1287 }
1288 }
1289
1290 #[test]
1296 fn update_builder_writes_list_as_block_yaml() {
1297 use std::fs;
1298 use tempfile::TempDir;
1299 let dir = TempDir::new().unwrap();
1300 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1301 fs::create_dir(dir.path().join("notes")).unwrap();
1302 fs::write(
1303 dir.path().join("notes/cat.md"),
1304 "---\nanlam: kedi\n---\n\n# 猫\n",
1305 )
1306 .unwrap();
1307 let vault = Vault::with_root(dir.path().to_path_buf());
1308
1309 let filter = Expr::Predicate(Predicate::Equals {
1310 field: "_name".into(),
1311 value: Value::String("cat".into()),
1312 });
1313 let anlamlar = Value::List(vec![
1314 Value::String("kedi".into()),
1315 Value::String("pisi".into()),
1316 ]);
1317 let report = UpdateBuilder::new("notes", filter)
1318 .set("anlamlar", anlamlar)
1319 .execute(&vault)
1320 .unwrap();
1321 assert_eq!(report.errors.len(), 0);
1322 assert_eq!(report.changes.len(), 1);
1323
1324 let written = fs::read_to_string(dir.path().join("notes/cat.md")).unwrap();
1325 assert!(
1327 written.contains("anlamlar:\n- kedi\n- pisi"),
1328 "got:\n{}",
1329 written
1330 );
1331 assert!(!written.contains("anlamlar: '- kedi"), "got:\n{}", written);
1333
1334 let records = vault
1338 .load_records(&dir.path().join("notes"), false, false)
1339 .unwrap()
1340 .records;
1341 let cat = &records[0];
1342 match cat.fields.get("anlamlar") {
1343 Some(Value::List(items)) => {
1344 assert_eq!(items.len(), 2);
1345 assert!(matches!(&items[0], Value::String(s) if s == "kedi"));
1346 assert!(matches!(&items[1], Value::String(s) if s == "pisi"));
1347 }
1348 other => panic!("expected Value::List, got {:?}", other),
1349 }
1350 }
1351
1352 #[test]
1353 fn update_builder_skips_noop_set() {
1354 use std::fs;
1355 use tempfile::TempDir;
1356 let dir = TempDir::new().unwrap();
1357 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1358 fs::create_dir(dir.path().join("notes")).unwrap();
1359 fs::write(
1360 dir.path().join("notes/a.md"),
1361 "---\nstatus: active\n---\n\nBody\n",
1362 )
1363 .unwrap();
1364 let vault = Vault::with_root(dir.path().to_path_buf());
1365
1366 let filter = Expr::Predicate(Predicate::Equals {
1367 field: "_name".into(),
1368 value: Value::String("a".into()),
1369 });
1370 let report = UpdateBuilder::new("notes", filter)
1372 .set("status", Value::String("active".into()))
1373 .execute(&vault)
1374 .unwrap();
1375 assert_eq!(report.errors.len(), 0);
1376 assert_eq!(
1377 report.changes.len(),
1378 0,
1379 "a no-op set must not be reported as a change"
1380 );
1381 }
1382
1383 #[test]
1384 fn update_builder_recursive_reaches_subfolders() {
1385 use std::fs;
1386 use tempfile::TempDir;
1387 let dir = TempDir::new().unwrap();
1388 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1389 fs::create_dir(dir.path().join("notes")).unwrap();
1390 fs::create_dir(dir.path().join("notes/sub")).unwrap();
1391 fs::write(
1392 dir.path().join("notes/sub/deep.md"),
1393 "---\nstatus: old\n---\n\nBody\n",
1394 )
1395 .unwrap();
1396 let vault = Vault::with_root(dir.path().to_path_buf());
1397
1398 let filter = Expr::Predicate(Predicate::Equals {
1399 field: "status".into(),
1400 value: Value::String("old".into()),
1401 });
1402
1403 let shallow = UpdateBuilder::new("notes", filter.clone())
1405 .set("status", Value::String("new".into()))
1406 .execute(&vault)
1407 .unwrap();
1408 assert_eq!(
1409 shallow.changes.len(),
1410 0,
1411 "non-recursive update must skip subfolders"
1412 );
1413
1414 let deep = UpdateBuilder::new("notes", filter)
1416 .recursive(true)
1417 .set("status", Value::String("new".into()))
1418 .execute(&vault)
1419 .unwrap();
1420 assert_eq!(deep.errors.len(), 0);
1421 assert_eq!(
1422 deep.changes.len(),
1423 1,
1424 "recursive update must reach subfolders"
1425 );
1426 assert!(deep.changes[0].path.ends_with("deep.md"));
1427 let written = fs::read_to_string(dir.path().join("notes/sub/deep.md")).unwrap();
1428 assert!(written.contains("status: new"), "got:\n{}", written);
1429 }
1430
1431 #[test]
1432 fn update_builder_chains() {
1433 let filter = Expr::Predicate(Predicate::Equals {
1434 field: "status".into(),
1435 value: Value::String("active".into()),
1436 });
1437 let b = UpdateBuilder::new("notes", filter)
1438 .set("priority", Value::Integer(1))
1439 .unset("draft")
1440 .add_tag("urgent")
1441 .remove_tag("stale");
1442 assert_eq!(b.set_fields.len(), 1);
1443 assert_eq!(b.unset_fields.len(), 1);
1444 assert_eq!(b.add_tags.len(), 1);
1445 assert_eq!(b.remove_tags.len(), 1);
1446 }
1447
1448 #[test]
1449 fn delete_builder_trash_moves_to_dot_trash() {
1450 use std::fs;
1451 use tempfile::TempDir;
1452 let dir = TempDir::new().unwrap();
1453 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1454 fs::create_dir(dir.path().join("notes")).unwrap();
1455 fs::write(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
1456 let vault = Vault::with_root(dir.path().to_path_buf());
1457 let filter = Expr::Predicate(Predicate::Equals {
1458 field: "status".into(),
1459 value: Value::String("stale".into()),
1460 });
1461 let builder = DeleteBuilder::new("notes", filter);
1462 let report = builder.execute(&vault).unwrap();
1463 assert_eq!(report.changes.len(), 1);
1464 assert_eq!(report.errors.len(), 0);
1465 assert!(!dir.path().join("notes/a.md").exists());
1466 assert!(dir.path().join(".trash/a.md").exists());
1467 }
1468
1469 #[test]
1470 fn delete_builder_permanent_removes_file() {
1471 use std::fs;
1472 use tempfile::TempDir;
1473 let dir = TempDir::new().unwrap();
1474 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1475 fs::create_dir(dir.path().join("notes")).unwrap();
1476 fs::write(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
1477 let vault = Vault::with_root(dir.path().to_path_buf());
1478 let filter = Expr::Predicate(Predicate::Equals {
1479 field: "status".into(),
1480 value: Value::String("stale".into()),
1481 });
1482 let builder = DeleteBuilder::new("notes", filter).permanent(true);
1483 builder.execute(&vault).unwrap();
1484 assert!(!dir.path().join("notes/a.md").exists());
1485 assert!(!dir.path().join(".trash/a.md").exists());
1486 }
1487
1488 #[test]
1489 fn move_builder_relocates_files() {
1490 use std::fs;
1491 use tempfile::TempDir;
1492 let dir = TempDir::new().unwrap();
1493 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1494 fs::create_dir(dir.path().join("notes")).unwrap();
1495 fs::write(
1496 dir.path().join("notes/a.md"),
1497 "---\nstatus: archived\n---\n",
1498 )
1499 .unwrap();
1500 let vault = Vault::with_root(dir.path().to_path_buf());
1501 let filter = Expr::Predicate(Predicate::Equals {
1502 field: "status".into(),
1503 value: Value::String("archived".into()),
1504 });
1505 let builder = MoveBuilder::new("notes", "archive", filter);
1506 let report = builder.execute(&vault).unwrap();
1507 assert_eq!(report.changes.len(), 1);
1508 assert_eq!(report.errors.len(), 0);
1509 assert!(!dir.path().join("notes/a.md").exists());
1510 assert!(dir.path().join("archive/a.md").exists());
1511 }
1512
1513 #[test]
1514 fn rename_builder_renames_and_rewrites_links() {
1515 use std::fs;
1516 use tempfile::TempDir;
1517 let dir = TempDir::new().unwrap();
1518 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1519 fs::create_dir(dir.path().join("notes")).unwrap();
1520 fs::write(
1521 dir.path().join("notes/old.md"),
1522 "---\nstatus: x\n---\nBody\n",
1523 )
1524 .unwrap();
1525 fs::write(
1526 dir.path().join("notes/source.md"),
1527 "---\nstatus: y\n---\nLinks to [[old]] and [[old|alias]] and [[old#section]].\n",
1528 )
1529 .unwrap();
1530 let vault = Vault::with_root(dir.path().to_path_buf());
1531
1532 let builder = RenameBuilder::new("notes", "old", "new");
1533 let report = builder.execute(&vault).unwrap();
1534 assert_eq!(report.changes.len(), 2);
1536 assert_eq!(report.errors.len(), 0);
1537 assert!(!dir.path().join("notes/old.md").exists());
1538 assert!(dir.path().join("notes/new.md").exists());
1539 let source_after = fs::read_to_string(dir.path().join("notes/source.md")).unwrap();
1540 assert!(source_after.contains("[[new]]"));
1541 assert!(source_after.contains("[[new|alias]]"));
1542 assert!(source_after.contains("[[new#section]]"));
1543 assert!(!source_after.contains("[[old"));
1544 }
1545
1546 #[test]
1547 fn rename_builder_target_conflict_returns_error() {
1548 use std::fs;
1549 use tempfile::TempDir;
1550 let dir = TempDir::new().unwrap();
1551 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1552 fs::create_dir(dir.path().join("notes")).unwrap();
1553 fs::write(dir.path().join("notes/old.md"), "---\nstatus: x\n---\n").unwrap();
1554 fs::write(dir.path().join("notes/new.md"), "---\nstatus: y\n---\n").unwrap();
1555 let vault = Vault::with_root(dir.path().to_path_buf());
1556 let report = RenameBuilder::new("notes", "old", "new")
1557 .execute(&vault)
1558 .unwrap();
1559 assert_eq!(report.changes.len(), 0);
1560 assert_eq!(report.errors.len(), 1);
1561 assert!(dir.path().join("notes/old.md").exists());
1563 }
1564
1565 #[test]
1566 fn update_builder_plan_and_execute_against_a_temp_vault() {
1567 use std::fs;
1568 use tempfile::TempDir;
1569
1570 let dir = TempDir::new().unwrap();
1571 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1572 fs::create_dir(dir.path().join("notes")).unwrap();
1573 fs::write(
1574 dir.path().join("notes/a.md"),
1575 "---\nstatus: active\n---\nBody A\n",
1576 )
1577 .unwrap();
1578 fs::write(
1579 dir.path().join("notes/b.md"),
1580 "---\nstatus: pending\n---\nBody B\n",
1581 )
1582 .unwrap();
1583
1584 let vault = Vault::with_root(dir.path().to_path_buf());
1585
1586 let filter = Expr::Predicate(Predicate::Equals {
1587 field: "status".into(),
1588 value: Value::String("active".into()),
1589 });
1590 let builder = UpdateBuilder::new("notes", filter).set("priority", Value::Integer(1));
1591
1592 let plan_report = builder.plan(&vault).unwrap();
1594 assert_eq!(plan_report.changes.len(), 1);
1595 assert_eq!(plan_report.errors.len(), 0);
1596 assert!(plan_report.changes[0].path.ends_with("a.md"));
1597 let before = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
1598 assert!(!before.contains("priority"));
1599
1600 let exec_report = builder.execute(&vault).unwrap();
1602 assert_eq!(exec_report.changes.len(), 1);
1603 let after = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
1604 assert!(after.contains("priority"));
1605 let b_after = fs::read_to_string(dir.path().join("notes/b.md")).unwrap();
1607 assert!(!b_after.contains("priority"));
1608 }
1609
1610 #[test]
1611 fn write_options_fsync_propagates_through_update_builder() {
1612 use std::fs;
1620 use tempfile::TempDir;
1621
1622 let f1 = Expr::Predicate(Predicate::Equals {
1624 field: "x".into(),
1625 value: Value::Integer(1),
1626 });
1627 let b = UpdateBuilder::new("notes", f1).fsync(true);
1628 assert!(b.write_options.fsync);
1629
1630 let f2 = Expr::Predicate(Predicate::Equals {
1631 field: "x".into(),
1632 value: Value::Integer(1),
1633 });
1634 let b =
1635 UpdateBuilder::new("notes", f2).write_options(crate::writer::WriteOptions::durable());
1636 assert!(b.write_options.fsync);
1637
1638 let dir = TempDir::new().unwrap();
1640 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1641 fs::create_dir(dir.path().join("notes")).unwrap();
1642 fs::write(
1643 dir.path().join("notes/durable.md"),
1644 "---\nstatus: active\n---\nBody.\n",
1645 )
1646 .unwrap();
1647 let vault = Vault::with_root(dir.path().to_path_buf());
1648
1649 let f3 = Expr::Predicate(Predicate::Equals {
1650 field: "status".into(),
1651 value: Value::String("active".into()),
1652 });
1653 let report = UpdateBuilder::new("notes", f3)
1654 .set("priority", Value::Integer(99))
1655 .fsync(true)
1656 .execute(&vault)
1657 .unwrap();
1658 assert_eq!(report.changes.len(), 1);
1659 assert_eq!(report.errors.len(), 0);
1660
1661 let after = fs::read_to_string(dir.path().join("notes/durable.md")).unwrap();
1662 assert!(after.contains("priority: 99"));
1663 assert!(after.contains("status: active"));
1664 }
1665
1666 #[test]
1667 fn rename_clean_run_leaves_no_journal_behind() {
1668 use std::fs;
1671 use tempfile::TempDir;
1672 let dir = TempDir::new().unwrap();
1673 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1674 fs::create_dir(dir.path().join("notes")).unwrap();
1675 fs::write(
1676 dir.path().join("notes/old.md"),
1677 "---\nstatus: x\n---\nBody\n",
1678 )
1679 .unwrap();
1680 fs::write(
1681 dir.path().join("notes/source.md"),
1682 "---\nstatus: y\n---\nLinks to [[old]].\n",
1683 )
1684 .unwrap();
1685 let vault = Vault::with_root(dir.path().to_path_buf());
1686
1687 RenameBuilder::new("notes", "old", "new")
1688 .execute(&vault)
1689 .unwrap();
1690
1691 let pending = crate::journal::list_pending(dir.path()).unwrap();
1692 assert!(
1693 pending.is_empty(),
1694 "successful rename must not leave journals behind: {:?}",
1695 pending
1696 );
1697 }
1698
1699 #[test]
1700 fn rename_recovers_from_pre_existing_journal() {
1701 use std::fs;
1705 use tempfile::TempDir;
1706 let dir = TempDir::new().unwrap();
1707 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1708 fs::create_dir(dir.path().join("notes")).unwrap();
1709 let source = dir.path().join("notes/Stanford.md");
1710 let dest = dir.path().join("notes/Stanford University.md");
1711 let backlink = dir.path().join("notes/Application.md");
1712 fs::write(&source, "---\nkind: university\n---\nMain note.\n").unwrap();
1713 fs::write(
1714 &backlink,
1715 "---\nkind: application\n---\nApplied to [[Stanford]].\n",
1716 )
1717 .unwrap();
1718
1719 let journal = crate::journal::RenameJournal {
1721 source: source.clone(),
1722 dest: dest.clone(),
1723 from_name: "Stanford".into(),
1724 to_name: "Stanford University".into(),
1725 backlinks: vec![backlink.clone()],
1726 };
1727 crate::journal::write(dir.path(), &journal).unwrap();
1728
1729 let vault = Vault::with_root(dir.path().to_path_buf());
1730 let recovered = vault.recover().unwrap();
1731 assert_eq!(recovered, 1, "expected exactly one journal replayed");
1732
1733 assert!(!source.exists());
1735 assert!(dest.is_file());
1736 let backlink_content = fs::read_to_string(&backlink).unwrap();
1737 assert!(backlink_content.contains("[[Stanford University]]"));
1738 assert!(!backlink_content.contains("[[Stanford]]"));
1739 assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
1740 }
1741
1742 #[test]
1743 fn rename_replays_pending_journal_before_starting_new_rename() {
1744 use std::fs;
1748 use tempfile::TempDir;
1749 let dir = TempDir::new().unwrap();
1750 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1751 fs::create_dir(dir.path().join("notes")).unwrap();
1752
1753 let a = dir.path().join("notes/A.md");
1757 let b = dir.path().join("notes/B.md");
1758 let c = dir.path().join("notes/C.md");
1759 let d = dir.path().join("notes/D.md");
1760 fs::write(&a, "---\n---\nA body.\n").unwrap();
1761 fs::write(&c, "---\n---\nC body.\n").unwrap();
1762
1763 crate::journal::write(
1765 dir.path(),
1766 &crate::journal::RenameJournal {
1767 source: a.clone(),
1768 dest: b.clone(),
1769 from_name: "A".into(),
1770 to_name: "B".into(),
1771 backlinks: vec![],
1772 },
1773 )
1774 .unwrap();
1775
1776 let vault = Vault::with_root(dir.path().to_path_buf());
1778 RenameBuilder::new("notes", "C", "D")
1779 .execute(&vault)
1780 .unwrap();
1781
1782 assert!(!a.exists(), "A.md should be gone (replayed journal)");
1785 assert!(b.is_file(), "B.md should exist (replayed journal)");
1786 assert!(!c.exists(), "C.md should be gone (new rename)");
1787 assert!(d.is_file(), "D.md should exist (new rename)");
1788
1789 assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
1791 }
1792
1793 #[test]
1794 fn concurrent_updates_serialize_via_vault_lock() {
1795 use std::fs;
1803 use std::sync::Arc;
1804 use std::thread;
1805 use tempfile::TempDir;
1806
1807 let dir = TempDir::new().unwrap();
1808 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1809 fs::create_dir(dir.path().join("notes")).unwrap();
1810 fs::write(
1811 dir.path().join("notes/race.md"),
1812 "---\nstatus: active\n---\nBody.\n",
1813 )
1814 .unwrap();
1815
1816 let vault_path = Arc::new(dir.path().to_path_buf());
1817
1818 let p1 = Arc::clone(&vault_path);
1819 let t1 = thread::spawn(move || {
1820 let vault = Vault::with_root((*p1).clone());
1821 let filter = Expr::Predicate(Predicate::Equals {
1822 field: "status".into(),
1823 value: Value::String("active".into()),
1824 });
1825 UpdateBuilder::new("notes", filter)
1826 .set("touched_by_t1", Value::Integer(1))
1827 .execute(&vault)
1828 .expect("t1 execute")
1829 });
1830
1831 let p2 = Arc::clone(&vault_path);
1832 let t2 = thread::spawn(move || {
1833 let vault = Vault::with_root((*p2).clone());
1834 let filter = Expr::Predicate(Predicate::Equals {
1835 field: "status".into(),
1836 value: Value::String("active".into()),
1837 });
1838 UpdateBuilder::new("notes", filter)
1839 .set("touched_by_t2", Value::Integer(1))
1840 .execute(&vault)
1841 .expect("t2 execute")
1842 });
1843
1844 let r1 = t1.join().unwrap();
1845 let r2 = t2.join().unwrap();
1846 assert_eq!(r1.errors.len(), 0);
1847 assert_eq!(r2.errors.len(), 0);
1848
1849 let final_content = fs::read_to_string(dir.path().join("notes/race.md")).unwrap();
1853 assert!(
1854 final_content.contains("touched_by_t1"),
1855 "t1's edit lost; concurrent writer race: {}",
1856 final_content
1857 );
1858 assert!(
1859 final_content.contains("touched_by_t2"),
1860 "t2's edit lost; concurrent writer race: {}",
1861 final_content
1862 );
1863 }
1864
1865 #[test]
1866 fn atomic_write_does_not_leave_partial_files_on_failed_writes() {
1867 use std::fs;
1873 use tempfile::TempDir;
1874
1875 let dir = TempDir::new().unwrap();
1876 let target = dir.path().join("subdir/that-does-not-exist/x.md");
1877 let result = crate::writer::atomic_write(&target, "new content");
1880 assert!(
1881 result.is_err(),
1882 "expected atomic_write to fail when parent dir doesn't exist"
1883 );
1884
1885 let real_dir = dir.path().join("real");
1888 fs::create_dir(&real_dir).unwrap();
1889 let real_target = real_dir.join("x.md");
1890 fs::write(&real_target, "original").unwrap();
1891 crate::writer::atomic_write(&real_target, "replacement").unwrap();
1892 let after = fs::read_to_string(&real_target).unwrap();
1893 assert_eq!(after, "replacement");
1894
1895 let leftovers: Vec<_> = fs::read_dir(&real_dir)
1897 .unwrap()
1898 .flatten()
1899 .filter(|e| e.file_name().to_string_lossy().starts_with(".tmp"))
1900 .collect();
1901 assert!(
1902 leftovers.is_empty(),
1903 "expected no tempfile leftovers, found: {:?}",
1904 leftovers.iter().map(|e| e.path()).collect::<Vec<_>>()
1905 );
1906 }
1907
1908 use crate::schema::{CollectionSchema, FieldSchema};
1911
1912 fn movie_schema() -> CollectionSchema {
1913 let mut fields = std::collections::BTreeMap::new();
1914 fields.insert(
1915 "db-table".into(),
1916 FieldSchema {
1917 field_type: "string".into(),
1918 enum_values: vec![Value::String("movie".into())],
1919 min: None,
1920 max: None,
1921 default: Some(Value::String("movie".into())),
1922 default_expr: None,
1923 },
1924 );
1925 fields.insert(
1926 "status".into(),
1927 FieldSchema {
1928 field_type: "string".into(),
1929 enum_values: vec![
1930 Value::String("to-watch".into()),
1931 Value::String("watched".into()),
1932 ],
1933 min: None,
1934 max: None,
1935 default: Some(Value::String("to-watch".into())),
1936 default_expr: None,
1937 },
1938 );
1939 fields.insert(
1940 "director".into(),
1941 FieldSchema {
1942 field_type: "string".into(),
1943 enum_values: vec![],
1944 min: None,
1945 max: None,
1946 default: None,
1947 default_expr: None,
1948 },
1949 );
1950 fields.insert(
1951 "year".into(),
1952 FieldSchema {
1953 field_type: "integer".into(),
1954 enum_values: vec![],
1955 min: None,
1956 max: None,
1957 default: None,
1958 default_expr: None,
1959 },
1960 );
1961 CollectionSchema {
1962 description: None,
1963 folder: "Notes/movie".into(),
1964 filter: vec![],
1965 required: vec![
1966 "db-table".into(),
1967 "director".into(),
1968 "status".into(),
1969 "year".into(),
1970 ],
1971 fields,
1972 }
1973 }
1974
1975 fn vault_with_obsidian() -> tempfile::TempDir {
1976 let dir = tempfile::TempDir::new().unwrap();
1977 std::fs::create_dir(dir.path().join(".obsidian")).unwrap();
1978 dir
1979 }
1980
1981 #[test]
1982 fn create_without_schema_writes_minimal_file() {
1983 let dir = vault_with_obsidian();
1984 let vault = Vault::with_root(dir.path().to_path_buf());
1985 let report = CreateBuilder::new("Notes/movie", "Dune")
1986 .execute(&vault)
1987 .unwrap();
1988 assert_eq!(report.errors.len(), 0);
1989 assert_eq!(report.changes.len(), 1);
1990 let written = dir.path().join("Notes/movie/Dune.md");
1991 assert!(written.is_file());
1992 let content = std::fs::read_to_string(&written).unwrap();
1993 assert!(content.contains("---\n---"));
1995 assert!(content.contains("# Dune"));
1996 }
1997
1998 #[test]
1999 fn create_with_set_writes_typed_frontmatter() {
2000 let dir = vault_with_obsidian();
2001 let vault = Vault::with_root(dir.path().to_path_buf());
2002 CreateBuilder::new("Notes/movie", "Dune")
2003 .set("director", Value::String("Denis Villeneuve".into()))
2004 .set("year", Value::Integer(2021))
2005 .execute(&vault)
2006 .unwrap();
2007 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2008 assert!(content.contains("director: Denis Villeneuve"));
2009 assert!(content.contains("year: 2021"));
2011 }
2012
2013 #[test]
2014 fn create_fills_schema_defaults() {
2015 let dir = vault_with_obsidian();
2016 let vault = Vault::with_root(dir.path().to_path_buf());
2017 CreateBuilder::new("Notes/movie", "Dune")
2018 .with_schema(movie_schema())
2019 .set("director", Value::String("Denis Villeneuve".into()))
2020 .set("year", Value::Integer(2021))
2021 .execute(&vault)
2022 .unwrap();
2023 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2024 assert!(content.contains("db-table: movie"));
2026 assert!(content.contains("status: to-watch"));
2027 assert!(content.contains("director: Denis Villeneuve"));
2029 assert!(content.contains("year: 2021"));
2030 }
2031
2032 #[test]
2033 fn create_set_overrides_default() {
2034 let dir = vault_with_obsidian();
2035 let vault = Vault::with_root(dir.path().to_path_buf());
2036 CreateBuilder::new("Notes/movie", "Watched")
2037 .with_schema(movie_schema())
2038 .set("director", Value::String("X".into()))
2039 .set("year", Value::Integer(2020))
2040 .set("status", Value::String("watched".into()))
2041 .execute(&vault)
2042 .unwrap();
2043 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Watched.md")).unwrap();
2044 assert!(content.contains("status: watched"));
2045 assert!(!content.contains("status: to-watch"));
2046 }
2047
2048 #[test]
2049 fn create_rejects_missing_required_before_writing() {
2050 let dir = vault_with_obsidian();
2051 let vault = Vault::with_root(dir.path().to_path_buf());
2052 let report = CreateBuilder::new("Notes/movie", "Blank")
2054 .with_schema(movie_schema())
2055 .execute(&vault)
2056 .unwrap();
2057 assert!(!report.errors.is_empty());
2058 assert!(report.errors.iter().any(|e| e.message.contains("director")));
2059 assert!(report.errors.iter().any(|e| e.message.contains("year")));
2060 assert!(!dir.path().join("Notes/movie/Blank.md").exists());
2062 }
2063
2064 #[test]
2065 fn create_rejects_existing_file() {
2066 let dir = vault_with_obsidian();
2067 std::fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2068 std::fs::write(dir.path().join("Notes/movie/Dune.md"), "existing\n").unwrap();
2069 let vault = Vault::with_root(dir.path().to_path_buf());
2070 let report = CreateBuilder::new("Notes/movie", "Dune")
2071 .execute(&vault)
2072 .unwrap();
2073 assert_eq!(report.errors.len(), 1);
2074 assert!(report.errors[0].message.contains("already exists"));
2075 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2077 assert_eq!(content, "existing\n");
2078 }
2079
2080 #[test]
2081 fn create_resolves_default_expr_today() {
2082 let dir = vault_with_obsidian();
2083 let vault = Vault::with_root(dir.path().to_path_buf());
2084 let mut fields = std::collections::BTreeMap::new();
2085 fields.insert(
2086 "due".into(),
2087 FieldSchema {
2088 field_type: "date".into(),
2089 enum_values: vec![],
2090 min: None,
2091 max: None,
2092 default: None,
2093 default_expr: Some("today".into()),
2094 },
2095 );
2096 let schema = CollectionSchema {
2097 description: None,
2098 folder: "tasks".into(),
2099 filter: vec![],
2100 required: vec![],
2101 fields,
2102 };
2103 CreateBuilder::new("tasks", "t1")
2104 .with_schema(schema)
2105 .execute(&vault)
2106 .unwrap();
2107 let content = std::fs::read_to_string(dir.path().join("tasks/t1.md")).unwrap();
2108 let today = crate::record::today_string();
2110 assert!(
2111 content.contains(&format!("due: {}", today)),
2112 "expected due={} in: {}",
2113 today,
2114 content
2115 );
2116 }
2117
2118 #[test]
2119 fn create_plan_does_not_touch_disk() {
2120 let dir = vault_with_obsidian();
2121 let vault = Vault::with_root(dir.path().to_path_buf());
2122 let (report, content) = CreateBuilder::new("Notes/movie", "Dune")
2123 .with_schema(movie_schema())
2124 .set("director", Value::String("DV".into()))
2125 .set("year", Value::Integer(2021))
2126 .plan_with_content(&vault)
2127 .unwrap();
2128 assert_eq!(report.errors.len(), 0);
2129 assert_eq!(report.changes.len(), 1);
2130 assert!(!dir.path().join("Notes/movie/Dune.md").exists());
2131 let c = content.unwrap();
2132 assert!(c.contains("director: DV"));
2133 assert!(c.contains("db-table: movie")); }
2135
2136 #[test]
2137 fn create_from_template_preserves_body_and_merges_frontmatter() {
2138 let dir = vault_with_obsidian();
2139 std::fs::create_dir_all(dir.path().join("templates")).unwrap();
2140 std::fs::write(
2141 dir.path().join("templates/movie.md"),
2142 "---\nstatus: to-watch\naliases: []\n---\n\n# Title\n\nReview goes here.\n",
2143 )
2144 .unwrap();
2145 let vault = Vault::with_root(dir.path().to_path_buf());
2146 CreateBuilder::new("Notes/movie", "Dune")
2147 .template("templates/movie.md")
2148 .set("year", Value::Integer(2021))
2149 .execute(&vault)
2150 .unwrap();
2151 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2152 assert!(content.contains("status: to-watch"));
2154 assert!(content.contains("year: 2021"));
2156 assert!(content.contains("Review goes here"));
2158 }
2159
2160 use crate::schema::VaultSchema;
2169
2170 fn vault_schema_movies() -> VaultSchema {
2171 let mut vs = VaultSchema {
2174 collections: std::collections::BTreeMap::new(),
2175 };
2176 vs.collections.insert("movies".into(), movie_schema());
2177 vs
2178 }
2179
2180 fn vault_schema_catchall_and_movies() -> VaultSchema {
2181 let mut collections = std::collections::BTreeMap::new();
2185
2186 let mut catchall_fields = std::collections::BTreeMap::new();
2187 catchall_fields.insert(
2188 "db-table".into(),
2189 FieldSchema {
2190 field_type: "string".into(),
2191 enum_values: vec![Value::String("movie".into()), Value::String("book".into())],
2192 min: None,
2193 max: None,
2194 default: None,
2195 default_expr: None,
2196 },
2197 );
2198 collections.insert(
2199 "Notes".into(),
2200 CollectionSchema {
2201 description: None,
2202 folder: "Notes".into(),
2203 filter: vec![],
2204 required: vec!["db-table".into()],
2205 fields: catchall_fields,
2206 },
2207 );
2208 collections.insert("movies".into(), {
2209 let mut m = movie_schema();
2210 m.filter = vec!["db-table = movie".into()];
2211 m
2212 });
2213
2214 VaultSchema { collections }
2215 }
2216
2217 #[test]
2218 fn update_rejects_type_mismatch() {
2219 use std::fs;
2220 let dir = vault_with_obsidian();
2221 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2222 fs::write(
2223 dir.path().join("Notes/movie/Dune.md"),
2224 "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\nBody\n",
2225 )
2226 .unwrap();
2227 let vault = Vault::with_root(dir.path().to_path_buf());
2228
2229 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2230 field: "director".into(),
2231 value: Value::String("DV".into()),
2232 });
2233 let report = UpdateBuilder::new("Notes/movie", filter)
2234 .set("year", Value::String("nope".into()))
2235 .with_vault_schema(vault_schema_movies())
2236 .execute(&vault)
2237 .unwrap();
2238
2239 assert!(report.changes.is_empty(), "no write should be reported");
2240 assert!(
2241 report
2242 .errors
2243 .iter()
2244 .any(|e| e.message.contains("year") && e.message.contains("integer")),
2245 "expected year/integer type-mismatch error, got: {:?}",
2246 report.errors
2247 );
2248 let content = fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2250 assert!(content.contains("year: 2021"));
2251 assert!(!content.contains("year: nope"));
2252 }
2253
2254 #[test]
2255 fn update_rejects_enum_violation() {
2256 use std::fs;
2257 let dir = vault_with_obsidian();
2258 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2259 fs::write(
2260 dir.path().join("Notes/movie/Dune.md"),
2261 "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\n",
2262 )
2263 .unwrap();
2264 let vault = Vault::with_root(dir.path().to_path_buf());
2265
2266 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2267 field: "director".into(),
2268 value: Value::String("DV".into()),
2269 });
2270 let report = UpdateBuilder::new("Notes/movie", filter)
2271 .set("status", Value::String("watching".into()))
2272 .with_vault_schema(vault_schema_movies())
2273 .execute(&vault)
2274 .unwrap();
2275
2276 assert!(report.changes.is_empty());
2277 assert!(
2278 report
2279 .errors
2280 .iter()
2281 .any(|e| e.message.contains("status") && e.message.contains("watching")),
2282 "expected status enum violation, got: {:?}",
2283 report.errors
2284 );
2285 }
2286
2287 #[test]
2288 fn update_passes_when_unconstrained_field_changes() {
2289 use std::fs;
2293 let dir = vault_with_obsidian();
2294 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2295 fs::write(
2296 dir.path().join("Notes/movie/Dune.md"),
2297 "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\n",
2298 )
2299 .unwrap();
2300 let vault = Vault::with_root(dir.path().to_path_buf());
2301
2302 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2303 field: "director".into(),
2304 value: Value::String("DV".into()),
2305 });
2306 let report = UpdateBuilder::new("Notes/movie", filter)
2307 .set("notes-to-self", Value::String("rewatch".into()))
2308 .with_vault_schema(vault_schema_movies())
2309 .execute(&vault)
2310 .unwrap();
2311 assert!(
2312 report.errors.is_empty(),
2313 "no errors expected, got: {:?}",
2314 report.errors
2315 );
2316 assert_eq!(report.changes.len(), 1);
2317 let content = fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2318 assert!(content.contains("notes-to-self: rewatch"));
2319 }
2320
2321 #[test]
2322 fn update_surfaces_preexisting_violation() {
2323 use std::fs;
2328 let dir = vault_with_obsidian();
2329 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2330 fs::write(
2331 dir.path().join("Notes/movie/Old.md"),
2332 "---\ndb-table: movie\nstatus: to-watch\nyear: 2021\n---\n",
2334 )
2335 .unwrap();
2336 let vault = Vault::with_root(dir.path().to_path_buf());
2337
2338 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2339 field: "db-table".into(),
2340 value: Value::String("movie".into()),
2341 });
2342 let report = UpdateBuilder::new("Notes/movie", filter)
2343 .set("year", Value::Integer(2022))
2344 .with_vault_schema(vault_schema_movies())
2345 .execute(&vault)
2346 .unwrap();
2347 assert!(
2348 report.errors.iter().any(|e| e.message.contains("director")),
2349 "expected pre-existing required-field violation, got: {:?}",
2350 report.errors
2351 );
2352 let content = fs::read_to_string(dir.path().join("Notes/movie/Old.md")).unwrap();
2354 assert!(content.contains("year: 2021"));
2355 }
2356
2357 #[test]
2358 fn update_skips_one_blocks_one_in_batch() {
2359 use std::fs;
2366 let dir = vault_with_obsidian();
2367 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2368 fs::write(
2369 dir.path().join("Notes/movie/Good.md"),
2370 "---\ndb-table: movie\ndirector: A\nstatus: to-watch\nyear: 2021\n---\n",
2371 )
2372 .unwrap();
2373 fs::write(
2374 dir.path().join("Notes/movie/Bad.md"),
2375 "---\ndb-table: movie\nstatus: to-watch\nyear: 2021\n---\n",
2377 )
2378 .unwrap();
2379 let vault = Vault::with_root(dir.path().to_path_buf());
2380
2381 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2382 field: "db-table".into(),
2383 value: Value::String("movie".into()),
2384 });
2385 let report = UpdateBuilder::new("Notes/movie", filter)
2386 .set("year", Value::Integer(2022))
2387 .with_vault_schema(vault_schema_movies())
2388 .execute(&vault)
2389 .unwrap();
2390 assert_eq!(report.changes.len(), 1, "exactly one record should write");
2391 assert!(report.changes[0].path.ends_with("Good.md"));
2392 assert!(report.errors.iter().any(|e| e.path.ends_with("Bad.md")));
2393 assert!(
2395 fs::read_to_string(dir.path().join("Notes/movie/Good.md"))
2396 .unwrap()
2397 .contains("year: 2022")
2398 );
2399 assert!(
2400 fs::read_to_string(dir.path().join("Notes/movie/Bad.md"))
2401 .unwrap()
2402 .contains("year: 2021")
2403 );
2404 }
2405
2406 #[test]
2407 fn update_validates_against_catchall_and_subfolder() {
2408 use std::fs;
2414 let dir = vault_with_obsidian();
2415 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2416 fs::write(
2417 dir.path().join("Notes/movie/Dune.md"),
2418 "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\n",
2419 )
2420 .unwrap();
2421 let vault = Vault::with_root(dir.path().to_path_buf());
2422
2423 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2424 field: "director".into(),
2425 value: Value::String("DV".into()),
2426 });
2427 let report = UpdateBuilder::new("Notes/movie", filter)
2428 .unset("db-table")
2429 .with_vault_schema(vault_schema_catchall_and_movies())
2430 .execute(&vault)
2431 .unwrap();
2432
2433 assert!(report.changes.is_empty());
2438 assert!(
2439 report.errors.iter().any(|e| e.message.contains("db-table")),
2440 "expected db-table missing error from catch-all, got: {:?}",
2441 report.errors
2442 );
2443 }
2444
2445 #[test]
2446 fn create_rejects_type_mismatch() {
2447 let dir = vault_with_obsidian();
2448 let vault = Vault::with_root(dir.path().to_path_buf());
2449 let report = CreateBuilder::new("Notes/movie", "Dune")
2450 .with_vault_schema(vault_schema_movies())
2451 .set("director", Value::String("DV".into()))
2452 .set("year", Value::String("not-a-year".into()))
2453 .execute(&vault)
2454 .unwrap();
2455 assert!(
2456 report
2457 .errors
2458 .iter()
2459 .any(|e| e.message.contains("year") && e.message.contains("integer")),
2460 "expected year/integer type error, got: {:?}",
2461 report.errors
2462 );
2463 assert!(!dir.path().join("Notes/movie/Dune.md").exists());
2464 }
2465
2466 #[test]
2467 fn create_validates_against_multiple_applicable_collections() {
2468 let dir = vault_with_obsidian();
2473 let vault = Vault::with_root(dir.path().to_path_buf());
2474 let report = CreateBuilder::new("Notes/movie", "X")
2475 .with_vault_schema(vault_schema_catchall_and_movies())
2476 .set("db-table", Value::String("movie".into()))
2477 .execute(&vault)
2478 .unwrap();
2479 assert!(report.errors.iter().any(|e| e.message.contains("director")));
2480 assert!(report.errors.iter().any(|e| e.message.contains("year")));
2481 assert!(!dir.path().join("Notes/movie/X.md").exists());
2482 }
2483}