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_set_body_to_same_text_is_skipped() {
1732 use std::fs;
1737 use tempfile::TempDir;
1738 let dir = TempDir::new().unwrap();
1739 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1740 fs::create_dir(dir.path().join("notes")).unwrap();
1741 fs::write(
1742 dir.path().join("notes/a.md"),
1743 "---\nstatus: x\n---\nExact body.\n",
1744 )
1745 .unwrap();
1746 let vault = Vault::with_root(dir.path().to_path_buf());
1747
1748 let filter = Expr::Predicate(Predicate::Equals {
1749 field: "_name".into(),
1750 value: Value::String("a".into()),
1751 });
1752 let report = UpdateBuilder::new("notes", filter)
1753 .set_body("Exact body.\n")
1754 .execute(&vault)
1755 .unwrap();
1756 assert_eq!(report.errors.len(), 0);
1757 assert_eq!(
1758 report.changes.len(),
1759 0,
1760 "set_body with the existing content must not be reported as a change"
1761 );
1762 }
1763
1764 #[test]
1765 fn update_builder_append_body_touches_every_matching_record() {
1766 use std::fs;
1771 use tempfile::TempDir;
1772 let dir = TempDir::new().unwrap();
1773 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1774 fs::create_dir(dir.path().join("notes")).unwrap();
1775 for name in ["a", "b", "c"] {
1776 fs::write(
1777 dir.path().join(format!("notes/{}.md", name)),
1778 format!("---\nstatus: open\n---\nbody {}.\n", name),
1779 )
1780 .unwrap();
1781 }
1782 let vault = Vault::with_root(dir.path().to_path_buf());
1783
1784 let filter = Expr::Predicate(Predicate::Equals {
1785 field: "status".into(),
1786 value: Value::String("open".into()),
1787 });
1788 let report = UpdateBuilder::new("notes", filter)
1789 .append_body("[done]")
1790 .execute(&vault)
1791 .unwrap();
1792 assert_eq!(report.errors.len(), 0);
1793 assert_eq!(report.changes.len(), 3, "all three records must be touched");
1794
1795 for name in ["a", "b", "c"] {
1796 let after = fs::read_to_string(dir.path().join(format!("notes/{}.md", name))).unwrap();
1797 assert!(
1798 after.ends_with(&format!("body {}.\n[done]", name)),
1799 "{} got: {}",
1800 name,
1801 after
1802 );
1803 }
1804 }
1805
1806 #[test]
1807 fn update_builder_body_op_preserves_crlf_line_endings() {
1808 use std::fs;
1814 use tempfile::TempDir;
1815 let dir = TempDir::new().unwrap();
1816 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1817 fs::create_dir(dir.path().join("notes")).unwrap();
1818 let crlf = "---\r\nstatus: x\r\n---\r\nOld.\r\n";
1819 fs::write(dir.path().join("notes/win.md"), crlf).unwrap();
1820 let vault = Vault::with_root(dir.path().to_path_buf());
1821
1822 let filter = Expr::Predicate(Predicate::Equals {
1823 field: "_name".into(),
1824 value: Value::String("win".into()),
1825 });
1826 UpdateBuilder::new("notes", filter)
1827 .set_body("New body.")
1828 .execute(&vault)
1829 .unwrap();
1830
1831 let after = fs::read_to_string(dir.path().join("notes/win.md")).unwrap();
1832 assert!(
1833 after.starts_with("---\r\nstatus: x\r\n---\r\n"),
1834 "frontmatter CRLFs lost: {:?}",
1835 after
1836 );
1837 assert!(after.ends_with("New body."));
1838 }
1839
1840 #[test]
1841 fn update_builder_appended_body_with_dashes_still_reparses() {
1842 use std::fs;
1848 use tempfile::TempDir;
1849 let dir = TempDir::new().unwrap();
1850 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1851 fs::create_dir(dir.path().join("notes")).unwrap();
1852 fs::write(
1853 dir.path().join("notes/n.md"),
1854 "---\nstatus: active\n---\nIntro.\n",
1855 )
1856 .unwrap();
1857 let vault = Vault::with_root(dir.path().to_path_buf());
1858
1859 let filter = Expr::Predicate(Predicate::Equals {
1860 field: "_name".into(),
1861 value: Value::String("n".into()),
1862 });
1863 UpdateBuilder::new("notes", filter)
1864 .body_separator("\n")
1865 .append_body("---\nNext section.")
1866 .execute(&vault)
1867 .unwrap();
1868
1869 let records = vault
1870 .load_records(&dir.path().join("notes"), false, false)
1871 .unwrap()
1872 .records;
1873 let n = records.iter().find(|r| r.path.ends_with("n.md")).unwrap();
1874 assert_eq!(
1875 n.fields.get("status"),
1876 Some(&Value::String("active".into()))
1877 );
1878 }
1879
1880 #[test]
1881 fn update_builder_plan_describes_body_ops() {
1882 use std::fs;
1886 use tempfile::TempDir;
1887 let dir = TempDir::new().unwrap();
1888 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1889 fs::create_dir(dir.path().join("notes")).unwrap();
1890 fs::write(
1891 dir.path().join("notes/a.md"),
1892 "---\nstatus: x\n---\nOriginal body.\n",
1893 )
1894 .unwrap();
1895 let vault = Vault::with_root(dir.path().to_path_buf());
1896
1897 let filter = Expr::Predicate(Predicate::Equals {
1898 field: "_name".into(),
1899 value: Value::String("a".into()),
1900 });
1901 let plan = UpdateBuilder::new("notes", filter)
1902 .append_body("Tail.")
1903 .plan(&vault)
1904 .unwrap();
1905 assert_eq!(plan.changes.len(), 1);
1906 assert!(
1907 plan.changes[0].description.contains("append body"),
1908 "description must mention the body op: {:?}",
1909 plan.changes[0].description
1910 );
1911 let still = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
1912 assert!(still.ends_with("Original body.\n"));
1913 }
1914
1915 #[test]
1916 fn update_builder_body_only_change_passes_schema() {
1917 use std::fs;
1923 let dir = vault_with_obsidian();
1924 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
1925 fs::write(
1926 dir.path().join("Notes/movie/Dune.md"),
1927 "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\nReview pending.\n",
1928 )
1929 .unwrap();
1930 let vault = Vault::with_root(dir.path().to_path_buf());
1931
1932 let filter = Expr::Predicate(crate::query::Predicate::Equals {
1933 field: "director".into(),
1934 value: Value::String("DV".into()),
1935 });
1936 let report = UpdateBuilder::new("Notes/movie", filter)
1937 .append_body("Now watched.")
1938 .with_vault_schema(vault_schema_movies())
1939 .execute(&vault)
1940 .unwrap();
1941 assert!(
1942 report.errors.is_empty(),
1943 "schema must pass on body-only change: {:?}",
1944 report.errors
1945 );
1946 assert_eq!(report.changes.len(), 1);
1947 let after = fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1948 assert!(after.contains("Review pending.\nNow watched."));
1949 assert!(after.contains("director: DV"));
1950 }
1951
1952 #[test]
1953 fn update_builder_clear_set_append_apply_in_documented_order() {
1954 use std::fs;
1960 use tempfile::TempDir;
1961 let dir = TempDir::new().unwrap();
1962 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1963 fs::create_dir(dir.path().join("notes")).unwrap();
1964 fs::write(
1965 dir.path().join("notes/a.md"),
1966 "---\nstatus: x\n---\nGarbage that should disappear.\n",
1967 )
1968 .unwrap();
1969 let vault = Vault::with_root(dir.path().to_path_buf());
1970
1971 let filter = Expr::Predicate(Predicate::Equals {
1972 field: "_name".into(),
1973 value: Value::String("a".into()),
1974 });
1975 UpdateBuilder::new("notes", filter)
1978 .append_body("Y")
1979 .set_body("X")
1980 .clear_body()
1981 .execute(&vault)
1982 .unwrap();
1983
1984 let after = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
1985 assert!(after.ends_with("---\nX\nY"), "got: {:?}", after);
1986 assert!(!after.contains("Garbage"));
1987 }
1988
1989 #[test]
1990 fn update_builder_chains() {
1991 let filter = Expr::Predicate(Predicate::Equals {
1992 field: "status".into(),
1993 value: Value::String("active".into()),
1994 });
1995 let b = UpdateBuilder::new("notes", filter)
1996 .set("priority", Value::Integer(1))
1997 .unset("draft")
1998 .add_tag("urgent")
1999 .remove_tag("stale");
2000 assert_eq!(b.set_fields.len(), 1);
2001 assert_eq!(b.unset_fields.len(), 1);
2002 assert_eq!(b.add_tags.len(), 1);
2003 assert_eq!(b.remove_tags.len(), 1);
2004 }
2005
2006 #[test]
2007 fn delete_builder_trash_moves_to_dot_trash() {
2008 use std::fs;
2009 use tempfile::TempDir;
2010 let dir = TempDir::new().unwrap();
2011 fs::create_dir(dir.path().join(".obsidian")).unwrap();
2012 fs::create_dir(dir.path().join("notes")).unwrap();
2013 fs::write(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
2014 let vault = Vault::with_root(dir.path().to_path_buf());
2015 let filter = Expr::Predicate(Predicate::Equals {
2016 field: "status".into(),
2017 value: Value::String("stale".into()),
2018 });
2019 let builder = DeleteBuilder::new("notes", filter);
2020 let report = builder.execute(&vault).unwrap();
2021 assert_eq!(report.changes.len(), 1);
2022 assert_eq!(report.errors.len(), 0);
2023 assert!(!dir.path().join("notes/a.md").exists());
2024 assert!(dir.path().join(".trash/a.md").exists());
2025 }
2026
2027 #[test]
2028 fn delete_builder_permanent_removes_file() {
2029 use std::fs;
2030 use tempfile::TempDir;
2031 let dir = TempDir::new().unwrap();
2032 fs::create_dir(dir.path().join(".obsidian")).unwrap();
2033 fs::create_dir(dir.path().join("notes")).unwrap();
2034 fs::write(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
2035 let vault = Vault::with_root(dir.path().to_path_buf());
2036 let filter = Expr::Predicate(Predicate::Equals {
2037 field: "status".into(),
2038 value: Value::String("stale".into()),
2039 });
2040 let builder = DeleteBuilder::new("notes", filter).permanent(true);
2041 builder.execute(&vault).unwrap();
2042 assert!(!dir.path().join("notes/a.md").exists());
2043 assert!(!dir.path().join(".trash/a.md").exists());
2044 }
2045
2046 #[test]
2047 fn move_builder_relocates_files() {
2048 use std::fs;
2049 use tempfile::TempDir;
2050 let dir = TempDir::new().unwrap();
2051 fs::create_dir(dir.path().join(".obsidian")).unwrap();
2052 fs::create_dir(dir.path().join("notes")).unwrap();
2053 fs::write(
2054 dir.path().join("notes/a.md"),
2055 "---\nstatus: archived\n---\n",
2056 )
2057 .unwrap();
2058 let vault = Vault::with_root(dir.path().to_path_buf());
2059 let filter = Expr::Predicate(Predicate::Equals {
2060 field: "status".into(),
2061 value: Value::String("archived".into()),
2062 });
2063 let builder = MoveBuilder::new("notes", "archive", filter);
2064 let report = builder.execute(&vault).unwrap();
2065 assert_eq!(report.changes.len(), 1);
2066 assert_eq!(report.errors.len(), 0);
2067 assert!(!dir.path().join("notes/a.md").exists());
2068 assert!(dir.path().join("archive/a.md").exists());
2069 }
2070
2071 #[test]
2072 fn rename_builder_renames_and_rewrites_links() {
2073 use std::fs;
2074 use tempfile::TempDir;
2075 let dir = TempDir::new().unwrap();
2076 fs::create_dir(dir.path().join(".obsidian")).unwrap();
2077 fs::create_dir(dir.path().join("notes")).unwrap();
2078 fs::write(
2079 dir.path().join("notes/old.md"),
2080 "---\nstatus: x\n---\nBody\n",
2081 )
2082 .unwrap();
2083 fs::write(
2084 dir.path().join("notes/source.md"),
2085 "---\nstatus: y\n---\nLinks to [[old]] and [[old|alias]] and [[old#section]].\n",
2086 )
2087 .unwrap();
2088 let vault = Vault::with_root(dir.path().to_path_buf());
2089
2090 let builder = RenameBuilder::new("notes", "old", "new");
2091 let report = builder.execute(&vault).unwrap();
2092 assert_eq!(report.changes.len(), 2);
2094 assert_eq!(report.errors.len(), 0);
2095 assert!(!dir.path().join("notes/old.md").exists());
2096 assert!(dir.path().join("notes/new.md").exists());
2097 let source_after = fs::read_to_string(dir.path().join("notes/source.md")).unwrap();
2098 assert!(source_after.contains("[[new]]"));
2099 assert!(source_after.contains("[[new|alias]]"));
2100 assert!(source_after.contains("[[new#section]]"));
2101 assert!(!source_after.contains("[[old"));
2102 }
2103
2104 #[test]
2105 fn rename_builder_target_conflict_returns_error() {
2106 use std::fs;
2107 use tempfile::TempDir;
2108 let dir = TempDir::new().unwrap();
2109 fs::create_dir(dir.path().join(".obsidian")).unwrap();
2110 fs::create_dir(dir.path().join("notes")).unwrap();
2111 fs::write(dir.path().join("notes/old.md"), "---\nstatus: x\n---\n").unwrap();
2112 fs::write(dir.path().join("notes/new.md"), "---\nstatus: y\n---\n").unwrap();
2113 let vault = Vault::with_root(dir.path().to_path_buf());
2114 let report = RenameBuilder::new("notes", "old", "new")
2115 .execute(&vault)
2116 .unwrap();
2117 assert_eq!(report.changes.len(), 0);
2118 assert_eq!(report.errors.len(), 1);
2119 assert!(dir.path().join("notes/old.md").exists());
2121 }
2122
2123 #[test]
2124 fn update_builder_plan_and_execute_against_a_temp_vault() {
2125 use std::fs;
2126 use tempfile::TempDir;
2127
2128 let dir = TempDir::new().unwrap();
2129 fs::create_dir(dir.path().join(".obsidian")).unwrap();
2130 fs::create_dir(dir.path().join("notes")).unwrap();
2131 fs::write(
2132 dir.path().join("notes/a.md"),
2133 "---\nstatus: active\n---\nBody A\n",
2134 )
2135 .unwrap();
2136 fs::write(
2137 dir.path().join("notes/b.md"),
2138 "---\nstatus: pending\n---\nBody B\n",
2139 )
2140 .unwrap();
2141
2142 let vault = Vault::with_root(dir.path().to_path_buf());
2143
2144 let filter = Expr::Predicate(Predicate::Equals {
2145 field: "status".into(),
2146 value: Value::String("active".into()),
2147 });
2148 let builder = UpdateBuilder::new("notes", filter).set("priority", Value::Integer(1));
2149
2150 let plan_report = builder.plan(&vault).unwrap();
2152 assert_eq!(plan_report.changes.len(), 1);
2153 assert_eq!(plan_report.errors.len(), 0);
2154 assert!(plan_report.changes[0].path.ends_with("a.md"));
2155 let before = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
2156 assert!(!before.contains("priority"));
2157
2158 let exec_report = builder.execute(&vault).unwrap();
2160 assert_eq!(exec_report.changes.len(), 1);
2161 let after = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
2162 assert!(after.contains("priority"));
2163 let b_after = fs::read_to_string(dir.path().join("notes/b.md")).unwrap();
2165 assert!(!b_after.contains("priority"));
2166 }
2167
2168 #[test]
2169 fn write_options_fsync_propagates_through_update_builder() {
2170 use std::fs;
2178 use tempfile::TempDir;
2179
2180 let f1 = Expr::Predicate(Predicate::Equals {
2182 field: "x".into(),
2183 value: Value::Integer(1),
2184 });
2185 let b = UpdateBuilder::new("notes", f1).fsync(true);
2186 assert!(b.write_options.fsync);
2187
2188 let f2 = Expr::Predicate(Predicate::Equals {
2189 field: "x".into(),
2190 value: Value::Integer(1),
2191 });
2192 let b =
2193 UpdateBuilder::new("notes", f2).write_options(crate::writer::WriteOptions::durable());
2194 assert!(b.write_options.fsync);
2195
2196 let dir = TempDir::new().unwrap();
2198 fs::create_dir(dir.path().join(".obsidian")).unwrap();
2199 fs::create_dir(dir.path().join("notes")).unwrap();
2200 fs::write(
2201 dir.path().join("notes/durable.md"),
2202 "---\nstatus: active\n---\nBody.\n",
2203 )
2204 .unwrap();
2205 let vault = Vault::with_root(dir.path().to_path_buf());
2206
2207 let f3 = Expr::Predicate(Predicate::Equals {
2208 field: "status".into(),
2209 value: Value::String("active".into()),
2210 });
2211 let report = UpdateBuilder::new("notes", f3)
2212 .set("priority", Value::Integer(99))
2213 .fsync(true)
2214 .execute(&vault)
2215 .unwrap();
2216 assert_eq!(report.changes.len(), 1);
2217 assert_eq!(report.errors.len(), 0);
2218
2219 let after = fs::read_to_string(dir.path().join("notes/durable.md")).unwrap();
2220 assert!(after.contains("priority: 99"));
2221 assert!(after.contains("status: active"));
2222 }
2223
2224 #[test]
2225 fn rename_clean_run_leaves_no_journal_behind() {
2226 use std::fs;
2229 use tempfile::TempDir;
2230 let dir = TempDir::new().unwrap();
2231 fs::create_dir(dir.path().join(".obsidian")).unwrap();
2232 fs::create_dir(dir.path().join("notes")).unwrap();
2233 fs::write(
2234 dir.path().join("notes/old.md"),
2235 "---\nstatus: x\n---\nBody\n",
2236 )
2237 .unwrap();
2238 fs::write(
2239 dir.path().join("notes/source.md"),
2240 "---\nstatus: y\n---\nLinks to [[old]].\n",
2241 )
2242 .unwrap();
2243 let vault = Vault::with_root(dir.path().to_path_buf());
2244
2245 RenameBuilder::new("notes", "old", "new")
2246 .execute(&vault)
2247 .unwrap();
2248
2249 let pending = crate::journal::list_pending(dir.path()).unwrap();
2250 assert!(
2251 pending.is_empty(),
2252 "successful rename must not leave journals behind: {:?}",
2253 pending
2254 );
2255 }
2256
2257 #[test]
2258 fn rename_recovers_from_pre_existing_journal() {
2259 use std::fs;
2263 use tempfile::TempDir;
2264 let dir = TempDir::new().unwrap();
2265 fs::create_dir(dir.path().join(".obsidian")).unwrap();
2266 fs::create_dir(dir.path().join("notes")).unwrap();
2267 let source = dir.path().join("notes/Stanford.md");
2268 let dest = dir.path().join("notes/Stanford University.md");
2269 let backlink = dir.path().join("notes/Application.md");
2270 fs::write(&source, "---\nkind: university\n---\nMain note.\n").unwrap();
2271 fs::write(
2272 &backlink,
2273 "---\nkind: application\n---\nApplied to [[Stanford]].\n",
2274 )
2275 .unwrap();
2276
2277 let journal = crate::journal::RenameJournal {
2279 source: source.clone(),
2280 dest: dest.clone(),
2281 from_name: "Stanford".into(),
2282 to_name: "Stanford University".into(),
2283 backlinks: vec![backlink.clone()],
2284 };
2285 crate::journal::write(dir.path(), &journal).unwrap();
2286
2287 let vault = Vault::with_root(dir.path().to_path_buf());
2288 let recovered = vault.recover().unwrap();
2289 assert_eq!(recovered, 1, "expected exactly one journal replayed");
2290
2291 assert!(!source.exists());
2293 assert!(dest.is_file());
2294 let backlink_content = fs::read_to_string(&backlink).unwrap();
2295 assert!(backlink_content.contains("[[Stanford University]]"));
2296 assert!(!backlink_content.contains("[[Stanford]]"));
2297 assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
2298 }
2299
2300 #[test]
2301 fn rename_replays_pending_journal_before_starting_new_rename() {
2302 use std::fs;
2306 use tempfile::TempDir;
2307 let dir = TempDir::new().unwrap();
2308 fs::create_dir(dir.path().join(".obsidian")).unwrap();
2309 fs::create_dir(dir.path().join("notes")).unwrap();
2310
2311 let a = dir.path().join("notes/A.md");
2315 let b = dir.path().join("notes/B.md");
2316 let c = dir.path().join("notes/C.md");
2317 let d = dir.path().join("notes/D.md");
2318 fs::write(&a, "---\n---\nA body.\n").unwrap();
2319 fs::write(&c, "---\n---\nC body.\n").unwrap();
2320
2321 crate::journal::write(
2323 dir.path(),
2324 &crate::journal::RenameJournal {
2325 source: a.clone(),
2326 dest: b.clone(),
2327 from_name: "A".into(),
2328 to_name: "B".into(),
2329 backlinks: vec![],
2330 },
2331 )
2332 .unwrap();
2333
2334 let vault = Vault::with_root(dir.path().to_path_buf());
2336 RenameBuilder::new("notes", "C", "D")
2337 .execute(&vault)
2338 .unwrap();
2339
2340 assert!(!a.exists(), "A.md should be gone (replayed journal)");
2343 assert!(b.is_file(), "B.md should exist (replayed journal)");
2344 assert!(!c.exists(), "C.md should be gone (new rename)");
2345 assert!(d.is_file(), "D.md should exist (new rename)");
2346
2347 assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
2349 }
2350
2351 #[test]
2352 fn concurrent_updates_serialize_via_vault_lock() {
2353 use std::fs;
2361 use std::sync::Arc;
2362 use std::thread;
2363 use tempfile::TempDir;
2364
2365 let dir = TempDir::new().unwrap();
2366 fs::create_dir(dir.path().join(".obsidian")).unwrap();
2367 fs::create_dir(dir.path().join("notes")).unwrap();
2368 fs::write(
2369 dir.path().join("notes/race.md"),
2370 "---\nstatus: active\n---\nBody.\n",
2371 )
2372 .unwrap();
2373
2374 let vault_path = Arc::new(dir.path().to_path_buf());
2375
2376 let p1 = Arc::clone(&vault_path);
2377 let t1 = thread::spawn(move || {
2378 let vault = Vault::with_root((*p1).clone());
2379 let filter = Expr::Predicate(Predicate::Equals {
2380 field: "status".into(),
2381 value: Value::String("active".into()),
2382 });
2383 UpdateBuilder::new("notes", filter)
2384 .set("touched_by_t1", Value::Integer(1))
2385 .execute(&vault)
2386 .expect("t1 execute")
2387 });
2388
2389 let p2 = Arc::clone(&vault_path);
2390 let t2 = thread::spawn(move || {
2391 let vault = Vault::with_root((*p2).clone());
2392 let filter = Expr::Predicate(Predicate::Equals {
2393 field: "status".into(),
2394 value: Value::String("active".into()),
2395 });
2396 UpdateBuilder::new("notes", filter)
2397 .set("touched_by_t2", Value::Integer(1))
2398 .execute(&vault)
2399 .expect("t2 execute")
2400 });
2401
2402 let r1 = t1.join().unwrap();
2403 let r2 = t2.join().unwrap();
2404 assert_eq!(r1.errors.len(), 0);
2405 assert_eq!(r2.errors.len(), 0);
2406
2407 let final_content = fs::read_to_string(dir.path().join("notes/race.md")).unwrap();
2411 assert!(
2412 final_content.contains("touched_by_t1"),
2413 "t1's edit lost; concurrent writer race: {}",
2414 final_content
2415 );
2416 assert!(
2417 final_content.contains("touched_by_t2"),
2418 "t2's edit lost; concurrent writer race: {}",
2419 final_content
2420 );
2421 }
2422
2423 #[test]
2424 fn atomic_write_does_not_leave_partial_files_on_failed_writes() {
2425 use std::fs;
2431 use tempfile::TempDir;
2432
2433 let dir = TempDir::new().unwrap();
2434 let target = dir.path().join("subdir/that-does-not-exist/x.md");
2435 let result = crate::writer::atomic_write(&target, "new content");
2438 assert!(
2439 result.is_err(),
2440 "expected atomic_write to fail when parent dir doesn't exist"
2441 );
2442
2443 let real_dir = dir.path().join("real");
2446 fs::create_dir(&real_dir).unwrap();
2447 let real_target = real_dir.join("x.md");
2448 fs::write(&real_target, "original").unwrap();
2449 crate::writer::atomic_write(&real_target, "replacement").unwrap();
2450 let after = fs::read_to_string(&real_target).unwrap();
2451 assert_eq!(after, "replacement");
2452
2453 let leftovers: Vec<_> = fs::read_dir(&real_dir)
2455 .unwrap()
2456 .flatten()
2457 .filter(|e| e.file_name().to_string_lossy().starts_with(".tmp"))
2458 .collect();
2459 assert!(
2460 leftovers.is_empty(),
2461 "expected no tempfile leftovers, found: {:?}",
2462 leftovers.iter().map(|e| e.path()).collect::<Vec<_>>()
2463 );
2464 }
2465
2466 use crate::schema::{CollectionSchema, FieldSchema};
2469
2470 fn movie_schema() -> CollectionSchema {
2471 let mut fields = std::collections::BTreeMap::new();
2472 fields.insert(
2473 "db-table".into(),
2474 FieldSchema {
2475 field_type: "string".into(),
2476 enum_values: vec![Value::String("movie".into())],
2477 min: None,
2478 max: None,
2479 default: Some(Value::String("movie".into())),
2480 default_expr: None,
2481 },
2482 );
2483 fields.insert(
2484 "status".into(),
2485 FieldSchema {
2486 field_type: "string".into(),
2487 enum_values: vec![
2488 Value::String("to-watch".into()),
2489 Value::String("watched".into()),
2490 ],
2491 min: None,
2492 max: None,
2493 default: Some(Value::String("to-watch".into())),
2494 default_expr: None,
2495 },
2496 );
2497 fields.insert(
2498 "director".into(),
2499 FieldSchema {
2500 field_type: "string".into(),
2501 enum_values: vec![],
2502 min: None,
2503 max: None,
2504 default: None,
2505 default_expr: None,
2506 },
2507 );
2508 fields.insert(
2509 "year".into(),
2510 FieldSchema {
2511 field_type: "integer".into(),
2512 enum_values: vec![],
2513 min: None,
2514 max: None,
2515 default: None,
2516 default_expr: None,
2517 },
2518 );
2519 CollectionSchema {
2520 description: None,
2521 folder: "Notes/movie".into(),
2522 filter: vec![],
2523 required: vec![
2524 "db-table".into(),
2525 "director".into(),
2526 "status".into(),
2527 "year".into(),
2528 ],
2529 fields,
2530 }
2531 }
2532
2533 fn vault_with_obsidian() -> tempfile::TempDir {
2534 let dir = tempfile::TempDir::new().unwrap();
2535 std::fs::create_dir(dir.path().join(".obsidian")).unwrap();
2536 dir
2537 }
2538
2539 #[test]
2540 fn create_without_schema_writes_minimal_file() {
2541 let dir = vault_with_obsidian();
2542 let vault = Vault::with_root(dir.path().to_path_buf());
2543 let report = CreateBuilder::new("Notes/movie", "Dune")
2544 .execute(&vault)
2545 .unwrap();
2546 assert_eq!(report.errors.len(), 0);
2547 assert_eq!(report.changes.len(), 1);
2548 let written = dir.path().join("Notes/movie/Dune.md");
2549 assert!(written.is_file());
2550 let content = std::fs::read_to_string(&written).unwrap();
2551 assert!(content.contains("---\n---"));
2553 assert!(content.contains("# Dune"));
2554 }
2555
2556 #[test]
2557 fn create_with_set_writes_typed_frontmatter() {
2558 let dir = vault_with_obsidian();
2559 let vault = Vault::with_root(dir.path().to_path_buf());
2560 CreateBuilder::new("Notes/movie", "Dune")
2561 .set("director", Value::String("Denis Villeneuve".into()))
2562 .set("year", Value::Integer(2021))
2563 .execute(&vault)
2564 .unwrap();
2565 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2566 assert!(content.contains("director: Denis Villeneuve"));
2567 assert!(content.contains("year: 2021"));
2569 }
2570
2571 #[test]
2572 fn create_fills_schema_defaults() {
2573 let dir = vault_with_obsidian();
2574 let vault = Vault::with_root(dir.path().to_path_buf());
2575 CreateBuilder::new("Notes/movie", "Dune")
2576 .with_schema(movie_schema())
2577 .set("director", Value::String("Denis Villeneuve".into()))
2578 .set("year", Value::Integer(2021))
2579 .execute(&vault)
2580 .unwrap();
2581 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2582 assert!(content.contains("db-table: movie"));
2584 assert!(content.contains("status: to-watch"));
2585 assert!(content.contains("director: Denis Villeneuve"));
2587 assert!(content.contains("year: 2021"));
2588 }
2589
2590 #[test]
2591 fn create_set_overrides_default() {
2592 let dir = vault_with_obsidian();
2593 let vault = Vault::with_root(dir.path().to_path_buf());
2594 CreateBuilder::new("Notes/movie", "Watched")
2595 .with_schema(movie_schema())
2596 .set("director", Value::String("X".into()))
2597 .set("year", Value::Integer(2020))
2598 .set("status", Value::String("watched".into()))
2599 .execute(&vault)
2600 .unwrap();
2601 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Watched.md")).unwrap();
2602 assert!(content.contains("status: watched"));
2603 assert!(!content.contains("status: to-watch"));
2604 }
2605
2606 #[test]
2607 fn create_rejects_missing_required_before_writing() {
2608 let dir = vault_with_obsidian();
2609 let vault = Vault::with_root(dir.path().to_path_buf());
2610 let report = CreateBuilder::new("Notes/movie", "Blank")
2612 .with_schema(movie_schema())
2613 .execute(&vault)
2614 .unwrap();
2615 assert!(!report.errors.is_empty());
2616 assert!(report.errors.iter().any(|e| e.message.contains("director")));
2617 assert!(report.errors.iter().any(|e| e.message.contains("year")));
2618 assert!(!dir.path().join("Notes/movie/Blank.md").exists());
2620 }
2621
2622 #[test]
2623 fn create_rejects_existing_file() {
2624 let dir = vault_with_obsidian();
2625 std::fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2626 std::fs::write(dir.path().join("Notes/movie/Dune.md"), "existing\n").unwrap();
2627 let vault = Vault::with_root(dir.path().to_path_buf());
2628 let report = CreateBuilder::new("Notes/movie", "Dune")
2629 .execute(&vault)
2630 .unwrap();
2631 assert_eq!(report.errors.len(), 1);
2632 assert!(report.errors[0].message.contains("already exists"));
2633 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2635 assert_eq!(content, "existing\n");
2636 }
2637
2638 #[test]
2639 fn create_resolves_default_expr_today() {
2640 let dir = vault_with_obsidian();
2641 let vault = Vault::with_root(dir.path().to_path_buf());
2642 let mut fields = std::collections::BTreeMap::new();
2643 fields.insert(
2644 "due".into(),
2645 FieldSchema {
2646 field_type: "date".into(),
2647 enum_values: vec![],
2648 min: None,
2649 max: None,
2650 default: None,
2651 default_expr: Some("today".into()),
2652 },
2653 );
2654 let schema = CollectionSchema {
2655 description: None,
2656 folder: "tasks".into(),
2657 filter: vec![],
2658 required: vec![],
2659 fields,
2660 };
2661 CreateBuilder::new("tasks", "t1")
2662 .with_schema(schema)
2663 .execute(&vault)
2664 .unwrap();
2665 let content = std::fs::read_to_string(dir.path().join("tasks/t1.md")).unwrap();
2666 let today = crate::record::today_string();
2668 assert!(
2669 content.contains(&format!("due: {}", today)),
2670 "expected due={} in: {}",
2671 today,
2672 content
2673 );
2674 }
2675
2676 #[test]
2677 fn create_plan_does_not_touch_disk() {
2678 let dir = vault_with_obsidian();
2679 let vault = Vault::with_root(dir.path().to_path_buf());
2680 let (report, content) = CreateBuilder::new("Notes/movie", "Dune")
2681 .with_schema(movie_schema())
2682 .set("director", Value::String("DV".into()))
2683 .set("year", Value::Integer(2021))
2684 .plan_with_content(&vault)
2685 .unwrap();
2686 assert_eq!(report.errors.len(), 0);
2687 assert_eq!(report.changes.len(), 1);
2688 assert!(!dir.path().join("Notes/movie/Dune.md").exists());
2689 let c = content.unwrap();
2690 assert!(c.contains("director: DV"));
2691 assert!(c.contains("db-table: movie")); }
2693
2694 #[test]
2695 fn create_with_explicit_body_overrides_default() {
2696 let dir = vault_with_obsidian();
2699 let vault = Vault::with_root(dir.path().to_path_buf());
2700 CreateBuilder::new("notes", "today")
2701 .body("- [ ] Wake up\n- [ ] Write code\n")
2702 .execute(&vault)
2703 .unwrap();
2704 let content = std::fs::read_to_string(dir.path().join("notes/today.md")).unwrap();
2705 assert!(content.contains("- [ ] Wake up"));
2706 assert!(!content.contains("# today"));
2708 }
2709
2710 #[test]
2711 fn create_with_explicit_body_overrides_template_body() {
2712 let dir = vault_with_obsidian();
2715 std::fs::create_dir_all(dir.path().join("templates")).unwrap();
2716 std::fs::write(
2717 dir.path().join("templates/note.md"),
2718 "---\nstatus: open\n---\n\n# Template body\n",
2719 )
2720 .unwrap();
2721 let vault = Vault::with_root(dir.path().to_path_buf());
2722 CreateBuilder::new("notes", "n")
2723 .template("templates/note.md")
2724 .body("Custom body.\n")
2725 .execute(&vault)
2726 .unwrap();
2727 let content = std::fs::read_to_string(dir.path().join("notes/n.md")).unwrap();
2728 assert!(content.contains("status: open")); assert!(content.contains("Custom body."));
2730 assert!(!content.contains("Template body"));
2731 }
2732
2733 #[test]
2734 fn create_from_template_preserves_body_and_merges_frontmatter() {
2735 let dir = vault_with_obsidian();
2736 std::fs::create_dir_all(dir.path().join("templates")).unwrap();
2737 std::fs::write(
2738 dir.path().join("templates/movie.md"),
2739 "---\nstatus: to-watch\naliases: []\n---\n\n# Title\n\nReview goes here.\n",
2740 )
2741 .unwrap();
2742 let vault = Vault::with_root(dir.path().to_path_buf());
2743 CreateBuilder::new("Notes/movie", "Dune")
2744 .template("templates/movie.md")
2745 .set("year", Value::Integer(2021))
2746 .execute(&vault)
2747 .unwrap();
2748 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2749 assert!(content.contains("status: to-watch"));
2751 assert!(content.contains("year: 2021"));
2753 assert!(content.contains("Review goes here"));
2755 }
2756
2757 use crate::schema::VaultSchema;
2766
2767 fn vault_schema_movies() -> VaultSchema {
2768 let mut vs = VaultSchema {
2771 collections: std::collections::BTreeMap::new(),
2772 };
2773 vs.collections.insert("movies".into(), movie_schema());
2774 vs
2775 }
2776
2777 fn vault_schema_catchall_and_movies() -> VaultSchema {
2778 let mut collections = std::collections::BTreeMap::new();
2782
2783 let mut catchall_fields = std::collections::BTreeMap::new();
2784 catchall_fields.insert(
2785 "db-table".into(),
2786 FieldSchema {
2787 field_type: "string".into(),
2788 enum_values: vec![Value::String("movie".into()), Value::String("book".into())],
2789 min: None,
2790 max: None,
2791 default: None,
2792 default_expr: None,
2793 },
2794 );
2795 collections.insert(
2796 "Notes".into(),
2797 CollectionSchema {
2798 description: None,
2799 folder: "Notes".into(),
2800 filter: vec![],
2801 required: vec!["db-table".into()],
2802 fields: catchall_fields,
2803 },
2804 );
2805 collections.insert("movies".into(), {
2806 let mut m = movie_schema();
2807 m.filter = vec!["db-table = movie".into()];
2808 m
2809 });
2810
2811 VaultSchema { collections }
2812 }
2813
2814 #[test]
2815 fn update_rejects_type_mismatch() {
2816 use std::fs;
2817 let dir = vault_with_obsidian();
2818 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2819 fs::write(
2820 dir.path().join("Notes/movie/Dune.md"),
2821 "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\nBody\n",
2822 )
2823 .unwrap();
2824 let vault = Vault::with_root(dir.path().to_path_buf());
2825
2826 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2827 field: "director".into(),
2828 value: Value::String("DV".into()),
2829 });
2830 let report = UpdateBuilder::new("Notes/movie", filter)
2831 .set("year", Value::String("nope".into()))
2832 .with_vault_schema(vault_schema_movies())
2833 .execute(&vault)
2834 .unwrap();
2835
2836 assert!(report.changes.is_empty(), "no write should be reported");
2837 assert!(
2838 report
2839 .errors
2840 .iter()
2841 .any(|e| e.message.contains("year") && e.message.contains("integer")),
2842 "expected year/integer type-mismatch error, got: {:?}",
2843 report.errors
2844 );
2845 let content = fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2847 assert!(content.contains("year: 2021"));
2848 assert!(!content.contains("year: nope"));
2849 }
2850
2851 #[test]
2852 fn update_rejects_enum_violation() {
2853 use std::fs;
2854 let dir = vault_with_obsidian();
2855 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2856 fs::write(
2857 dir.path().join("Notes/movie/Dune.md"),
2858 "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\n",
2859 )
2860 .unwrap();
2861 let vault = Vault::with_root(dir.path().to_path_buf());
2862
2863 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2864 field: "director".into(),
2865 value: Value::String("DV".into()),
2866 });
2867 let report = UpdateBuilder::new("Notes/movie", filter)
2868 .set("status", Value::String("watching".into()))
2869 .with_vault_schema(vault_schema_movies())
2870 .execute(&vault)
2871 .unwrap();
2872
2873 assert!(report.changes.is_empty());
2874 assert!(
2875 report
2876 .errors
2877 .iter()
2878 .any(|e| e.message.contains("status") && e.message.contains("watching")),
2879 "expected status enum violation, got: {:?}",
2880 report.errors
2881 );
2882 }
2883
2884 #[test]
2885 fn update_passes_when_unconstrained_field_changes() {
2886 use std::fs;
2890 let dir = vault_with_obsidian();
2891 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2892 fs::write(
2893 dir.path().join("Notes/movie/Dune.md"),
2894 "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\n",
2895 )
2896 .unwrap();
2897 let vault = Vault::with_root(dir.path().to_path_buf());
2898
2899 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2900 field: "director".into(),
2901 value: Value::String("DV".into()),
2902 });
2903 let report = UpdateBuilder::new("Notes/movie", filter)
2904 .set("notes-to-self", Value::String("rewatch".into()))
2905 .with_vault_schema(vault_schema_movies())
2906 .execute(&vault)
2907 .unwrap();
2908 assert!(
2909 report.errors.is_empty(),
2910 "no errors expected, got: {:?}",
2911 report.errors
2912 );
2913 assert_eq!(report.changes.len(), 1);
2914 let content = fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2915 assert!(content.contains("notes-to-self: rewatch"));
2916 }
2917
2918 #[test]
2919 fn update_surfaces_preexisting_violation() {
2920 use std::fs;
2925 let dir = vault_with_obsidian();
2926 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2927 fs::write(
2928 dir.path().join("Notes/movie/Old.md"),
2929 "---\ndb-table: movie\nstatus: to-watch\nyear: 2021\n---\n",
2931 )
2932 .unwrap();
2933 let vault = Vault::with_root(dir.path().to_path_buf());
2934
2935 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2936 field: "db-table".into(),
2937 value: Value::String("movie".into()),
2938 });
2939 let report = UpdateBuilder::new("Notes/movie", filter)
2940 .set("year", Value::Integer(2022))
2941 .with_vault_schema(vault_schema_movies())
2942 .execute(&vault)
2943 .unwrap();
2944 assert!(
2945 report.errors.iter().any(|e| e.message.contains("director")),
2946 "expected pre-existing required-field violation, got: {:?}",
2947 report.errors
2948 );
2949 let content = fs::read_to_string(dir.path().join("Notes/movie/Old.md")).unwrap();
2951 assert!(content.contains("year: 2021"));
2952 }
2953
2954 #[test]
2955 fn update_skips_one_blocks_one_in_batch() {
2956 use std::fs;
2963 let dir = vault_with_obsidian();
2964 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2965 fs::write(
2966 dir.path().join("Notes/movie/Good.md"),
2967 "---\ndb-table: movie\ndirector: A\nstatus: to-watch\nyear: 2021\n---\n",
2968 )
2969 .unwrap();
2970 fs::write(
2971 dir.path().join("Notes/movie/Bad.md"),
2972 "---\ndb-table: movie\nstatus: to-watch\nyear: 2021\n---\n",
2974 )
2975 .unwrap();
2976 let vault = Vault::with_root(dir.path().to_path_buf());
2977
2978 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2979 field: "db-table".into(),
2980 value: Value::String("movie".into()),
2981 });
2982 let report = UpdateBuilder::new("Notes/movie", filter)
2983 .set("year", Value::Integer(2022))
2984 .with_vault_schema(vault_schema_movies())
2985 .execute(&vault)
2986 .unwrap();
2987 assert_eq!(report.changes.len(), 1, "exactly one record should write");
2988 assert!(report.changes[0].path.ends_with("Good.md"));
2989 assert!(report.errors.iter().any(|e| e.path.ends_with("Bad.md")));
2990 assert!(
2992 fs::read_to_string(dir.path().join("Notes/movie/Good.md"))
2993 .unwrap()
2994 .contains("year: 2022")
2995 );
2996 assert!(
2997 fs::read_to_string(dir.path().join("Notes/movie/Bad.md"))
2998 .unwrap()
2999 .contains("year: 2021")
3000 );
3001 }
3002
3003 #[test]
3004 fn update_validates_against_catchall_and_subfolder() {
3005 use std::fs;
3011 let dir = vault_with_obsidian();
3012 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
3013 fs::write(
3014 dir.path().join("Notes/movie/Dune.md"),
3015 "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\n",
3016 )
3017 .unwrap();
3018 let vault = Vault::with_root(dir.path().to_path_buf());
3019
3020 let filter = Expr::Predicate(crate::query::Predicate::Equals {
3021 field: "director".into(),
3022 value: Value::String("DV".into()),
3023 });
3024 let report = UpdateBuilder::new("Notes/movie", filter)
3025 .unset("db-table")
3026 .with_vault_schema(vault_schema_catchall_and_movies())
3027 .execute(&vault)
3028 .unwrap();
3029
3030 assert!(report.changes.is_empty());
3035 assert!(
3036 report.errors.iter().any(|e| e.message.contains("db-table")),
3037 "expected db-table missing error from catch-all, got: {:?}",
3038 report.errors
3039 );
3040 }
3041
3042 #[test]
3043 fn create_rejects_type_mismatch() {
3044 let dir = vault_with_obsidian();
3045 let vault = Vault::with_root(dir.path().to_path_buf());
3046 let report = CreateBuilder::new("Notes/movie", "Dune")
3047 .with_vault_schema(vault_schema_movies())
3048 .set("director", Value::String("DV".into()))
3049 .set("year", Value::String("not-a-year".into()))
3050 .execute(&vault)
3051 .unwrap();
3052 assert!(
3053 report
3054 .errors
3055 .iter()
3056 .any(|e| e.message.contains("year") && e.message.contains("integer")),
3057 "expected year/integer type error, got: {:?}",
3058 report.errors
3059 );
3060 assert!(!dir.path().join("Notes/movie/Dune.md").exists());
3061 }
3062
3063 #[test]
3064 fn create_validates_against_multiple_applicable_collections() {
3065 let dir = vault_with_obsidian();
3070 let vault = Vault::with_root(dir.path().to_path_buf());
3071 let report = CreateBuilder::new("Notes/movie", "X")
3072 .with_vault_schema(vault_schema_catchall_and_movies())
3073 .set("db-table", Value::String("movie".into()))
3074 .execute(&vault)
3075 .unwrap();
3076 assert!(report.errors.iter().any(|e| e.message.contains("director")));
3077 assert!(report.errors.iter().any(|e| e.message.contains("year")));
3078 assert!(!dir.path().join("Notes/movie/X.md").exists());
3079 }
3080}