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)]
50pub struct UpdateBuilder {
51 filter: Expr,
52 folder: String,
53 set_fields: Vec<(String, Value)>,
54 unset_fields: Vec<String>,
55 add_tags: Vec<String>,
56 remove_tags: Vec<String>,
57 clear_body: bool,
62 set_body: Option<String>,
63 append_body: Vec<String>,
64 body_separator: String,
69 vault_schema: Option<crate::schema::VaultSchema>,
70 write_options: writer::WriteOptions,
71 recursive: bool,
72}
73
74impl UpdateBuilder {
75 pub fn new(folder: impl Into<String>, filter: Expr) -> Self {
76 Self {
77 filter,
78 folder: folder.into(),
79 set_fields: Vec::new(),
80 unset_fields: Vec::new(),
81 add_tags: Vec::new(),
82 remove_tags: Vec::new(),
83 clear_body: false,
84 set_body: None,
85 append_body: Vec::new(),
86 body_separator: "\n".to_string(),
87 vault_schema: None,
88 write_options: writer::WriteOptions::default(),
89 recursive: false,
90 }
91 }
92
93 pub fn recursive(mut self, yes: bool) -> Self {
98 self.recursive = yes;
99 self
100 }
101
102 pub fn set(mut self, field: impl Into<String>, value: Value) -> Self {
103 self.set_fields.push((field.into(), value));
104 self
105 }
106
107 pub fn unset(mut self, field: impl Into<String>) -> Self {
108 self.unset_fields.push(field.into());
109 self
110 }
111
112 pub fn add_tag(mut self, tag: impl Into<String>) -> Self {
113 self.add_tags.push(tag.into());
114 self
115 }
116
117 pub fn remove_tag(mut self, tag: impl Into<String>) -> Self {
118 self.remove_tags.push(tag.into());
119 self
120 }
121
122 pub fn set_body(mut self, text: impl Into<String>) -> Self {
126 self.set_body = Some(text.into());
127 self
128 }
129
130 pub fn append_body(mut self, text: impl Into<String>) -> Self {
136 self.append_body.push(text.into());
137 self
138 }
139
140 pub fn clear_body(mut self) -> Self {
144 self.clear_body = true;
145 self
146 }
147
148 pub fn body_separator(mut self, sep: impl Into<String>) -> Self {
154 self.body_separator = sep.into();
155 self
156 }
157
158 pub fn with_vault_schema(mut self, schema: crate::schema::VaultSchema) -> Self {
167 self.vault_schema = Some(schema);
168 self
169 }
170
171 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
174 self.write_options = opts;
175 self
176 }
177
178 pub fn fsync(mut self, yes: bool) -> Self {
181 self.write_options.fsync = yes;
182 self
183 }
184
185 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
187 let (report, _writes) = self.compute(vault)?;
188 Ok(report)
189 }
190
191 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
199 crate::lock::with_lock(&vault.root, || {
200 let (report, writes) = self.compute(vault)?;
201 for w in &writes {
202 writer::apply_with(w, self.write_options).map_err(VaultdbError::Io)?;
203 }
204 Ok(report)
205 })
206 }
207
208 fn compute(&self, vault: &Vault) -> Result<(MutationReport, Vec<WriteResult>)> {
209 let folder_path = vault.resolve_folder(&self.folder)?;
210 let load = vault.load_records_with_content(&folder_path, self.recursive, false)?;
211 let needs_links = crate::filter::expr_uses_links(&self.filter);
212 let link_index = if needs_links {
213 Some(crate::links::LinkGraph::build_with_root(
214 &load.records,
215 Some(&vault.root),
216 ))
217 } else {
218 None
219 };
220
221 let mut changes = Vec::new();
222 let mut errors = Vec::new();
223 let mut writes = Vec::new();
224
225 for record in &load.records {
226 if !crate::filter::evaluate_expr(&self.filter, record, &vault.root, link_index.as_ref())
227 {
228 continue;
229 }
230
231 if let Some(vault_schema) = &self.vault_schema {
238 let projected_fields = self.project_fields(&record.fields);
239 let projected_record = crate::record::Record {
240 path: record.path.clone(),
241 fields: projected_fields.clone(),
242 raw_content: record.raw_content.clone(),
243 };
244 let applicable = match vault_schema.applicable_collections(
245 &self.folder,
246 &projected_record,
247 &vault.root,
248 ) {
249 Ok(cols) => cols,
250 Err(e) => {
251 errors.push(MutationError {
252 path: record.path.clone(),
253 message: format!("evaluating schema applicability: {}", e),
254 });
255 continue;
256 }
257 };
258 let mut seen = std::collections::BTreeSet::<(String, String)>::new();
259 let mut had_violation = false;
260 let filename = record.virtual_name();
261 for col in &applicable {
262 for v in crate::schema::validate_record(&filename, &projected_fields, col) {
263 if seen.insert((v.field.clone(), v.message.clone())) {
264 errors.push(MutationError {
265 path: record.path.clone(),
266 message: format!("schema: {} — {}", v.field, v.message),
267 });
268 had_violation = true;
269 }
270 }
271 }
272 if had_violation {
273 continue;
274 }
275 }
276
277 let mut content = record
282 .raw_content
283 .as_ref()
284 .ok_or_else(|| {
285 VaultdbError::Internal(format!(
286 "record at {} has no raw_content; UpdateBuilder loaded without content",
287 record.path.display()
288 ))
289 })?
290 .clone();
291 let original_content = content.clone();
292 let mut wr_changes = Vec::new();
293 let mut description_parts: Vec<String> = Vec::new();
294
295 let result: Result<()> = (|| {
296 for (field, value) in &self.set_fields {
297 let (new_content, change) = match value {
307 Value::List(_) | Value::Map(_) => {
308 writer::set_field_block(&content, field, value)?
309 }
310 _ => {
311 let value_str = render_value_for_yaml(value);
312 writer::set_field_preformatted(&content, field, &value_str)?
313 }
314 };
315 description_parts.push(format!("{}", change));
316 wr_changes.push(change);
317 content = new_content;
318 }
319 for field in &self.unset_fields {
320 let (new_content, change) = writer::unset_field(&content, field)?;
321 description_parts.push(format!("{}", change));
322 wr_changes.push(change);
323 content = new_content;
324 }
325 for tag in &self.add_tags {
326 let (new_content, change) = writer::add_tag(&content, tag)?;
327 description_parts.push(format!("{}", change));
328 wr_changes.push(change);
329 content = new_content;
330 }
331 for tag in &self.remove_tags {
332 let (new_content, change) = writer::remove_tag(&content, tag)?;
333 description_parts.push(format!("{}", change));
334 wr_changes.push(change);
335 content = new_content;
336 }
337 if self.clear_body {
344 let (new_content, change) = writer::clear_body(&content)?;
345 description_parts.push(format!("{}", change));
346 wr_changes.push(change);
347 content = new_content;
348 }
349 if let Some(text) = &self.set_body {
350 let (new_content, change) = writer::set_body(&content, text)?;
351 description_parts.push(format!("{}", change));
352 wr_changes.push(change);
353 content = new_content;
354 }
355 for text in &self.append_body {
356 let (new_content, change) =
357 writer::append_body(&content, text, &self.body_separator)?;
358 description_parts.push(format!("{}", change));
359 wr_changes.push(change);
360 content = new_content;
361 }
362 Ok(())
363 })();
364
365 match result {
366 Ok(_) => {
367 if !wr_changes.is_empty() && content != original_content {
371 writes.push(WriteResult {
372 path: record.path.clone(),
373 original_content,
374 modified_content: content,
375 changes: wr_changes,
376 });
377 changes.push(PlannedChange {
378 path: record.path.clone(),
379 description: description_parts.join("; "),
380 });
381 }
382 }
383 Err(e) => errors.push(MutationError {
384 path: record.path.clone(),
385 message: e.to_string(),
386 }),
387 }
388 }
389
390 Ok((MutationReport { changes, errors }, writes))
391 }
392
393 fn project_fields(
401 &self,
402 original: &std::collections::BTreeMap<String, Value>,
403 ) -> std::collections::BTreeMap<String, Value> {
404 let mut fields = original.clone();
405 for (k, v) in &self.set_fields {
406 fields.insert(k.clone(), v.clone());
407 }
408 for k in &self.unset_fields {
409 fields.remove(k);
410 }
411 if !self.add_tags.is_empty() || !self.remove_tags.is_empty() {
412 let mut tags_list: Vec<Value> = match fields.get("tags") {
413 Some(Value::List(l)) => l.clone(),
414 _ => Vec::new(),
415 };
416 for t in &self.add_tags {
417 tags_list.push(Value::String(t.clone()));
418 }
419 for t in &self.remove_tags {
420 if let Some(idx) = tags_list
421 .iter()
422 .position(|v| matches!(v, Value::String(s) if s == t))
423 {
424 tags_list.remove(idx);
425 }
426 }
427 fields.insert("tags".to_string(), Value::List(tags_list));
428 }
429 fields
430 }
431}
432
433#[derive(Debug, Clone)]
439pub struct DeleteBuilder {
440 filter: Expr,
441 folder: String,
442 permanent: bool,
443 write_options: writer::WriteOptions,
444 recursive: bool,
445}
446
447impl DeleteBuilder {
448 pub fn new(folder: impl Into<String>, filter: Expr) -> Self {
449 Self {
450 filter,
451 folder: folder.into(),
452 permanent: false,
453 write_options: writer::WriteOptions::default(),
454 recursive: false,
455 }
456 }
457
458 pub fn recursive(mut self, yes: bool) -> Self {
461 self.recursive = yes;
462 self
463 }
464
465 pub fn permanent(mut self, yes: bool) -> Self {
466 self.permanent = yes;
467 self
468 }
469
470 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
472 self.write_options = opts;
473 self
474 }
475
476 pub fn fsync(mut self, yes: bool) -> Self {
479 self.write_options.fsync = yes;
480 self
481 }
482
483 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
484 let folder_path = vault.resolve_folder(&self.folder)?;
485 let load = vault.load_records(&folder_path, self.recursive, false)?;
486 let needs_links = crate::filter::expr_uses_links(&self.filter);
487 let link_index = if needs_links {
488 Some(crate::links::LinkGraph::build_with_root(
489 &load.records,
490 Some(&vault.root),
491 ))
492 } else {
493 None
494 };
495
496 let mut changes = Vec::new();
497 for r in &load.records {
498 if !crate::filter::evaluate_expr(&self.filter, r, &vault.root, link_index.as_ref()) {
499 continue;
500 }
501 changes.push(PlannedChange {
502 path: r.path.clone(),
503 description: if self.permanent {
504 "delete (permanent)".to_string()
505 } else {
506 "move to .trash/".to_string()
507 },
508 });
509 }
510 Ok(MutationReport {
511 changes,
512 errors: Vec::new(),
513 })
514 }
515
516 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
517 crate::lock::with_lock(&vault.root, || {
518 let report = self.plan(vault)?;
519 let mut errors = Vec::new();
520 let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
522 std::collections::BTreeSet::new();
523
524 if self.permanent {
525 for change in &report.changes {
526 if let Err(e) = std::fs::remove_file(&change.path) {
527 errors.push(MutationError {
528 path: change.path.clone(),
529 message: format!("remove failed: {}", e),
530 });
531 } else if let Some(parent) = change.path.parent() {
532 dirs_to_fsync.insert(parent.to_path_buf());
533 }
534 }
535 } else {
536 let trash_dir = vault.root.join(".trash");
537 if !report.changes.is_empty() {
538 std::fs::create_dir_all(&trash_dir).map_err(VaultdbError::Io)?;
539 }
540 for change in &report.changes {
541 let dest = unique_in_dir(&trash_dir, &change.path);
542 if let Err(e) = std::fs::rename(&change.path, &dest) {
543 errors.push(MutationError {
544 path: change.path.clone(),
545 message: format!("trash failed: {}", e),
546 });
547 } else {
548 if let Some(parent) = change.path.parent() {
549 dirs_to_fsync.insert(parent.to_path_buf());
550 }
551 dirs_to_fsync.insert(trash_dir.clone());
552 }
553 }
554 }
555
556 if self.write_options.fsync {
559 for d in &dirs_to_fsync {
560 if let Err(e) = writer::fsync_dir(d) {
561 errors.push(MutationError {
562 path: d.clone(),
563 message: format!("fsync_dir failed: {}", e),
564 });
565 }
566 }
567 }
568
569 Ok(MutationReport {
570 changes: report.changes,
571 errors,
572 })
573 })
574 }
575}
576
577fn unique_in_dir(dir: &std::path::Path, src: &std::path::Path) -> PathBuf {
578 let filename = src.file_name().and_then(|n| n.to_str()).unwrap_or("file");
579 let candidate = dir.join(filename);
580 if !candidate.exists() {
581 return candidate;
582 }
583 let stem = src.file_stem().and_then(|n| n.to_str()).unwrap_or("file");
584 let ext = src.extension().and_then(|n| n.to_str()).unwrap_or("md");
585 let mut i = 1;
586 loop {
587 let c = dir.join(format!("{}-{}.{}", stem, i, ext));
588 if !c.exists() {
589 return c;
590 }
591 i += 1;
592 }
593}
594
595#[derive(Debug, Clone)]
601pub struct MoveBuilder {
602 filter: Expr,
603 folder: String,
604 to_folder: String,
605 write_options: writer::WriteOptions,
606 recursive: bool,
607}
608
609impl MoveBuilder {
610 pub fn new(folder: impl Into<String>, to_folder: impl Into<String>, filter: Expr) -> Self {
611 Self {
612 filter,
613 folder: folder.into(),
614 to_folder: to_folder.into(),
615 write_options: writer::WriteOptions::default(),
616 recursive: false,
617 }
618 }
619
620 pub fn recursive(mut self, yes: bool) -> Self {
623 self.recursive = yes;
624 self
625 }
626
627 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
629 self.write_options = opts;
630 self
631 }
632
633 pub fn fsync(mut self, yes: bool) -> Self {
635 self.write_options.fsync = yes;
636 self
637 }
638
639 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
640 let folder_path = vault.resolve_folder(&self.folder)?;
641 let to_path = vault.root.join(&self.to_folder);
642 let load = vault.load_records(&folder_path, self.recursive, false)?;
643 let needs_links = crate::filter::expr_uses_links(&self.filter);
644 let link_index = if needs_links {
645 Some(crate::links::LinkGraph::build_with_root(
646 &load.records,
647 Some(&vault.root),
648 ))
649 } else {
650 None
651 };
652
653 let mut changes = Vec::new();
654 let mut errors = Vec::new();
655
656 for r in &load.records {
657 if !crate::filter::evaluate_expr(&self.filter, r, &vault.root, link_index.as_ref()) {
658 continue;
659 }
660 let filename = match r.path.file_name() {
661 Some(n) => n,
662 None => continue,
663 };
664 let dest = to_path.join(filename);
665 if dest.exists() {
666 errors.push(MutationError {
667 path: r.path.clone(),
668 message: format!(
669 "move conflict: {} already exists in {}",
670 filename.to_string_lossy(),
671 self.to_folder
672 ),
673 });
674 continue;
675 }
676 changes.push(PlannedChange {
677 path: r.path.clone(),
678 description: format!("move to {}", dest.display()),
679 });
680 }
681 Ok(MutationReport { changes, errors })
682 }
683
684 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
685 crate::lock::with_lock(&vault.root, || {
686 let to_path = vault.root.join(&self.to_folder);
687 let report = self.plan(vault)?;
688 if !report.changes.is_empty() {
689 std::fs::create_dir_all(&to_path).map_err(VaultdbError::Io)?;
690 }
691 let mut errors = report.errors;
692 let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
693 std::collections::BTreeSet::new();
694 for change in &report.changes {
695 let filename = match change.path.file_name() {
696 Some(n) => n,
697 None => continue,
698 };
699 let dest = to_path.join(filename);
700 if let Err(e) = std::fs::rename(&change.path, &dest) {
701 errors.push(MutationError {
702 path: change.path.clone(),
703 message: format!("rename failed: {}", e),
704 });
705 } else {
706 if let Some(parent) = change.path.parent() {
707 dirs_to_fsync.insert(parent.to_path_buf());
708 }
709 dirs_to_fsync.insert(to_path.clone());
710 }
711 }
712
713 if self.write_options.fsync {
714 for d in &dirs_to_fsync {
715 if let Err(e) = writer::fsync_dir(d) {
716 errors.push(MutationError {
717 path: d.clone(),
718 message: format!("fsync_dir failed: {}", e),
719 });
720 }
721 }
722 }
723
724 Ok(MutationReport {
725 changes: report.changes,
726 errors,
727 })
728 })
729 }
730}
731
732#[derive(Debug, Clone)]
741pub struct RenameBuilder {
742 folder: String,
743 from: String,
744 to: String,
745 write_options: writer::WriteOptions,
746}
747
748impl RenameBuilder {
749 pub fn new(folder: impl Into<String>, from: impl Into<String>, to: impl Into<String>) -> Self {
750 Self {
751 folder: folder.into(),
752 from: from.into(),
753 to: to.into(),
754 write_options: writer::WriteOptions::default(),
755 }
756 }
757
758 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
760 self.write_options = opts;
761 self
762 }
763
764 pub fn fsync(mut self, yes: bool) -> Self {
767 self.write_options.fsync = yes;
768 self
769 }
770
771 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
772 let folder_path = vault.resolve_folder(&self.folder)?;
773 let source = folder_path.join(format!("{}.md", self.from));
774 let dest = folder_path.join(format!("{}.md", self.to));
775
776 let mut changes = Vec::new();
777 let mut errors = Vec::new();
778
779 if !source.is_file() {
780 errors.push(MutationError {
781 path: source.clone(),
782 message: format!("source `{}` not found", self.from),
783 });
784 return Ok(MutationReport { changes, errors });
785 }
786 if dest.exists() {
787 errors.push(MutationError {
788 path: dest.clone(),
789 message: format!("target `{}.md` already exists", self.to),
790 });
791 return Ok(MutationReport { changes, errors });
792 }
793
794 changes.push(PlannedChange {
795 path: source.clone(),
796 description: format!("rename to {}", dest.display()),
797 });
798
799 let all = vault.load_records_with_content(&vault.root, true, false)?;
802 let graph = crate::links::LinkGraph::build_with_root(&all.records, Some(&vault.root));
803 for source_name in graph.incoming_links(&self.from) {
804 if let Some(record) = graph.record_by_name(source_name) {
805 changes.push(PlannedChange {
806 path: record.path.clone(),
807 description: format!("rewrite [[{}]] -> [[{}]]", self.from, self.to),
808 });
809 }
810 }
811
812 Ok(MutationReport { changes, errors })
813 }
814
815 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
816 crate::lock::with_lock(&vault.root, || {
817 crate::journal::replay_all(&vault.root)?;
824
825 let folder_path = vault.resolve_folder(&self.folder)?;
826 let source = folder_path.join(format!("{}.md", self.from));
827 let dest = folder_path.join(format!("{}.md", self.to));
828
829 let report = self.plan(vault)?;
830 if !report.errors.is_empty() {
832 return Ok(report);
833 }
834
835 let backlinks: Vec<PathBuf> = report
840 .changes
841 .iter()
842 .skip(1) .map(|c| c.path.clone())
844 .collect();
845 let journal = crate::journal::RenameJournal {
846 source: source.clone(),
847 dest: dest.clone(),
848 from_name: self.from.clone(),
849 to_name: self.to.clone(),
850 backlinks,
851 };
852 let journal_path = crate::journal::write(&vault.root, &journal)?;
853
854 if let Err(e) = std::fs::rename(&source, &dest) {
857 crate::journal::delete(&journal_path);
858 return Ok(MutationReport {
859 changes: report.changes,
860 errors: vec![MutationError {
861 path: source,
862 message: format!("rename failed: {}", e),
863 }],
864 });
865 }
866
867 let mut errors = Vec::new();
871 let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
872 std::collections::BTreeSet::new();
873 if let Some(parent) = source.parent() {
874 dirs_to_fsync.insert(parent.to_path_buf());
875 }
876 if let Some(parent) = dest.parent() {
877 dirs_to_fsync.insert(parent.to_path_buf());
878 }
879 for change in report.changes.iter().skip(1) {
880 let path = &change.path;
881 let content = match std::fs::read_to_string(path) {
882 Ok(c) => c,
883 Err(e) => {
884 errors.push(MutationError {
885 path: path.clone(),
886 message: format!("read failed: {}", e),
887 });
888 continue;
889 }
890 };
891 let new_content = rewrite_wikilinks(&content, &self.from, &self.to);
892 if new_content == content {
893 continue;
894 }
895 if let Err(e) = writer::atomic_write_with(path, &new_content, self.write_options) {
896 errors.push(MutationError {
897 path: path.clone(),
898 message: format!("write failed: {}", e),
899 });
900 }
901 }
902
903 if self.write_options.fsync {
908 for d in &dirs_to_fsync {
909 if let Err(e) = writer::fsync_dir(d) {
910 errors.push(MutationError {
911 path: d.clone(),
912 message: format!("fsync_dir failed: {}", e),
913 });
914 }
915 }
916 }
917
918 if errors.is_empty() {
922 crate::journal::delete(&journal_path);
923 }
924
925 Ok(MutationReport {
926 changes: report.changes,
927 errors,
928 })
929 })
930 }
931}
932
933#[derive(Debug, Clone)]
952pub struct CreateBuilder {
953 folder: String,
954 name: String,
955 template: Option<String>,
956 set_fields: Vec<(String, Value)>,
957 body: Option<String>,
961 vault_schema: Option<crate::schema::VaultSchema>,
962 write_options: writer::WriteOptions,
963}
964
965impl CreateBuilder {
966 pub fn new(folder: impl Into<String>, name: impl Into<String>) -> Self {
967 Self {
968 folder: folder.into(),
969 name: name.into(),
970 template: None,
971 set_fields: Vec::new(),
972 body: None,
973 vault_schema: None,
974 write_options: writer::WriteOptions::default(),
975 }
976 }
977
978 pub fn body(mut self, text: impl Into<String>) -> Self {
981 self.body = Some(text.into());
982 self
983 }
984
985 pub fn template(mut self, path: impl Into<String>) -> Self {
989 self.template = Some(path.into());
990 self
991 }
992
993 pub fn set(mut self, field: impl Into<String>, value: Value) -> Self {
996 self.set_fields.push((field.into(), value));
997 self
998 }
999
1000 pub fn with_schema(self, schema: crate::schema::CollectionSchema) -> Self {
1007 let mut vs = crate::schema::VaultSchema {
1008 collections: std::collections::BTreeMap::new(),
1009 };
1010 vs.collections.insert("__single__".to_string(), schema);
1011 self.with_vault_schema(vs)
1012 }
1013
1014 pub fn with_vault_schema(mut self, schema: crate::schema::VaultSchema) -> Self {
1021 self.vault_schema = Some(schema);
1022 self
1023 }
1024
1025 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
1026 self.write_options = opts;
1027 self
1028 }
1029
1030 pub fn fsync(mut self, yes: bool) -> Self {
1031 self.write_options.fsync = yes;
1032 self
1033 }
1034
1035 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
1037 let (report, _) = self.compute(vault)?;
1038 Ok(report)
1039 }
1040
1041 pub fn plan_with_content(&self, vault: &Vault) -> Result<(MutationReport, Option<String>)> {
1045 let (report, write) = self.compute(vault)?;
1046 Ok((report, write.map(|w| w.modified_content)))
1047 }
1048
1049 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
1053 crate::lock::with_lock(&vault.root, || {
1054 let (report, write) = self.compute(vault)?;
1055 if !report.errors.is_empty() {
1056 return Ok(report);
1057 }
1058 if let Some(w) = write {
1059 if let Some(parent) = w.path.parent()
1060 && !parent.exists()
1061 {
1062 std::fs::create_dir_all(parent).map_err(VaultdbError::Io)?;
1063 }
1064 writer::atomic_create_with(&w.path, &w.modified_content, self.write_options)
1070 .map_err(VaultdbError::Io)?;
1071 }
1072 Ok(report)
1073 })
1074 }
1075
1076 fn compute(&self, vault: &Vault) -> Result<(MutationReport, Option<WriteResult>)> {
1077 let folder_path = vault.root.join(&self.folder);
1082 let filename = format!("{}.md", self.name);
1083 let dest = folder_path.join(&filename);
1084
1085 let mut changes = Vec::new();
1086 let mut errors = Vec::new();
1087
1088 if dest.exists() {
1089 errors.push(MutationError {
1090 path: dest.clone(),
1091 message: format!("file already exists: {}", dest.display()),
1092 });
1093 return Ok((MutationReport { changes, errors }, None));
1094 }
1095
1096 let (mut fields, mut body) = match &self.template {
1103 Some(tmpl) => {
1104 let tmpl_path = vault.root.join(tmpl);
1105 if !tmpl_path.is_file() {
1106 errors.push(MutationError {
1107 path: tmpl_path.clone(),
1108 message: format!("template not found: {}", tmpl_path.display()),
1109 });
1110 return Ok((MutationReport { changes, errors }, None));
1111 }
1112 let raw = std::fs::read_to_string(&tmpl_path).map_err(VaultdbError::Io)?;
1113 split_template(&raw)
1114 }
1115 None => (
1116 std::collections::BTreeMap::<String, Value>::new(),
1117 format!("\n# {}\n", self.name),
1118 ),
1119 };
1120 if let Some(explicit) = &self.body {
1121 body = explicit.clone();
1122 }
1123
1124 for (k, v) in &self.set_fields {
1126 fields.insert(k.clone(), v.clone());
1127 }
1128
1129 if let Some(vault_schema) = &self.vault_schema {
1136 let projected = crate::record::Record {
1137 path: dest.clone(),
1138 fields: fields.clone(),
1139 raw_content: None,
1140 };
1141 let applicable =
1142 match vault_schema.applicable_collections(&self.folder, &projected, &vault.root) {
1143 Ok(cols) => cols,
1144 Err(e) => {
1145 errors.push(MutationError {
1146 path: dest.clone(),
1147 message: format!("evaluating schema applicability: {}", e),
1148 });
1149 return Ok((MutationReport { changes, errors }, None));
1150 }
1151 };
1152
1153 for col in &applicable {
1157 for (fname, fs) in &col.fields {
1158 if fields.contains_key(fname) {
1159 continue;
1160 }
1161 if let Some(default) = &fs.default {
1162 fields.insert(fname.clone(), default.clone());
1163 } else if let Some(expr) = &fs.default_expr {
1164 match crate::schema::resolve_default_expr(expr) {
1165 Ok(v) => {
1166 fields.insert(fname.clone(), v);
1167 }
1168 Err(e) => {
1169 errors.push(MutationError {
1170 path: dest.clone(),
1171 message: format!(
1172 "resolving default_expr for '{}': {}",
1173 fname, e
1174 ),
1175 });
1176 }
1177 }
1178 }
1179 }
1180 }
1181
1182 let mut seen = std::collections::BTreeSet::<(String, String)>::new();
1187 for col in &applicable {
1188 for v in crate::schema::validate_record(&filename, &fields, col) {
1189 if seen.insert((v.field.clone(), v.message.clone())) {
1190 errors.push(MutationError {
1191 path: dest.clone(),
1192 message: format!("schema: {} — {}", v.field, v.message),
1193 });
1194 }
1195 }
1196 }
1197 }
1198
1199 if !errors.is_empty() {
1200 return Ok((MutationReport { changes, errors }, None));
1201 }
1202
1203 let frontmatter_yaml = if fields.is_empty() {
1207 String::new()
1208 } else {
1209 serde_yaml::to_string(&fields)
1210 .map_err(|e| VaultdbError::SchemaError(format!("rendering frontmatter: {}", e)))?
1211 };
1212 let content = if frontmatter_yaml.is_empty() {
1213 format!("---\n---\n{}", body)
1214 } else {
1215 format!("---\n{}---\n{}", frontmatter_yaml, body)
1216 };
1217
1218 let field_count = fields.len();
1219 let field_summary: String = fields.keys().cloned().collect::<Vec<_>>().join(", ");
1220 let description = if field_count == 0 {
1221 "create (no frontmatter fields)".to_string()
1222 } else {
1223 format!("create with {} field(s): {}", field_count, field_summary)
1224 };
1225
1226 changes.push(PlannedChange {
1227 path: dest.clone(),
1228 description,
1229 });
1230
1231 let write = WriteResult {
1232 path: dest,
1233 original_content: String::new(),
1234 modified_content: content,
1235 changes: Vec::new(),
1236 };
1237
1238 Ok((MutationReport { changes, errors }, Some(write)))
1239 }
1240}
1241
1242fn split_template(raw: &str) -> (std::collections::BTreeMap<String, Value>, String) {
1246 use crate::frontmatter::{extract_frontmatter, parse_frontmatter};
1247 match extract_frontmatter(raw) {
1248 Some((yaml_text, body_start)) => {
1249 let fields = parse_frontmatter(yaml_text).unwrap_or_default();
1250 let body = raw[body_start..].to_string();
1251 (fields, body)
1252 }
1253 None => (std::collections::BTreeMap::new(), raw.to_string()),
1254 }
1255}
1256
1257pub(crate) fn rewrite_wikilinks(content: &str, from: &str, to: &str) -> String {
1260 content
1261 .replace(&format!("[[{}]]", from), &format!("[[{}]]", to))
1262 .replace(&format!("[[{}|", from), &format!("[[{}|", to))
1263 .replace(&format!("[[{}#", from), &format!("[[{}#", to))
1264}
1265
1266fn render_value_for_yaml(v: &Value) -> String {
1272 match v {
1273 Value::Null => "null".to_string(),
1274 Value::Bool(b) => b.to_string(),
1275 Value::Integer(i) => i.to_string(),
1276 Value::Float(f) => f.to_string(),
1277 Value::String(s) => writer::quote_value(s),
1278 Value::List(_) | Value::Map(_) => {
1279 let yaml = serde_yaml::to_string(v).unwrap_or_default();
1280 yaml.trim_end().to_string()
1281 }
1282 }
1283}
1284
1285#[cfg(test)]
1286mod tests {
1287 use super::*;
1288 use crate::query::Predicate;
1289
1290 #[test]
1299 fn update_builder_writes_url_string_without_double_quoting() {
1300 use std::fs;
1301 use tempfile::TempDir;
1302 let dir = TempDir::new().unwrap();
1303 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1304 fs::create_dir(dir.path().join("notes")).unwrap();
1305 fs::write(
1306 dir.path().join("notes/product.md"),
1307 "---\nname: bialetti\nurl:\n---\n\n# Bialetti\n",
1308 )
1309 .unwrap();
1310 let vault = Vault::with_root(dir.path().to_path_buf());
1311
1312 let filter = Expr::Predicate(Predicate::Equals {
1313 field: "_name".into(),
1314 value: Value::String("product".into()),
1315 });
1316 let url = Value::String("https://www.amazon.com.tr/Bialetti/foo".into());
1317 UpdateBuilder::new("notes", filter)
1318 .set("url", url)
1319 .execute(&vault)
1320 .unwrap();
1321
1322 let written = fs::read_to_string(dir.path().join("notes/product.md")).unwrap();
1323 assert!(
1325 written.contains("url: 'https://www.amazon.com.tr/Bialetti/foo'"),
1326 "got:\n{}",
1327 written
1328 );
1329 assert!(!written.contains("url: \"'https://"), "got:\n{}", written);
1331
1332 let records = vault
1335 .load_records(&dir.path().join("notes"), false, false)
1336 .unwrap()
1337 .records;
1338 let product = &records[0];
1339 match product.fields.get("url") {
1340 Some(Value::String(s)) => {
1341 assert_eq!(s, "https://www.amazon.com.tr/Bialetti/foo");
1342 }
1343 other => panic!("expected Value::String(bare URL), got {:?}", other),
1344 }
1345 }
1346
1347 #[test]
1352 fn update_builder_preserves_string_that_looks_like_bool() {
1353 use std::fs;
1354 use tempfile::TempDir;
1355 let dir = TempDir::new().unwrap();
1356 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1357 fs::create_dir(dir.path().join("notes")).unwrap();
1358 fs::write(
1359 dir.path().join("notes/n.md"),
1360 "---\nname: n\nstatus:\n---\n",
1361 )
1362 .unwrap();
1363 let vault = Vault::with_root(dir.path().to_path_buf());
1364
1365 let filter = Expr::Predicate(Predicate::Equals {
1366 field: "_name".into(),
1367 value: Value::String("n".into()),
1368 });
1369 UpdateBuilder::new("notes", filter)
1370 .set("status", Value::String("true".into()))
1371 .execute(&vault)
1372 .unwrap();
1373
1374 let records = vault
1375 .load_records(&dir.path().join("notes"), false, false)
1376 .unwrap()
1377 .records;
1378 match records[0].fields.get("status") {
1379 Some(Value::String(s)) if s == "true" => {}
1380 other => panic!(
1381 "expected status to round-trip as Value::String(\"true\"), got {:?}",
1382 other
1383 ),
1384 }
1385 }
1386
1387 #[test]
1393 fn update_builder_writes_list_as_block_yaml() {
1394 use std::fs;
1395 use tempfile::TempDir;
1396 let dir = TempDir::new().unwrap();
1397 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1398 fs::create_dir(dir.path().join("notes")).unwrap();
1399 fs::write(
1400 dir.path().join("notes/cat.md"),
1401 "---\nanlam: kedi\n---\n\n# 猫\n",
1402 )
1403 .unwrap();
1404 let vault = Vault::with_root(dir.path().to_path_buf());
1405
1406 let filter = Expr::Predicate(Predicate::Equals {
1407 field: "_name".into(),
1408 value: Value::String("cat".into()),
1409 });
1410 let anlamlar = Value::List(vec![
1411 Value::String("kedi".into()),
1412 Value::String("pisi".into()),
1413 ]);
1414 let report = UpdateBuilder::new("notes", filter)
1415 .set("anlamlar", anlamlar)
1416 .execute(&vault)
1417 .unwrap();
1418 assert_eq!(report.errors.len(), 0);
1419 assert_eq!(report.changes.len(), 1);
1420
1421 let written = fs::read_to_string(dir.path().join("notes/cat.md")).unwrap();
1422 assert!(
1424 written.contains("anlamlar:\n- kedi\n- pisi"),
1425 "got:\n{}",
1426 written
1427 );
1428 assert!(!written.contains("anlamlar: '- kedi"), "got:\n{}", written);
1430
1431 let records = vault
1435 .load_records(&dir.path().join("notes"), false, false)
1436 .unwrap()
1437 .records;
1438 let cat = &records[0];
1439 match cat.fields.get("anlamlar") {
1440 Some(Value::List(items)) => {
1441 assert_eq!(items.len(), 2);
1442 assert!(matches!(&items[0], Value::String(s) if s == "kedi"));
1443 assert!(matches!(&items[1], Value::String(s) if s == "pisi"));
1444 }
1445 other => panic!("expected Value::List, got {:?}", other),
1446 }
1447 }
1448
1449 #[test]
1450 fn update_builder_skips_noop_set() {
1451 use std::fs;
1452 use tempfile::TempDir;
1453 let dir = TempDir::new().unwrap();
1454 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1455 fs::create_dir(dir.path().join("notes")).unwrap();
1456 fs::write(
1457 dir.path().join("notes/a.md"),
1458 "---\nstatus: active\n---\n\nBody\n",
1459 )
1460 .unwrap();
1461 let vault = Vault::with_root(dir.path().to_path_buf());
1462
1463 let filter = Expr::Predicate(Predicate::Equals {
1464 field: "_name".into(),
1465 value: Value::String("a".into()),
1466 });
1467 let report = UpdateBuilder::new("notes", filter)
1469 .set("status", Value::String("active".into()))
1470 .execute(&vault)
1471 .unwrap();
1472 assert_eq!(report.errors.len(), 0);
1473 assert_eq!(
1474 report.changes.len(),
1475 0,
1476 "a no-op set must not be reported as a change"
1477 );
1478 }
1479
1480 #[test]
1481 fn update_builder_recursive_reaches_subfolders() {
1482 use std::fs;
1483 use tempfile::TempDir;
1484 let dir = TempDir::new().unwrap();
1485 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1486 fs::create_dir(dir.path().join("notes")).unwrap();
1487 fs::create_dir(dir.path().join("notes/sub")).unwrap();
1488 fs::write(
1489 dir.path().join("notes/sub/deep.md"),
1490 "---\nstatus: old\n---\n\nBody\n",
1491 )
1492 .unwrap();
1493 let vault = Vault::with_root(dir.path().to_path_buf());
1494
1495 let filter = Expr::Predicate(Predicate::Equals {
1496 field: "status".into(),
1497 value: Value::String("old".into()),
1498 });
1499
1500 let shallow = UpdateBuilder::new("notes", filter.clone())
1502 .set("status", Value::String("new".into()))
1503 .execute(&vault)
1504 .unwrap();
1505 assert_eq!(
1506 shallow.changes.len(),
1507 0,
1508 "non-recursive update must skip subfolders"
1509 );
1510
1511 let deep = UpdateBuilder::new("notes", filter)
1513 .recursive(true)
1514 .set("status", Value::String("new".into()))
1515 .execute(&vault)
1516 .unwrap();
1517 assert_eq!(deep.errors.len(), 0);
1518 assert_eq!(
1519 deep.changes.len(),
1520 1,
1521 "recursive update must reach subfolders"
1522 );
1523 assert!(deep.changes[0].path.ends_with("deep.md"));
1524 let written = fs::read_to_string(dir.path().join("notes/sub/deep.md")).unwrap();
1525 assert!(written.contains("status: new"), "got:\n{}", written);
1526 }
1527
1528 #[test]
1531 fn update_builder_set_body_replaces_existing() {
1532 use std::fs;
1533 use tempfile::TempDir;
1534 let dir = TempDir::new().unwrap();
1535 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1536 fs::create_dir(dir.path().join("notes")).unwrap();
1537 fs::write(
1538 dir.path().join("notes/log.md"),
1539 "---\nstatus: active\n---\nOld body.\n",
1540 )
1541 .unwrap();
1542 let vault = Vault::with_root(dir.path().to_path_buf());
1543
1544 let filter = Expr::Predicate(Predicate::Equals {
1545 field: "_name".into(),
1546 value: Value::String("log".into()),
1547 });
1548 let report = UpdateBuilder::new("notes", filter)
1549 .set_body("Replacement.\n")
1550 .execute(&vault)
1551 .unwrap();
1552 assert_eq!(report.errors.len(), 0);
1553 assert_eq!(report.changes.len(), 1);
1554
1555 let after = fs::read_to_string(dir.path().join("notes/log.md")).unwrap();
1556 assert!(after.contains("status: active"));
1557 assert!(!after.contains("Old body"));
1558 assert!(after.ends_with("---\nReplacement.\n"));
1559 }
1560
1561 #[test]
1562 fn update_builder_append_body_uses_default_newline_separator() {
1563 use std::fs;
1564 use tempfile::TempDir;
1565 let dir = TempDir::new().unwrap();
1566 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1567 fs::create_dir(dir.path().join("notes")).unwrap();
1568 fs::write(
1569 dir.path().join("notes/journal.md"),
1570 "---\nstatus: open\n---\nEntry 1.\n",
1571 )
1572 .unwrap();
1573 let vault = Vault::with_root(dir.path().to_path_buf());
1574
1575 let filter = Expr::Predicate(Predicate::Equals {
1576 field: "_name".into(),
1577 value: Value::String("journal".into()),
1578 });
1579 UpdateBuilder::new("notes", filter)
1580 .append_body("Entry 2.")
1581 .execute(&vault)
1582 .unwrap();
1583
1584 let after = fs::read_to_string(dir.path().join("notes/journal.md")).unwrap();
1585 assert!(after.ends_with("Entry 1.\nEntry 2."));
1586 }
1587
1588 #[test]
1589 fn update_builder_append_body_with_blank_line_separator() {
1590 use std::fs;
1591 use tempfile::TempDir;
1592 let dir = TempDir::new().unwrap();
1593 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1594 fs::create_dir(dir.path().join("notes")).unwrap();
1595 fs::write(
1596 dir.path().join("notes/n.md"),
1597 "---\nstatus: x\n---\nSection 1.\n",
1598 )
1599 .unwrap();
1600 let vault = Vault::with_root(dir.path().to_path_buf());
1601
1602 let filter = Expr::Predicate(Predicate::Equals {
1603 field: "_name".into(),
1604 value: Value::String("n".into()),
1605 });
1606 UpdateBuilder::new("notes", filter)
1607 .body_separator("\n\n")
1608 .append_body("Section 2.")
1609 .execute(&vault)
1610 .unwrap();
1611
1612 let after = fs::read_to_string(dir.path().join("notes/n.md")).unwrap();
1613 assert!(after.ends_with("Section 1.\n\nSection 2."));
1614 }
1615
1616 #[test]
1617 fn update_builder_clear_body_keeps_frontmatter() {
1618 use std::fs;
1619 use tempfile::TempDir;
1620 let dir = TempDir::new().unwrap();
1621 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1622 fs::create_dir(dir.path().join("notes")).unwrap();
1623 fs::write(
1624 dir.path().join("notes/temp.md"),
1625 "---\nstatus: x\n---\nLots of body content.\n",
1626 )
1627 .unwrap();
1628 let vault = Vault::with_root(dir.path().to_path_buf());
1629
1630 let filter = Expr::Predicate(Predicate::Equals {
1631 field: "_name".into(),
1632 value: Value::String("temp".into()),
1633 });
1634 UpdateBuilder::new("notes", filter)
1635 .clear_body()
1636 .execute(&vault)
1637 .unwrap();
1638
1639 let after = fs::read_to_string(dir.path().join("notes/temp.md")).unwrap();
1640 assert!(after.contains("status: x"));
1641 assert!(!after.contains("body content"));
1642 assert!(after.ends_with("---\n"));
1643 }
1644
1645 #[test]
1646 fn update_builder_clear_then_append_replaces_body() {
1647 use std::fs;
1650 use tempfile::TempDir;
1651 let dir = TempDir::new().unwrap();
1652 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1653 fs::create_dir(dir.path().join("notes")).unwrap();
1654 fs::write(dir.path().join("notes/x.md"), "---\nstatus: x\n---\nOld.\n").unwrap();
1655 let vault = Vault::with_root(dir.path().to_path_buf());
1656
1657 let filter = Expr::Predicate(Predicate::Equals {
1658 field: "_name".into(),
1659 value: Value::String("x".into()),
1660 });
1661 UpdateBuilder::new("notes", filter)
1662 .clear_body()
1663 .append_body("Fresh.")
1664 .execute(&vault)
1665 .unwrap();
1666
1667 let after = fs::read_to_string(dir.path().join("notes/x.md")).unwrap();
1668 assert!(after.ends_with("---\nFresh."));
1669 assert!(!after.contains("Old."));
1670 }
1671
1672 #[test]
1673 fn update_builder_body_op_combines_with_frontmatter_op() {
1674 use std::fs;
1678 use tempfile::TempDir;
1679 let dir = TempDir::new().unwrap();
1680 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1681 fs::create_dir(dir.path().join("notes")).unwrap();
1682 fs::write(
1683 dir.path().join("notes/n.md"),
1684 "---\nstatus: open\n---\nOriginal.\n",
1685 )
1686 .unwrap();
1687 let vault = Vault::with_root(dir.path().to_path_buf());
1688
1689 let filter = Expr::Predicate(Predicate::Equals {
1690 field: "_name".into(),
1691 value: Value::String("n".into()),
1692 });
1693 UpdateBuilder::new("notes", filter)
1694 .set("status", Value::String("done".into()))
1695 .append_body("Update log entry.")
1696 .execute(&vault)
1697 .unwrap();
1698
1699 let after = fs::read_to_string(dir.path().join("notes/n.md")).unwrap();
1700 assert!(after.contains("status: done"));
1701 assert!(after.contains("Original."));
1702 assert!(after.contains("Update log entry."));
1703 }
1704
1705 #[test]
1706 fn update_builder_append_body_on_empty_body() {
1707 use std::fs;
1710 use tempfile::TempDir;
1711 let dir = TempDir::new().unwrap();
1712 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1713 fs::create_dir(dir.path().join("notes")).unwrap();
1714 fs::write(dir.path().join("notes/empty.md"), "---\nstatus: x\n---\n").unwrap();
1715 let vault = Vault::with_root(dir.path().to_path_buf());
1716
1717 let filter = Expr::Predicate(Predicate::Equals {
1718 field: "_name".into(),
1719 value: Value::String("empty".into()),
1720 });
1721 UpdateBuilder::new("notes", filter)
1722 .append_body("First entry.")
1723 .execute(&vault)
1724 .unwrap();
1725
1726 let after = fs::read_to_string(dir.path().join("notes/empty.md")).unwrap();
1727 assert_eq!(after, "---\nstatus: x\n---\nFirst entry.");
1728 }
1729
1730 #[test]
1731 fn update_builder_chains() {
1732 let filter = Expr::Predicate(Predicate::Equals {
1733 field: "status".into(),
1734 value: Value::String("active".into()),
1735 });
1736 let b = UpdateBuilder::new("notes", filter)
1737 .set("priority", Value::Integer(1))
1738 .unset("draft")
1739 .add_tag("urgent")
1740 .remove_tag("stale");
1741 assert_eq!(b.set_fields.len(), 1);
1742 assert_eq!(b.unset_fields.len(), 1);
1743 assert_eq!(b.add_tags.len(), 1);
1744 assert_eq!(b.remove_tags.len(), 1);
1745 }
1746
1747 #[test]
1748 fn delete_builder_trash_moves_to_dot_trash() {
1749 use std::fs;
1750 use tempfile::TempDir;
1751 let dir = TempDir::new().unwrap();
1752 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1753 fs::create_dir(dir.path().join("notes")).unwrap();
1754 fs::write(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
1755 let vault = Vault::with_root(dir.path().to_path_buf());
1756 let filter = Expr::Predicate(Predicate::Equals {
1757 field: "status".into(),
1758 value: Value::String("stale".into()),
1759 });
1760 let builder = DeleteBuilder::new("notes", filter);
1761 let report = builder.execute(&vault).unwrap();
1762 assert_eq!(report.changes.len(), 1);
1763 assert_eq!(report.errors.len(), 0);
1764 assert!(!dir.path().join("notes/a.md").exists());
1765 assert!(dir.path().join(".trash/a.md").exists());
1766 }
1767
1768 #[test]
1769 fn delete_builder_permanent_removes_file() {
1770 use std::fs;
1771 use tempfile::TempDir;
1772 let dir = TempDir::new().unwrap();
1773 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1774 fs::create_dir(dir.path().join("notes")).unwrap();
1775 fs::write(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
1776 let vault = Vault::with_root(dir.path().to_path_buf());
1777 let filter = Expr::Predicate(Predicate::Equals {
1778 field: "status".into(),
1779 value: Value::String("stale".into()),
1780 });
1781 let builder = DeleteBuilder::new("notes", filter).permanent(true);
1782 builder.execute(&vault).unwrap();
1783 assert!(!dir.path().join("notes/a.md").exists());
1784 assert!(!dir.path().join(".trash/a.md").exists());
1785 }
1786
1787 #[test]
1788 fn move_builder_relocates_files() {
1789 use std::fs;
1790 use tempfile::TempDir;
1791 let dir = TempDir::new().unwrap();
1792 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1793 fs::create_dir(dir.path().join("notes")).unwrap();
1794 fs::write(
1795 dir.path().join("notes/a.md"),
1796 "---\nstatus: archived\n---\n",
1797 )
1798 .unwrap();
1799 let vault = Vault::with_root(dir.path().to_path_buf());
1800 let filter = Expr::Predicate(Predicate::Equals {
1801 field: "status".into(),
1802 value: Value::String("archived".into()),
1803 });
1804 let builder = MoveBuilder::new("notes", "archive", filter);
1805 let report = builder.execute(&vault).unwrap();
1806 assert_eq!(report.changes.len(), 1);
1807 assert_eq!(report.errors.len(), 0);
1808 assert!(!dir.path().join("notes/a.md").exists());
1809 assert!(dir.path().join("archive/a.md").exists());
1810 }
1811
1812 #[test]
1813 fn rename_builder_renames_and_rewrites_links() {
1814 use std::fs;
1815 use tempfile::TempDir;
1816 let dir = TempDir::new().unwrap();
1817 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1818 fs::create_dir(dir.path().join("notes")).unwrap();
1819 fs::write(
1820 dir.path().join("notes/old.md"),
1821 "---\nstatus: x\n---\nBody\n",
1822 )
1823 .unwrap();
1824 fs::write(
1825 dir.path().join("notes/source.md"),
1826 "---\nstatus: y\n---\nLinks to [[old]] and [[old|alias]] and [[old#section]].\n",
1827 )
1828 .unwrap();
1829 let vault = Vault::with_root(dir.path().to_path_buf());
1830
1831 let builder = RenameBuilder::new("notes", "old", "new");
1832 let report = builder.execute(&vault).unwrap();
1833 assert_eq!(report.changes.len(), 2);
1835 assert_eq!(report.errors.len(), 0);
1836 assert!(!dir.path().join("notes/old.md").exists());
1837 assert!(dir.path().join("notes/new.md").exists());
1838 let source_after = fs::read_to_string(dir.path().join("notes/source.md")).unwrap();
1839 assert!(source_after.contains("[[new]]"));
1840 assert!(source_after.contains("[[new|alias]]"));
1841 assert!(source_after.contains("[[new#section]]"));
1842 assert!(!source_after.contains("[[old"));
1843 }
1844
1845 #[test]
1846 fn rename_builder_target_conflict_returns_error() {
1847 use std::fs;
1848 use tempfile::TempDir;
1849 let dir = TempDir::new().unwrap();
1850 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1851 fs::create_dir(dir.path().join("notes")).unwrap();
1852 fs::write(dir.path().join("notes/old.md"), "---\nstatus: x\n---\n").unwrap();
1853 fs::write(dir.path().join("notes/new.md"), "---\nstatus: y\n---\n").unwrap();
1854 let vault = Vault::with_root(dir.path().to_path_buf());
1855 let report = RenameBuilder::new("notes", "old", "new")
1856 .execute(&vault)
1857 .unwrap();
1858 assert_eq!(report.changes.len(), 0);
1859 assert_eq!(report.errors.len(), 1);
1860 assert!(dir.path().join("notes/old.md").exists());
1862 }
1863
1864 #[test]
1865 fn update_builder_plan_and_execute_against_a_temp_vault() {
1866 use std::fs;
1867 use tempfile::TempDir;
1868
1869 let dir = TempDir::new().unwrap();
1870 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1871 fs::create_dir(dir.path().join("notes")).unwrap();
1872 fs::write(
1873 dir.path().join("notes/a.md"),
1874 "---\nstatus: active\n---\nBody A\n",
1875 )
1876 .unwrap();
1877 fs::write(
1878 dir.path().join("notes/b.md"),
1879 "---\nstatus: pending\n---\nBody B\n",
1880 )
1881 .unwrap();
1882
1883 let vault = Vault::with_root(dir.path().to_path_buf());
1884
1885 let filter = Expr::Predicate(Predicate::Equals {
1886 field: "status".into(),
1887 value: Value::String("active".into()),
1888 });
1889 let builder = UpdateBuilder::new("notes", filter).set("priority", Value::Integer(1));
1890
1891 let plan_report = builder.plan(&vault).unwrap();
1893 assert_eq!(plan_report.changes.len(), 1);
1894 assert_eq!(plan_report.errors.len(), 0);
1895 assert!(plan_report.changes[0].path.ends_with("a.md"));
1896 let before = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
1897 assert!(!before.contains("priority"));
1898
1899 let exec_report = builder.execute(&vault).unwrap();
1901 assert_eq!(exec_report.changes.len(), 1);
1902 let after = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
1903 assert!(after.contains("priority"));
1904 let b_after = fs::read_to_string(dir.path().join("notes/b.md")).unwrap();
1906 assert!(!b_after.contains("priority"));
1907 }
1908
1909 #[test]
1910 fn write_options_fsync_propagates_through_update_builder() {
1911 use std::fs;
1919 use tempfile::TempDir;
1920
1921 let f1 = Expr::Predicate(Predicate::Equals {
1923 field: "x".into(),
1924 value: Value::Integer(1),
1925 });
1926 let b = UpdateBuilder::new("notes", f1).fsync(true);
1927 assert!(b.write_options.fsync);
1928
1929 let f2 = Expr::Predicate(Predicate::Equals {
1930 field: "x".into(),
1931 value: Value::Integer(1),
1932 });
1933 let b =
1934 UpdateBuilder::new("notes", f2).write_options(crate::writer::WriteOptions::durable());
1935 assert!(b.write_options.fsync);
1936
1937 let dir = TempDir::new().unwrap();
1939 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1940 fs::create_dir(dir.path().join("notes")).unwrap();
1941 fs::write(
1942 dir.path().join("notes/durable.md"),
1943 "---\nstatus: active\n---\nBody.\n",
1944 )
1945 .unwrap();
1946 let vault = Vault::with_root(dir.path().to_path_buf());
1947
1948 let f3 = Expr::Predicate(Predicate::Equals {
1949 field: "status".into(),
1950 value: Value::String("active".into()),
1951 });
1952 let report = UpdateBuilder::new("notes", f3)
1953 .set("priority", Value::Integer(99))
1954 .fsync(true)
1955 .execute(&vault)
1956 .unwrap();
1957 assert_eq!(report.changes.len(), 1);
1958 assert_eq!(report.errors.len(), 0);
1959
1960 let after = fs::read_to_string(dir.path().join("notes/durable.md")).unwrap();
1961 assert!(after.contains("priority: 99"));
1962 assert!(after.contains("status: active"));
1963 }
1964
1965 #[test]
1966 fn rename_clean_run_leaves_no_journal_behind() {
1967 use std::fs;
1970 use tempfile::TempDir;
1971 let dir = TempDir::new().unwrap();
1972 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1973 fs::create_dir(dir.path().join("notes")).unwrap();
1974 fs::write(
1975 dir.path().join("notes/old.md"),
1976 "---\nstatus: x\n---\nBody\n",
1977 )
1978 .unwrap();
1979 fs::write(
1980 dir.path().join("notes/source.md"),
1981 "---\nstatus: y\n---\nLinks to [[old]].\n",
1982 )
1983 .unwrap();
1984 let vault = Vault::with_root(dir.path().to_path_buf());
1985
1986 RenameBuilder::new("notes", "old", "new")
1987 .execute(&vault)
1988 .unwrap();
1989
1990 let pending = crate::journal::list_pending(dir.path()).unwrap();
1991 assert!(
1992 pending.is_empty(),
1993 "successful rename must not leave journals behind: {:?}",
1994 pending
1995 );
1996 }
1997
1998 #[test]
1999 fn rename_recovers_from_pre_existing_journal() {
2000 use std::fs;
2004 use tempfile::TempDir;
2005 let dir = TempDir::new().unwrap();
2006 fs::create_dir(dir.path().join(".obsidian")).unwrap();
2007 fs::create_dir(dir.path().join("notes")).unwrap();
2008 let source = dir.path().join("notes/Stanford.md");
2009 let dest = dir.path().join("notes/Stanford University.md");
2010 let backlink = dir.path().join("notes/Application.md");
2011 fs::write(&source, "---\nkind: university\n---\nMain note.\n").unwrap();
2012 fs::write(
2013 &backlink,
2014 "---\nkind: application\n---\nApplied to [[Stanford]].\n",
2015 )
2016 .unwrap();
2017
2018 let journal = crate::journal::RenameJournal {
2020 source: source.clone(),
2021 dest: dest.clone(),
2022 from_name: "Stanford".into(),
2023 to_name: "Stanford University".into(),
2024 backlinks: vec![backlink.clone()],
2025 };
2026 crate::journal::write(dir.path(), &journal).unwrap();
2027
2028 let vault = Vault::with_root(dir.path().to_path_buf());
2029 let recovered = vault.recover().unwrap();
2030 assert_eq!(recovered, 1, "expected exactly one journal replayed");
2031
2032 assert!(!source.exists());
2034 assert!(dest.is_file());
2035 let backlink_content = fs::read_to_string(&backlink).unwrap();
2036 assert!(backlink_content.contains("[[Stanford University]]"));
2037 assert!(!backlink_content.contains("[[Stanford]]"));
2038 assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
2039 }
2040
2041 #[test]
2042 fn rename_replays_pending_journal_before_starting_new_rename() {
2043 use std::fs;
2047 use tempfile::TempDir;
2048 let dir = TempDir::new().unwrap();
2049 fs::create_dir(dir.path().join(".obsidian")).unwrap();
2050 fs::create_dir(dir.path().join("notes")).unwrap();
2051
2052 let a = dir.path().join("notes/A.md");
2056 let b = dir.path().join("notes/B.md");
2057 let c = dir.path().join("notes/C.md");
2058 let d = dir.path().join("notes/D.md");
2059 fs::write(&a, "---\n---\nA body.\n").unwrap();
2060 fs::write(&c, "---\n---\nC body.\n").unwrap();
2061
2062 crate::journal::write(
2064 dir.path(),
2065 &crate::journal::RenameJournal {
2066 source: a.clone(),
2067 dest: b.clone(),
2068 from_name: "A".into(),
2069 to_name: "B".into(),
2070 backlinks: vec![],
2071 },
2072 )
2073 .unwrap();
2074
2075 let vault = Vault::with_root(dir.path().to_path_buf());
2077 RenameBuilder::new("notes", "C", "D")
2078 .execute(&vault)
2079 .unwrap();
2080
2081 assert!(!a.exists(), "A.md should be gone (replayed journal)");
2084 assert!(b.is_file(), "B.md should exist (replayed journal)");
2085 assert!(!c.exists(), "C.md should be gone (new rename)");
2086 assert!(d.is_file(), "D.md should exist (new rename)");
2087
2088 assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
2090 }
2091
2092 #[test]
2093 fn concurrent_updates_serialize_via_vault_lock() {
2094 use std::fs;
2102 use std::sync::Arc;
2103 use std::thread;
2104 use tempfile::TempDir;
2105
2106 let dir = TempDir::new().unwrap();
2107 fs::create_dir(dir.path().join(".obsidian")).unwrap();
2108 fs::create_dir(dir.path().join("notes")).unwrap();
2109 fs::write(
2110 dir.path().join("notes/race.md"),
2111 "---\nstatus: active\n---\nBody.\n",
2112 )
2113 .unwrap();
2114
2115 let vault_path = Arc::new(dir.path().to_path_buf());
2116
2117 let p1 = Arc::clone(&vault_path);
2118 let t1 = thread::spawn(move || {
2119 let vault = Vault::with_root((*p1).clone());
2120 let filter = Expr::Predicate(Predicate::Equals {
2121 field: "status".into(),
2122 value: Value::String("active".into()),
2123 });
2124 UpdateBuilder::new("notes", filter)
2125 .set("touched_by_t1", Value::Integer(1))
2126 .execute(&vault)
2127 .expect("t1 execute")
2128 });
2129
2130 let p2 = Arc::clone(&vault_path);
2131 let t2 = thread::spawn(move || {
2132 let vault = Vault::with_root((*p2).clone());
2133 let filter = Expr::Predicate(Predicate::Equals {
2134 field: "status".into(),
2135 value: Value::String("active".into()),
2136 });
2137 UpdateBuilder::new("notes", filter)
2138 .set("touched_by_t2", Value::Integer(1))
2139 .execute(&vault)
2140 .expect("t2 execute")
2141 });
2142
2143 let r1 = t1.join().unwrap();
2144 let r2 = t2.join().unwrap();
2145 assert_eq!(r1.errors.len(), 0);
2146 assert_eq!(r2.errors.len(), 0);
2147
2148 let final_content = fs::read_to_string(dir.path().join("notes/race.md")).unwrap();
2152 assert!(
2153 final_content.contains("touched_by_t1"),
2154 "t1's edit lost; concurrent writer race: {}",
2155 final_content
2156 );
2157 assert!(
2158 final_content.contains("touched_by_t2"),
2159 "t2's edit lost; concurrent writer race: {}",
2160 final_content
2161 );
2162 }
2163
2164 #[test]
2165 fn atomic_write_does_not_leave_partial_files_on_failed_writes() {
2166 use std::fs;
2172 use tempfile::TempDir;
2173
2174 let dir = TempDir::new().unwrap();
2175 let target = dir.path().join("subdir/that-does-not-exist/x.md");
2176 let result = crate::writer::atomic_write(&target, "new content");
2179 assert!(
2180 result.is_err(),
2181 "expected atomic_write to fail when parent dir doesn't exist"
2182 );
2183
2184 let real_dir = dir.path().join("real");
2187 fs::create_dir(&real_dir).unwrap();
2188 let real_target = real_dir.join("x.md");
2189 fs::write(&real_target, "original").unwrap();
2190 crate::writer::atomic_write(&real_target, "replacement").unwrap();
2191 let after = fs::read_to_string(&real_target).unwrap();
2192 assert_eq!(after, "replacement");
2193
2194 let leftovers: Vec<_> = fs::read_dir(&real_dir)
2196 .unwrap()
2197 .flatten()
2198 .filter(|e| e.file_name().to_string_lossy().starts_with(".tmp"))
2199 .collect();
2200 assert!(
2201 leftovers.is_empty(),
2202 "expected no tempfile leftovers, found: {:?}",
2203 leftovers.iter().map(|e| e.path()).collect::<Vec<_>>()
2204 );
2205 }
2206
2207 use crate::schema::{CollectionSchema, FieldSchema};
2210
2211 fn movie_schema() -> CollectionSchema {
2212 let mut fields = std::collections::BTreeMap::new();
2213 fields.insert(
2214 "db-table".into(),
2215 FieldSchema {
2216 field_type: "string".into(),
2217 enum_values: vec![Value::String("movie".into())],
2218 min: None,
2219 max: None,
2220 default: Some(Value::String("movie".into())),
2221 default_expr: None,
2222 },
2223 );
2224 fields.insert(
2225 "status".into(),
2226 FieldSchema {
2227 field_type: "string".into(),
2228 enum_values: vec![
2229 Value::String("to-watch".into()),
2230 Value::String("watched".into()),
2231 ],
2232 min: None,
2233 max: None,
2234 default: Some(Value::String("to-watch".into())),
2235 default_expr: None,
2236 },
2237 );
2238 fields.insert(
2239 "director".into(),
2240 FieldSchema {
2241 field_type: "string".into(),
2242 enum_values: vec![],
2243 min: None,
2244 max: None,
2245 default: None,
2246 default_expr: None,
2247 },
2248 );
2249 fields.insert(
2250 "year".into(),
2251 FieldSchema {
2252 field_type: "integer".into(),
2253 enum_values: vec![],
2254 min: None,
2255 max: None,
2256 default: None,
2257 default_expr: None,
2258 },
2259 );
2260 CollectionSchema {
2261 description: None,
2262 folder: "Notes/movie".into(),
2263 filter: vec![],
2264 required: vec![
2265 "db-table".into(),
2266 "director".into(),
2267 "status".into(),
2268 "year".into(),
2269 ],
2270 fields,
2271 }
2272 }
2273
2274 fn vault_with_obsidian() -> tempfile::TempDir {
2275 let dir = tempfile::TempDir::new().unwrap();
2276 std::fs::create_dir(dir.path().join(".obsidian")).unwrap();
2277 dir
2278 }
2279
2280 #[test]
2281 fn create_without_schema_writes_minimal_file() {
2282 let dir = vault_with_obsidian();
2283 let vault = Vault::with_root(dir.path().to_path_buf());
2284 let report = CreateBuilder::new("Notes/movie", "Dune")
2285 .execute(&vault)
2286 .unwrap();
2287 assert_eq!(report.errors.len(), 0);
2288 assert_eq!(report.changes.len(), 1);
2289 let written = dir.path().join("Notes/movie/Dune.md");
2290 assert!(written.is_file());
2291 let content = std::fs::read_to_string(&written).unwrap();
2292 assert!(content.contains("---\n---"));
2294 assert!(content.contains("# Dune"));
2295 }
2296
2297 #[test]
2298 fn create_with_set_writes_typed_frontmatter() {
2299 let dir = vault_with_obsidian();
2300 let vault = Vault::with_root(dir.path().to_path_buf());
2301 CreateBuilder::new("Notes/movie", "Dune")
2302 .set("director", Value::String("Denis Villeneuve".into()))
2303 .set("year", Value::Integer(2021))
2304 .execute(&vault)
2305 .unwrap();
2306 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2307 assert!(content.contains("director: Denis Villeneuve"));
2308 assert!(content.contains("year: 2021"));
2310 }
2311
2312 #[test]
2313 fn create_fills_schema_defaults() {
2314 let dir = vault_with_obsidian();
2315 let vault = Vault::with_root(dir.path().to_path_buf());
2316 CreateBuilder::new("Notes/movie", "Dune")
2317 .with_schema(movie_schema())
2318 .set("director", Value::String("Denis Villeneuve".into()))
2319 .set("year", Value::Integer(2021))
2320 .execute(&vault)
2321 .unwrap();
2322 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2323 assert!(content.contains("db-table: movie"));
2325 assert!(content.contains("status: to-watch"));
2326 assert!(content.contains("director: Denis Villeneuve"));
2328 assert!(content.contains("year: 2021"));
2329 }
2330
2331 #[test]
2332 fn create_set_overrides_default() {
2333 let dir = vault_with_obsidian();
2334 let vault = Vault::with_root(dir.path().to_path_buf());
2335 CreateBuilder::new("Notes/movie", "Watched")
2336 .with_schema(movie_schema())
2337 .set("director", Value::String("X".into()))
2338 .set("year", Value::Integer(2020))
2339 .set("status", Value::String("watched".into()))
2340 .execute(&vault)
2341 .unwrap();
2342 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Watched.md")).unwrap();
2343 assert!(content.contains("status: watched"));
2344 assert!(!content.contains("status: to-watch"));
2345 }
2346
2347 #[test]
2348 fn create_rejects_missing_required_before_writing() {
2349 let dir = vault_with_obsidian();
2350 let vault = Vault::with_root(dir.path().to_path_buf());
2351 let report = CreateBuilder::new("Notes/movie", "Blank")
2353 .with_schema(movie_schema())
2354 .execute(&vault)
2355 .unwrap();
2356 assert!(!report.errors.is_empty());
2357 assert!(report.errors.iter().any(|e| e.message.contains("director")));
2358 assert!(report.errors.iter().any(|e| e.message.contains("year")));
2359 assert!(!dir.path().join("Notes/movie/Blank.md").exists());
2361 }
2362
2363 #[test]
2364 fn create_rejects_existing_file() {
2365 let dir = vault_with_obsidian();
2366 std::fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2367 std::fs::write(dir.path().join("Notes/movie/Dune.md"), "existing\n").unwrap();
2368 let vault = Vault::with_root(dir.path().to_path_buf());
2369 let report = CreateBuilder::new("Notes/movie", "Dune")
2370 .execute(&vault)
2371 .unwrap();
2372 assert_eq!(report.errors.len(), 1);
2373 assert!(report.errors[0].message.contains("already exists"));
2374 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2376 assert_eq!(content, "existing\n");
2377 }
2378
2379 #[test]
2380 fn create_resolves_default_expr_today() {
2381 let dir = vault_with_obsidian();
2382 let vault = Vault::with_root(dir.path().to_path_buf());
2383 let mut fields = std::collections::BTreeMap::new();
2384 fields.insert(
2385 "due".into(),
2386 FieldSchema {
2387 field_type: "date".into(),
2388 enum_values: vec![],
2389 min: None,
2390 max: None,
2391 default: None,
2392 default_expr: Some("today".into()),
2393 },
2394 );
2395 let schema = CollectionSchema {
2396 description: None,
2397 folder: "tasks".into(),
2398 filter: vec![],
2399 required: vec![],
2400 fields,
2401 };
2402 CreateBuilder::new("tasks", "t1")
2403 .with_schema(schema)
2404 .execute(&vault)
2405 .unwrap();
2406 let content = std::fs::read_to_string(dir.path().join("tasks/t1.md")).unwrap();
2407 let today = crate::record::today_string();
2409 assert!(
2410 content.contains(&format!("due: {}", today)),
2411 "expected due={} in: {}",
2412 today,
2413 content
2414 );
2415 }
2416
2417 #[test]
2418 fn create_plan_does_not_touch_disk() {
2419 let dir = vault_with_obsidian();
2420 let vault = Vault::with_root(dir.path().to_path_buf());
2421 let (report, content) = CreateBuilder::new("Notes/movie", "Dune")
2422 .with_schema(movie_schema())
2423 .set("director", Value::String("DV".into()))
2424 .set("year", Value::Integer(2021))
2425 .plan_with_content(&vault)
2426 .unwrap();
2427 assert_eq!(report.errors.len(), 0);
2428 assert_eq!(report.changes.len(), 1);
2429 assert!(!dir.path().join("Notes/movie/Dune.md").exists());
2430 let c = content.unwrap();
2431 assert!(c.contains("director: DV"));
2432 assert!(c.contains("db-table: movie")); }
2434
2435 #[test]
2436 fn create_with_explicit_body_overrides_default() {
2437 let dir = vault_with_obsidian();
2440 let vault = Vault::with_root(dir.path().to_path_buf());
2441 CreateBuilder::new("notes", "today")
2442 .body("- [ ] Wake up\n- [ ] Write code\n")
2443 .execute(&vault)
2444 .unwrap();
2445 let content = std::fs::read_to_string(dir.path().join("notes/today.md")).unwrap();
2446 assert!(content.contains("- [ ] Wake up"));
2447 assert!(!content.contains("# today"));
2449 }
2450
2451 #[test]
2452 fn create_with_explicit_body_overrides_template_body() {
2453 let dir = vault_with_obsidian();
2456 std::fs::create_dir_all(dir.path().join("templates")).unwrap();
2457 std::fs::write(
2458 dir.path().join("templates/note.md"),
2459 "---\nstatus: open\n---\n\n# Template body\n",
2460 )
2461 .unwrap();
2462 let vault = Vault::with_root(dir.path().to_path_buf());
2463 CreateBuilder::new("notes", "n")
2464 .template("templates/note.md")
2465 .body("Custom body.\n")
2466 .execute(&vault)
2467 .unwrap();
2468 let content = std::fs::read_to_string(dir.path().join("notes/n.md")).unwrap();
2469 assert!(content.contains("status: open")); assert!(content.contains("Custom body."));
2471 assert!(!content.contains("Template body"));
2472 }
2473
2474 #[test]
2475 fn create_from_template_preserves_body_and_merges_frontmatter() {
2476 let dir = vault_with_obsidian();
2477 std::fs::create_dir_all(dir.path().join("templates")).unwrap();
2478 std::fs::write(
2479 dir.path().join("templates/movie.md"),
2480 "---\nstatus: to-watch\naliases: []\n---\n\n# Title\n\nReview goes here.\n",
2481 )
2482 .unwrap();
2483 let vault = Vault::with_root(dir.path().to_path_buf());
2484 CreateBuilder::new("Notes/movie", "Dune")
2485 .template("templates/movie.md")
2486 .set("year", Value::Integer(2021))
2487 .execute(&vault)
2488 .unwrap();
2489 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2490 assert!(content.contains("status: to-watch"));
2492 assert!(content.contains("year: 2021"));
2494 assert!(content.contains("Review goes here"));
2496 }
2497
2498 use crate::schema::VaultSchema;
2507
2508 fn vault_schema_movies() -> VaultSchema {
2509 let mut vs = VaultSchema {
2512 collections: std::collections::BTreeMap::new(),
2513 };
2514 vs.collections.insert("movies".into(), movie_schema());
2515 vs
2516 }
2517
2518 fn vault_schema_catchall_and_movies() -> VaultSchema {
2519 let mut collections = std::collections::BTreeMap::new();
2523
2524 let mut catchall_fields = std::collections::BTreeMap::new();
2525 catchall_fields.insert(
2526 "db-table".into(),
2527 FieldSchema {
2528 field_type: "string".into(),
2529 enum_values: vec![Value::String("movie".into()), Value::String("book".into())],
2530 min: None,
2531 max: None,
2532 default: None,
2533 default_expr: None,
2534 },
2535 );
2536 collections.insert(
2537 "Notes".into(),
2538 CollectionSchema {
2539 description: None,
2540 folder: "Notes".into(),
2541 filter: vec![],
2542 required: vec!["db-table".into()],
2543 fields: catchall_fields,
2544 },
2545 );
2546 collections.insert("movies".into(), {
2547 let mut m = movie_schema();
2548 m.filter = vec!["db-table = movie".into()];
2549 m
2550 });
2551
2552 VaultSchema { collections }
2553 }
2554
2555 #[test]
2556 fn update_rejects_type_mismatch() {
2557 use std::fs;
2558 let dir = vault_with_obsidian();
2559 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2560 fs::write(
2561 dir.path().join("Notes/movie/Dune.md"),
2562 "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\nBody\n",
2563 )
2564 .unwrap();
2565 let vault = Vault::with_root(dir.path().to_path_buf());
2566
2567 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2568 field: "director".into(),
2569 value: Value::String("DV".into()),
2570 });
2571 let report = UpdateBuilder::new("Notes/movie", filter)
2572 .set("year", Value::String("nope".into()))
2573 .with_vault_schema(vault_schema_movies())
2574 .execute(&vault)
2575 .unwrap();
2576
2577 assert!(report.changes.is_empty(), "no write should be reported");
2578 assert!(
2579 report
2580 .errors
2581 .iter()
2582 .any(|e| e.message.contains("year") && e.message.contains("integer")),
2583 "expected year/integer type-mismatch error, got: {:?}",
2584 report.errors
2585 );
2586 let content = fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2588 assert!(content.contains("year: 2021"));
2589 assert!(!content.contains("year: nope"));
2590 }
2591
2592 #[test]
2593 fn update_rejects_enum_violation() {
2594 use std::fs;
2595 let dir = vault_with_obsidian();
2596 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2597 fs::write(
2598 dir.path().join("Notes/movie/Dune.md"),
2599 "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\n",
2600 )
2601 .unwrap();
2602 let vault = Vault::with_root(dir.path().to_path_buf());
2603
2604 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2605 field: "director".into(),
2606 value: Value::String("DV".into()),
2607 });
2608 let report = UpdateBuilder::new("Notes/movie", filter)
2609 .set("status", Value::String("watching".into()))
2610 .with_vault_schema(vault_schema_movies())
2611 .execute(&vault)
2612 .unwrap();
2613
2614 assert!(report.changes.is_empty());
2615 assert!(
2616 report
2617 .errors
2618 .iter()
2619 .any(|e| e.message.contains("status") && e.message.contains("watching")),
2620 "expected status enum violation, got: {:?}",
2621 report.errors
2622 );
2623 }
2624
2625 #[test]
2626 fn update_passes_when_unconstrained_field_changes() {
2627 use std::fs;
2631 let dir = vault_with_obsidian();
2632 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2633 fs::write(
2634 dir.path().join("Notes/movie/Dune.md"),
2635 "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\n",
2636 )
2637 .unwrap();
2638 let vault = Vault::with_root(dir.path().to_path_buf());
2639
2640 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2641 field: "director".into(),
2642 value: Value::String("DV".into()),
2643 });
2644 let report = UpdateBuilder::new("Notes/movie", filter)
2645 .set("notes-to-self", Value::String("rewatch".into()))
2646 .with_vault_schema(vault_schema_movies())
2647 .execute(&vault)
2648 .unwrap();
2649 assert!(
2650 report.errors.is_empty(),
2651 "no errors expected, got: {:?}",
2652 report.errors
2653 );
2654 assert_eq!(report.changes.len(), 1);
2655 let content = fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2656 assert!(content.contains("notes-to-self: rewatch"));
2657 }
2658
2659 #[test]
2660 fn update_surfaces_preexisting_violation() {
2661 use std::fs;
2666 let dir = vault_with_obsidian();
2667 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2668 fs::write(
2669 dir.path().join("Notes/movie/Old.md"),
2670 "---\ndb-table: movie\nstatus: to-watch\nyear: 2021\n---\n",
2672 )
2673 .unwrap();
2674 let vault = Vault::with_root(dir.path().to_path_buf());
2675
2676 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2677 field: "db-table".into(),
2678 value: Value::String("movie".into()),
2679 });
2680 let report = UpdateBuilder::new("Notes/movie", filter)
2681 .set("year", Value::Integer(2022))
2682 .with_vault_schema(vault_schema_movies())
2683 .execute(&vault)
2684 .unwrap();
2685 assert!(
2686 report.errors.iter().any(|e| e.message.contains("director")),
2687 "expected pre-existing required-field violation, got: {:?}",
2688 report.errors
2689 );
2690 let content = fs::read_to_string(dir.path().join("Notes/movie/Old.md")).unwrap();
2692 assert!(content.contains("year: 2021"));
2693 }
2694
2695 #[test]
2696 fn update_skips_one_blocks_one_in_batch() {
2697 use std::fs;
2704 let dir = vault_with_obsidian();
2705 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2706 fs::write(
2707 dir.path().join("Notes/movie/Good.md"),
2708 "---\ndb-table: movie\ndirector: A\nstatus: to-watch\nyear: 2021\n---\n",
2709 )
2710 .unwrap();
2711 fs::write(
2712 dir.path().join("Notes/movie/Bad.md"),
2713 "---\ndb-table: movie\nstatus: to-watch\nyear: 2021\n---\n",
2715 )
2716 .unwrap();
2717 let vault = Vault::with_root(dir.path().to_path_buf());
2718
2719 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2720 field: "db-table".into(),
2721 value: Value::String("movie".into()),
2722 });
2723 let report = UpdateBuilder::new("Notes/movie", filter)
2724 .set("year", Value::Integer(2022))
2725 .with_vault_schema(vault_schema_movies())
2726 .execute(&vault)
2727 .unwrap();
2728 assert_eq!(report.changes.len(), 1, "exactly one record should write");
2729 assert!(report.changes[0].path.ends_with("Good.md"));
2730 assert!(report.errors.iter().any(|e| e.path.ends_with("Bad.md")));
2731 assert!(
2733 fs::read_to_string(dir.path().join("Notes/movie/Good.md"))
2734 .unwrap()
2735 .contains("year: 2022")
2736 );
2737 assert!(
2738 fs::read_to_string(dir.path().join("Notes/movie/Bad.md"))
2739 .unwrap()
2740 .contains("year: 2021")
2741 );
2742 }
2743
2744 #[test]
2745 fn update_validates_against_catchall_and_subfolder() {
2746 use std::fs;
2752 let dir = vault_with_obsidian();
2753 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2754 fs::write(
2755 dir.path().join("Notes/movie/Dune.md"),
2756 "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\n",
2757 )
2758 .unwrap();
2759 let vault = Vault::with_root(dir.path().to_path_buf());
2760
2761 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2762 field: "director".into(),
2763 value: Value::String("DV".into()),
2764 });
2765 let report = UpdateBuilder::new("Notes/movie", filter)
2766 .unset("db-table")
2767 .with_vault_schema(vault_schema_catchall_and_movies())
2768 .execute(&vault)
2769 .unwrap();
2770
2771 assert!(report.changes.is_empty());
2776 assert!(
2777 report.errors.iter().any(|e| e.message.contains("db-table")),
2778 "expected db-table missing error from catch-all, got: {:?}",
2779 report.errors
2780 );
2781 }
2782
2783 #[test]
2784 fn create_rejects_type_mismatch() {
2785 let dir = vault_with_obsidian();
2786 let vault = Vault::with_root(dir.path().to_path_buf());
2787 let report = CreateBuilder::new("Notes/movie", "Dune")
2788 .with_vault_schema(vault_schema_movies())
2789 .set("director", Value::String("DV".into()))
2790 .set("year", Value::String("not-a-year".into()))
2791 .execute(&vault)
2792 .unwrap();
2793 assert!(
2794 report
2795 .errors
2796 .iter()
2797 .any(|e| e.message.contains("year") && e.message.contains("integer")),
2798 "expected year/integer type error, got: {:?}",
2799 report.errors
2800 );
2801 assert!(!dir.path().join("Notes/movie/Dune.md").exists());
2802 }
2803
2804 #[test]
2805 fn create_validates_against_multiple_applicable_collections() {
2806 let dir = vault_with_obsidian();
2811 let vault = Vault::with_root(dir.path().to_path_buf());
2812 let report = CreateBuilder::new("Notes/movie", "X")
2813 .with_vault_schema(vault_schema_catchall_and_movies())
2814 .set("db-table", Value::String("movie".into()))
2815 .execute(&vault)
2816 .unwrap();
2817 assert!(report.errors.iter().any(|e| e.message.contains("director")));
2818 assert!(report.errors.iter().any(|e| e.message.contains("year")));
2819 assert!(!dir.path().join("Notes/movie/X.md").exists());
2820 }
2821}