1use std::path::PathBuf;
14
15use crate::error::{Result, VaultdbError};
16use crate::query::Expr;
17use crate::record::Value;
18use crate::vault::Vault;
19use crate::writer::{self, WriteResult};
20
21#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23pub struct MutationReport {
24 pub changes: Vec<PlannedChange>,
25 pub errors: Vec<MutationError>,
26}
27
28#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
30pub struct PlannedChange {
31 pub path: PathBuf,
32 pub description: String,
33}
34
35#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
37pub struct MutationError {
38 pub path: PathBuf,
39 pub message: String,
40}
41
42#[derive(Debug, Clone)]
48pub struct UpdateBuilder {
49 filter: Expr,
50 folder: String,
51 set_fields: Vec<(String, Value)>,
52 unset_fields: Vec<String>,
53 add_tags: Vec<String>,
54 remove_tags: Vec<String>,
55 vault_schema: Option<crate::schema::VaultSchema>,
56 write_options: writer::WriteOptions,
57}
58
59impl UpdateBuilder {
60 pub fn new(folder: impl Into<String>, filter: Expr) -> Self {
61 Self {
62 filter,
63 folder: folder.into(),
64 set_fields: Vec::new(),
65 unset_fields: Vec::new(),
66 add_tags: Vec::new(),
67 remove_tags: Vec::new(),
68 vault_schema: None,
69 write_options: writer::WriteOptions::default(),
70 }
71 }
72
73 pub fn set(mut self, field: impl Into<String>, value: Value) -> Self {
74 self.set_fields.push((field.into(), value));
75 self
76 }
77
78 pub fn unset(mut self, field: impl Into<String>) -> Self {
79 self.unset_fields.push(field.into());
80 self
81 }
82
83 pub fn add_tag(mut self, tag: impl Into<String>) -> Self {
84 self.add_tags.push(tag.into());
85 self
86 }
87
88 pub fn remove_tag(mut self, tag: impl Into<String>) -> Self {
89 self.remove_tags.push(tag.into());
90 self
91 }
92
93 pub fn with_vault_schema(mut self, schema: crate::schema::VaultSchema) -> Self {
102 self.vault_schema = Some(schema);
103 self
104 }
105
106 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
109 self.write_options = opts;
110 self
111 }
112
113 pub fn fsync(mut self, yes: bool) -> Self {
116 self.write_options.fsync = yes;
117 self
118 }
119
120 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
122 let (report, _writes) = self.compute(vault)?;
123 Ok(report)
124 }
125
126 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
134 crate::lock::with_lock(&vault.root, || {
135 let (report, writes) = self.compute(vault)?;
136 for w in &writes {
137 writer::apply_with(w, self.write_options).map_err(VaultdbError::Io)?;
138 }
139 Ok(report)
140 })
141 }
142
143 fn compute(&self, vault: &Vault) -> Result<(MutationReport, Vec<WriteResult>)> {
144 let folder_path = vault.resolve_folder(&self.folder)?;
145 let load = vault.load_records_with_content(&folder_path, false, false)?;
146 let needs_links = crate::filter::expr_uses_links(&self.filter);
147 let link_index = if needs_links {
148 Some(crate::links::LinkGraph::build_with_root(
149 &load.records,
150 Some(&vault.root),
151 ))
152 } else {
153 None
154 };
155
156 let mut changes = Vec::new();
157 let mut errors = Vec::new();
158 let mut writes = Vec::new();
159
160 for record in &load.records {
161 if !crate::filter::evaluate_expr(&self.filter, record, &vault.root, link_index.as_ref())
162 {
163 continue;
164 }
165
166 if let Some(vault_schema) = &self.vault_schema {
173 let projected_fields = self.project_fields(&record.fields);
174 let projected_record = crate::record::Record {
175 path: record.path.clone(),
176 fields: projected_fields.clone(),
177 raw_content: record.raw_content.clone(),
178 };
179 let applicable = match vault_schema.applicable_collections(
180 &self.folder,
181 &projected_record,
182 &vault.root,
183 ) {
184 Ok(cols) => cols,
185 Err(e) => {
186 errors.push(MutationError {
187 path: record.path.clone(),
188 message: format!("evaluating schema applicability: {}", e),
189 });
190 continue;
191 }
192 };
193 let mut seen = std::collections::BTreeSet::<(String, String)>::new();
194 let mut had_violation = false;
195 let filename = record.virtual_name();
196 for col in &applicable {
197 for v in crate::schema::validate_record(&filename, &projected_fields, col) {
198 if seen.insert((v.field.clone(), v.message.clone())) {
199 errors.push(MutationError {
200 path: record.path.clone(),
201 message: format!("schema: {} — {}", v.field, v.message),
202 });
203 had_violation = true;
204 }
205 }
206 }
207 if had_violation {
208 continue;
209 }
210 }
211
212 let mut content = record
217 .raw_content
218 .as_ref()
219 .ok_or_else(|| {
220 VaultdbError::Internal(format!(
221 "record at {} has no raw_content; UpdateBuilder loaded without content",
222 record.path.display()
223 ))
224 })?
225 .clone();
226 let original_content = content.clone();
227 let mut wr_changes = Vec::new();
228 let mut description_parts: Vec<String> = Vec::new();
229
230 let result: Result<()> = (|| {
231 for (field, value) in &self.set_fields {
232 let (new_content, change) = match value {
242 Value::List(_) | Value::Map(_) => {
243 writer::set_field_block(&content, field, value)?
244 }
245 _ => {
246 let value_str = render_value_for_yaml(value);
247 writer::set_field_preformatted(&content, field, &value_str)?
248 }
249 };
250 description_parts.push(format!("{}", change));
251 wr_changes.push(change);
252 content = new_content;
253 }
254 for field in &self.unset_fields {
255 let (new_content, change) = writer::unset_field(&content, field)?;
256 description_parts.push(format!("{}", change));
257 wr_changes.push(change);
258 content = new_content;
259 }
260 for tag in &self.add_tags {
261 let (new_content, change) = writer::add_tag(&content, tag)?;
262 description_parts.push(format!("{}", change));
263 wr_changes.push(change);
264 content = new_content;
265 }
266 for tag in &self.remove_tags {
267 let (new_content, change) = writer::remove_tag(&content, tag)?;
268 description_parts.push(format!("{}", change));
269 wr_changes.push(change);
270 content = new_content;
271 }
272 Ok(())
273 })();
274
275 match result {
276 Ok(_) => {
277 if !wr_changes.is_empty() {
278 writes.push(WriteResult {
279 path: record.path.clone(),
280 original_content,
281 modified_content: content,
282 changes: wr_changes,
283 });
284 changes.push(PlannedChange {
285 path: record.path.clone(),
286 description: description_parts.join("; "),
287 });
288 }
289 }
290 Err(e) => errors.push(MutationError {
291 path: record.path.clone(),
292 message: e.to_string(),
293 }),
294 }
295 }
296
297 Ok((MutationReport { changes, errors }, writes))
298 }
299
300 fn project_fields(
308 &self,
309 original: &std::collections::BTreeMap<String, Value>,
310 ) -> std::collections::BTreeMap<String, Value> {
311 let mut fields = original.clone();
312 for (k, v) in &self.set_fields {
313 fields.insert(k.clone(), v.clone());
314 }
315 for k in &self.unset_fields {
316 fields.remove(k);
317 }
318 if !self.add_tags.is_empty() || !self.remove_tags.is_empty() {
319 let mut tags_list: Vec<Value> = match fields.get("tags") {
320 Some(Value::List(l)) => l.clone(),
321 _ => Vec::new(),
322 };
323 for t in &self.add_tags {
324 tags_list.push(Value::String(t.clone()));
325 }
326 for t in &self.remove_tags {
327 if let Some(idx) = tags_list
328 .iter()
329 .position(|v| matches!(v, Value::String(s) if s == t))
330 {
331 tags_list.remove(idx);
332 }
333 }
334 fields.insert("tags".to_string(), Value::List(tags_list));
335 }
336 fields
337 }
338}
339
340#[derive(Debug, Clone)]
346pub struct DeleteBuilder {
347 filter: Expr,
348 folder: String,
349 permanent: bool,
350 write_options: writer::WriteOptions,
351}
352
353impl DeleteBuilder {
354 pub fn new(folder: impl Into<String>, filter: Expr) -> Self {
355 Self {
356 filter,
357 folder: folder.into(),
358 permanent: false,
359 write_options: writer::WriteOptions::default(),
360 }
361 }
362
363 pub fn permanent(mut self, yes: bool) -> Self {
364 self.permanent = yes;
365 self
366 }
367
368 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
370 self.write_options = opts;
371 self
372 }
373
374 pub fn fsync(mut self, yes: bool) -> Self {
377 self.write_options.fsync = yes;
378 self
379 }
380
381 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
382 let folder_path = vault.resolve_folder(&self.folder)?;
383 let load = vault.load_records(&folder_path, false, false)?;
384 let needs_links = crate::filter::expr_uses_links(&self.filter);
385 let link_index = if needs_links {
386 Some(crate::links::LinkGraph::build_with_root(
387 &load.records,
388 Some(&vault.root),
389 ))
390 } else {
391 None
392 };
393
394 let mut changes = Vec::new();
395 for r in &load.records {
396 if !crate::filter::evaluate_expr(&self.filter, r, &vault.root, link_index.as_ref()) {
397 continue;
398 }
399 changes.push(PlannedChange {
400 path: r.path.clone(),
401 description: if self.permanent {
402 "delete (permanent)".to_string()
403 } else {
404 "move to .trash/".to_string()
405 },
406 });
407 }
408 Ok(MutationReport {
409 changes,
410 errors: Vec::new(),
411 })
412 }
413
414 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
415 crate::lock::with_lock(&vault.root, || {
416 let report = self.plan(vault)?;
417 let mut errors = Vec::new();
418 let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
420 std::collections::BTreeSet::new();
421
422 if self.permanent {
423 for change in &report.changes {
424 if let Err(e) = std::fs::remove_file(&change.path) {
425 errors.push(MutationError {
426 path: change.path.clone(),
427 message: format!("remove failed: {}", e),
428 });
429 } else if let Some(parent) = change.path.parent() {
430 dirs_to_fsync.insert(parent.to_path_buf());
431 }
432 }
433 } else {
434 let trash_dir = vault.root.join(".trash");
435 if !report.changes.is_empty() {
436 std::fs::create_dir_all(&trash_dir).map_err(VaultdbError::Io)?;
437 }
438 for change in &report.changes {
439 let dest = unique_in_dir(&trash_dir, &change.path);
440 if let Err(e) = std::fs::rename(&change.path, &dest) {
441 errors.push(MutationError {
442 path: change.path.clone(),
443 message: format!("trash failed: {}", e),
444 });
445 } else {
446 if let Some(parent) = change.path.parent() {
447 dirs_to_fsync.insert(parent.to_path_buf());
448 }
449 dirs_to_fsync.insert(trash_dir.clone());
450 }
451 }
452 }
453
454 if self.write_options.fsync {
457 for d in &dirs_to_fsync {
458 if let Err(e) = writer::fsync_dir(d) {
459 errors.push(MutationError {
460 path: d.clone(),
461 message: format!("fsync_dir failed: {}", e),
462 });
463 }
464 }
465 }
466
467 Ok(MutationReport {
468 changes: report.changes,
469 errors,
470 })
471 })
472 }
473}
474
475fn unique_in_dir(dir: &std::path::Path, src: &std::path::Path) -> PathBuf {
476 let filename = src.file_name().and_then(|n| n.to_str()).unwrap_or("file");
477 let candidate = dir.join(filename);
478 if !candidate.exists() {
479 return candidate;
480 }
481 let stem = src.file_stem().and_then(|n| n.to_str()).unwrap_or("file");
482 let ext = src.extension().and_then(|n| n.to_str()).unwrap_or("md");
483 let mut i = 1;
484 loop {
485 let c = dir.join(format!("{}-{}.{}", stem, i, ext));
486 if !c.exists() {
487 return c;
488 }
489 i += 1;
490 }
491}
492
493#[derive(Debug, Clone)]
499pub struct MoveBuilder {
500 filter: Expr,
501 folder: String,
502 to_folder: String,
503 write_options: writer::WriteOptions,
504}
505
506impl MoveBuilder {
507 pub fn new(folder: impl Into<String>, to_folder: impl Into<String>, filter: Expr) -> Self {
508 Self {
509 filter,
510 folder: folder.into(),
511 to_folder: to_folder.into(),
512 write_options: writer::WriteOptions::default(),
513 }
514 }
515
516 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
518 self.write_options = opts;
519 self
520 }
521
522 pub fn fsync(mut self, yes: bool) -> Self {
524 self.write_options.fsync = yes;
525 self
526 }
527
528 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
529 let folder_path = vault.resolve_folder(&self.folder)?;
530 let to_path = vault.root.join(&self.to_folder);
531 let load = vault.load_records(&folder_path, false, false)?;
532 let needs_links = crate::filter::expr_uses_links(&self.filter);
533 let link_index = if needs_links {
534 Some(crate::links::LinkGraph::build_with_root(
535 &load.records,
536 Some(&vault.root),
537 ))
538 } else {
539 None
540 };
541
542 let mut changes = Vec::new();
543 let mut errors = Vec::new();
544
545 for r in &load.records {
546 if !crate::filter::evaluate_expr(&self.filter, r, &vault.root, link_index.as_ref()) {
547 continue;
548 }
549 let filename = match r.path.file_name() {
550 Some(n) => n,
551 None => continue,
552 };
553 let dest = to_path.join(filename);
554 if dest.exists() {
555 errors.push(MutationError {
556 path: r.path.clone(),
557 message: format!(
558 "move conflict: {} already exists in {}",
559 filename.to_string_lossy(),
560 self.to_folder
561 ),
562 });
563 continue;
564 }
565 changes.push(PlannedChange {
566 path: r.path.clone(),
567 description: format!("move to {}", dest.display()),
568 });
569 }
570 Ok(MutationReport { changes, errors })
571 }
572
573 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
574 crate::lock::with_lock(&vault.root, || {
575 let to_path = vault.root.join(&self.to_folder);
576 let report = self.plan(vault)?;
577 if !report.changes.is_empty() {
578 std::fs::create_dir_all(&to_path).map_err(VaultdbError::Io)?;
579 }
580 let mut errors = report.errors;
581 let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
582 std::collections::BTreeSet::new();
583 for change in &report.changes {
584 let filename = match change.path.file_name() {
585 Some(n) => n,
586 None => continue,
587 };
588 let dest = to_path.join(filename);
589 if let Err(e) = std::fs::rename(&change.path, &dest) {
590 errors.push(MutationError {
591 path: change.path.clone(),
592 message: format!("rename failed: {}", e),
593 });
594 } else {
595 if let Some(parent) = change.path.parent() {
596 dirs_to_fsync.insert(parent.to_path_buf());
597 }
598 dirs_to_fsync.insert(to_path.clone());
599 }
600 }
601
602 if self.write_options.fsync {
603 for d in &dirs_to_fsync {
604 if let Err(e) = writer::fsync_dir(d) {
605 errors.push(MutationError {
606 path: d.clone(),
607 message: format!("fsync_dir failed: {}", e),
608 });
609 }
610 }
611 }
612
613 Ok(MutationReport {
614 changes: report.changes,
615 errors,
616 })
617 })
618 }
619}
620
621#[derive(Debug, Clone)]
630pub struct RenameBuilder {
631 folder: String,
632 from: String,
633 to: String,
634 write_options: writer::WriteOptions,
635}
636
637impl RenameBuilder {
638 pub fn new(folder: impl Into<String>, from: impl Into<String>, to: impl Into<String>) -> Self {
639 Self {
640 folder: folder.into(),
641 from: from.into(),
642 to: to.into(),
643 write_options: writer::WriteOptions::default(),
644 }
645 }
646
647 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
649 self.write_options = opts;
650 self
651 }
652
653 pub fn fsync(mut self, yes: bool) -> Self {
656 self.write_options.fsync = yes;
657 self
658 }
659
660 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
661 let folder_path = vault.resolve_folder(&self.folder)?;
662 let source = folder_path.join(format!("{}.md", self.from));
663 let dest = folder_path.join(format!("{}.md", self.to));
664
665 let mut changes = Vec::new();
666 let mut errors = Vec::new();
667
668 if !source.is_file() {
669 errors.push(MutationError {
670 path: source.clone(),
671 message: format!("source `{}` not found", self.from),
672 });
673 return Ok(MutationReport { changes, errors });
674 }
675 if dest.exists() {
676 errors.push(MutationError {
677 path: dest.clone(),
678 message: format!("target `{}.md` already exists", self.to),
679 });
680 return Ok(MutationReport { changes, errors });
681 }
682
683 changes.push(PlannedChange {
684 path: source.clone(),
685 description: format!("rename to {}", dest.display()),
686 });
687
688 let all = vault.load_records_with_content(&vault.root, true, false)?;
691 let graph = crate::links::LinkGraph::build_with_root(&all.records, Some(&vault.root));
692 for source_name in graph.incoming_links(&self.from) {
693 if let Some(record) = graph.record_by_name(source_name) {
694 changes.push(PlannedChange {
695 path: record.path.clone(),
696 description: format!("rewrite [[{}]] -> [[{}]]", self.from, self.to),
697 });
698 }
699 }
700
701 Ok(MutationReport { changes, errors })
702 }
703
704 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
705 crate::lock::with_lock(&vault.root, || {
706 crate::journal::replay_all(&vault.root)?;
713
714 let folder_path = vault.resolve_folder(&self.folder)?;
715 let source = folder_path.join(format!("{}.md", self.from));
716 let dest = folder_path.join(format!("{}.md", self.to));
717
718 let report = self.plan(vault)?;
719 if !report.errors.is_empty() {
721 return Ok(report);
722 }
723
724 let backlinks: Vec<PathBuf> = report
729 .changes
730 .iter()
731 .skip(1) .map(|c| c.path.clone())
733 .collect();
734 let journal = crate::journal::RenameJournal {
735 source: source.clone(),
736 dest: dest.clone(),
737 from_name: self.from.clone(),
738 to_name: self.to.clone(),
739 backlinks,
740 };
741 let journal_path = crate::journal::write(&vault.root, &journal)?;
742
743 if let Err(e) = std::fs::rename(&source, &dest) {
746 crate::journal::delete(&journal_path);
747 return Ok(MutationReport {
748 changes: report.changes,
749 errors: vec![MutationError {
750 path: source,
751 message: format!("rename failed: {}", e),
752 }],
753 });
754 }
755
756 let mut errors = Vec::new();
760 let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
761 std::collections::BTreeSet::new();
762 if let Some(parent) = source.parent() {
763 dirs_to_fsync.insert(parent.to_path_buf());
764 }
765 if let Some(parent) = dest.parent() {
766 dirs_to_fsync.insert(parent.to_path_buf());
767 }
768 for change in report.changes.iter().skip(1) {
769 let path = &change.path;
770 let content = match std::fs::read_to_string(path) {
771 Ok(c) => c,
772 Err(e) => {
773 errors.push(MutationError {
774 path: path.clone(),
775 message: format!("read failed: {}", e),
776 });
777 continue;
778 }
779 };
780 let new_content = rewrite_wikilinks(&content, &self.from, &self.to);
781 if new_content == content {
782 continue;
783 }
784 if let Err(e) = writer::atomic_write_with(path, &new_content, self.write_options) {
785 errors.push(MutationError {
786 path: path.clone(),
787 message: format!("write failed: {}", e),
788 });
789 }
790 }
791
792 if self.write_options.fsync {
797 for d in &dirs_to_fsync {
798 if let Err(e) = writer::fsync_dir(d) {
799 errors.push(MutationError {
800 path: d.clone(),
801 message: format!("fsync_dir failed: {}", e),
802 });
803 }
804 }
805 }
806
807 if errors.is_empty() {
811 crate::journal::delete(&journal_path);
812 }
813
814 Ok(MutationReport {
815 changes: report.changes,
816 errors,
817 })
818 })
819 }
820}
821
822#[derive(Debug, Clone)]
841pub struct CreateBuilder {
842 folder: String,
843 name: String,
844 template: Option<String>,
845 set_fields: Vec<(String, Value)>,
846 vault_schema: Option<crate::schema::VaultSchema>,
847 write_options: writer::WriteOptions,
848}
849
850impl CreateBuilder {
851 pub fn new(folder: impl Into<String>, name: impl Into<String>) -> Self {
852 Self {
853 folder: folder.into(),
854 name: name.into(),
855 template: None,
856 set_fields: Vec::new(),
857 vault_schema: None,
858 write_options: writer::WriteOptions::default(),
859 }
860 }
861
862 pub fn template(mut self, path: impl Into<String>) -> Self {
866 self.template = Some(path.into());
867 self
868 }
869
870 pub fn set(mut self, field: impl Into<String>, value: Value) -> Self {
873 self.set_fields.push((field.into(), value));
874 self
875 }
876
877 pub fn with_schema(self, schema: crate::schema::CollectionSchema) -> Self {
884 let mut vs = crate::schema::VaultSchema {
885 collections: std::collections::BTreeMap::new(),
886 };
887 vs.collections.insert("__single__".to_string(), schema);
888 self.with_vault_schema(vs)
889 }
890
891 pub fn with_vault_schema(mut self, schema: crate::schema::VaultSchema) -> Self {
898 self.vault_schema = Some(schema);
899 self
900 }
901
902 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
903 self.write_options = opts;
904 self
905 }
906
907 pub fn fsync(mut self, yes: bool) -> Self {
908 self.write_options.fsync = yes;
909 self
910 }
911
912 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
914 let (report, _) = self.compute(vault)?;
915 Ok(report)
916 }
917
918 pub fn plan_with_content(&self, vault: &Vault) -> Result<(MutationReport, Option<String>)> {
922 let (report, write) = self.compute(vault)?;
923 Ok((report, write.map(|w| w.modified_content)))
924 }
925
926 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
930 crate::lock::with_lock(&vault.root, || {
931 let (report, write) = self.compute(vault)?;
932 if !report.errors.is_empty() {
933 return Ok(report);
934 }
935 if let Some(w) = write {
936 if let Some(parent) = w.path.parent()
937 && !parent.exists()
938 {
939 std::fs::create_dir_all(parent).map_err(VaultdbError::Io)?;
940 }
941 writer::atomic_create_with(&w.path, &w.modified_content, self.write_options)
947 .map_err(VaultdbError::Io)?;
948 }
949 Ok(report)
950 })
951 }
952
953 fn compute(&self, vault: &Vault) -> Result<(MutationReport, Option<WriteResult>)> {
954 let folder_path = vault.root.join(&self.folder);
959 let filename = format!("{}.md", self.name);
960 let dest = folder_path.join(&filename);
961
962 let mut changes = Vec::new();
963 let mut errors = Vec::new();
964
965 if dest.exists() {
966 errors.push(MutationError {
967 path: dest.clone(),
968 message: format!("file already exists: {}", dest.display()),
969 });
970 return Ok((MutationReport { changes, errors }, None));
971 }
972
973 let (mut fields, body) = match &self.template {
977 Some(tmpl) => {
978 let tmpl_path = vault.root.join(tmpl);
979 if !tmpl_path.is_file() {
980 errors.push(MutationError {
981 path: tmpl_path.clone(),
982 message: format!("template not found: {}", tmpl_path.display()),
983 });
984 return Ok((MutationReport { changes, errors }, None));
985 }
986 let raw = std::fs::read_to_string(&tmpl_path).map_err(VaultdbError::Io)?;
987 split_template(&raw)
988 }
989 None => (
990 std::collections::BTreeMap::<String, Value>::new(),
991 format!("\n# {}\n", self.name),
992 ),
993 };
994
995 for (k, v) in &self.set_fields {
997 fields.insert(k.clone(), v.clone());
998 }
999
1000 if let Some(vault_schema) = &self.vault_schema {
1007 let projected = crate::record::Record {
1008 path: dest.clone(),
1009 fields: fields.clone(),
1010 raw_content: None,
1011 };
1012 let applicable =
1013 match vault_schema.applicable_collections(&self.folder, &projected, &vault.root) {
1014 Ok(cols) => cols,
1015 Err(e) => {
1016 errors.push(MutationError {
1017 path: dest.clone(),
1018 message: format!("evaluating schema applicability: {}", e),
1019 });
1020 return Ok((MutationReport { changes, errors }, None));
1021 }
1022 };
1023
1024 for col in &applicable {
1028 for (fname, fs) in &col.fields {
1029 if fields.contains_key(fname) {
1030 continue;
1031 }
1032 if let Some(default) = &fs.default {
1033 fields.insert(fname.clone(), default.clone());
1034 } else if let Some(expr) = &fs.default_expr {
1035 match crate::schema::resolve_default_expr(expr) {
1036 Ok(v) => {
1037 fields.insert(fname.clone(), v);
1038 }
1039 Err(e) => {
1040 errors.push(MutationError {
1041 path: dest.clone(),
1042 message: format!(
1043 "resolving default_expr for '{}': {}",
1044 fname, e
1045 ),
1046 });
1047 }
1048 }
1049 }
1050 }
1051 }
1052
1053 let mut seen = std::collections::BTreeSet::<(String, String)>::new();
1058 for col in &applicable {
1059 for v in crate::schema::validate_record(&filename, &fields, col) {
1060 if seen.insert((v.field.clone(), v.message.clone())) {
1061 errors.push(MutationError {
1062 path: dest.clone(),
1063 message: format!("schema: {} — {}", v.field, v.message),
1064 });
1065 }
1066 }
1067 }
1068 }
1069
1070 if !errors.is_empty() {
1071 return Ok((MutationReport { changes, errors }, None));
1072 }
1073
1074 let frontmatter_yaml = if fields.is_empty() {
1078 String::new()
1079 } else {
1080 serde_yaml::to_string(&fields)
1081 .map_err(|e| VaultdbError::SchemaError(format!("rendering frontmatter: {}", e)))?
1082 };
1083 let content = if frontmatter_yaml.is_empty() {
1084 format!("---\n---\n{}", body)
1085 } else {
1086 format!("---\n{}---\n{}", frontmatter_yaml, body)
1087 };
1088
1089 let field_count = fields.len();
1090 let field_summary: String = fields.keys().cloned().collect::<Vec<_>>().join(", ");
1091 let description = if field_count == 0 {
1092 "create (no frontmatter fields)".to_string()
1093 } else {
1094 format!("create with {} field(s): {}", field_count, field_summary)
1095 };
1096
1097 changes.push(PlannedChange {
1098 path: dest.clone(),
1099 description,
1100 });
1101
1102 let write = WriteResult {
1103 path: dest,
1104 original_content: String::new(),
1105 modified_content: content,
1106 changes: Vec::new(),
1107 };
1108
1109 Ok((MutationReport { changes, errors }, Some(write)))
1110 }
1111}
1112
1113fn split_template(raw: &str) -> (std::collections::BTreeMap<String, Value>, String) {
1117 use crate::frontmatter::{extract_frontmatter, parse_frontmatter};
1118 match extract_frontmatter(raw) {
1119 Some((yaml_text, body_start)) => {
1120 let fields = parse_frontmatter(yaml_text).unwrap_or_default();
1121 let body = raw[body_start..].to_string();
1122 (fields, body)
1123 }
1124 None => (std::collections::BTreeMap::new(), raw.to_string()),
1125 }
1126}
1127
1128pub(crate) fn rewrite_wikilinks(content: &str, from: &str, to: &str) -> String {
1131 content
1132 .replace(&format!("[[{}]]", from), &format!("[[{}]]", to))
1133 .replace(&format!("[[{}|", from), &format!("[[{}|", to))
1134 .replace(&format!("[[{}#", from), &format!("[[{}#", to))
1135}
1136
1137fn render_value_for_yaml(v: &Value) -> String {
1143 match v {
1144 Value::Null => "null".to_string(),
1145 Value::Bool(b) => b.to_string(),
1146 Value::Integer(i) => i.to_string(),
1147 Value::Float(f) => f.to_string(),
1148 Value::String(s) => writer::quote_value(s),
1149 Value::List(_) | Value::Map(_) => {
1150 let yaml = serde_yaml::to_string(v).unwrap_or_default();
1151 yaml.trim_end().to_string()
1152 }
1153 }
1154}
1155
1156#[cfg(test)]
1157mod tests {
1158 use super::*;
1159 use crate::query::Predicate;
1160
1161 #[test]
1170 fn update_builder_writes_url_string_without_double_quoting() {
1171 use std::fs;
1172 use tempfile::TempDir;
1173 let dir = TempDir::new().unwrap();
1174 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1175 fs::create_dir(dir.path().join("notes")).unwrap();
1176 fs::write(
1177 dir.path().join("notes/product.md"),
1178 "---\nname: bialetti\nurl:\n---\n\n# Bialetti\n",
1179 )
1180 .unwrap();
1181 let vault = Vault::with_root(dir.path().to_path_buf());
1182
1183 let filter = Expr::Predicate(Predicate::Equals {
1184 field: "_name".into(),
1185 value: Value::String("product".into()),
1186 });
1187 let url = Value::String("https://www.amazon.com.tr/Bialetti/foo".into());
1188 UpdateBuilder::new("notes", filter)
1189 .set("url", url)
1190 .execute(&vault)
1191 .unwrap();
1192
1193 let written = fs::read_to_string(dir.path().join("notes/product.md")).unwrap();
1194 assert!(
1196 written.contains("url: 'https://www.amazon.com.tr/Bialetti/foo'"),
1197 "got:\n{}",
1198 written
1199 );
1200 assert!(!written.contains("url: \"'https://"), "got:\n{}", written);
1202
1203 let records = vault
1206 .load_records(&dir.path().join("notes"), false, false)
1207 .unwrap()
1208 .records;
1209 let product = &records[0];
1210 match product.fields.get("url") {
1211 Some(Value::String(s)) => {
1212 assert_eq!(s, "https://www.amazon.com.tr/Bialetti/foo");
1213 }
1214 other => panic!("expected Value::String(bare URL), got {:?}", other),
1215 }
1216 }
1217
1218 #[test]
1223 fn update_builder_preserves_string_that_looks_like_bool() {
1224 use std::fs;
1225 use tempfile::TempDir;
1226 let dir = TempDir::new().unwrap();
1227 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1228 fs::create_dir(dir.path().join("notes")).unwrap();
1229 fs::write(
1230 dir.path().join("notes/n.md"),
1231 "---\nname: n\nstatus:\n---\n",
1232 )
1233 .unwrap();
1234 let vault = Vault::with_root(dir.path().to_path_buf());
1235
1236 let filter = Expr::Predicate(Predicate::Equals {
1237 field: "_name".into(),
1238 value: Value::String("n".into()),
1239 });
1240 UpdateBuilder::new("notes", filter)
1241 .set("status", Value::String("true".into()))
1242 .execute(&vault)
1243 .unwrap();
1244
1245 let records = vault
1246 .load_records(&dir.path().join("notes"), false, false)
1247 .unwrap()
1248 .records;
1249 match records[0].fields.get("status") {
1250 Some(Value::String(s)) if s == "true" => {}
1251 other => panic!(
1252 "expected status to round-trip as Value::String(\"true\"), got {:?}",
1253 other
1254 ),
1255 }
1256 }
1257
1258 #[test]
1264 fn update_builder_writes_list_as_block_yaml() {
1265 use std::fs;
1266 use tempfile::TempDir;
1267 let dir = TempDir::new().unwrap();
1268 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1269 fs::create_dir(dir.path().join("notes")).unwrap();
1270 fs::write(
1271 dir.path().join("notes/cat.md"),
1272 "---\nanlam: kedi\n---\n\n# 猫\n",
1273 )
1274 .unwrap();
1275 let vault = Vault::with_root(dir.path().to_path_buf());
1276
1277 let filter = Expr::Predicate(Predicate::Equals {
1278 field: "_name".into(),
1279 value: Value::String("cat".into()),
1280 });
1281 let anlamlar = Value::List(vec![
1282 Value::String("kedi".into()),
1283 Value::String("pisi".into()),
1284 ]);
1285 let report = UpdateBuilder::new("notes", filter)
1286 .set("anlamlar", anlamlar)
1287 .execute(&vault)
1288 .unwrap();
1289 assert_eq!(report.errors.len(), 0);
1290 assert_eq!(report.changes.len(), 1);
1291
1292 let written = fs::read_to_string(dir.path().join("notes/cat.md")).unwrap();
1293 assert!(
1295 written.contains("anlamlar:\n- kedi\n- pisi"),
1296 "got:\n{}",
1297 written
1298 );
1299 assert!(!written.contains("anlamlar: '- kedi"), "got:\n{}", written);
1301
1302 let records = vault
1306 .load_records(&dir.path().join("notes"), false, false)
1307 .unwrap()
1308 .records;
1309 let cat = &records[0];
1310 match cat.fields.get("anlamlar") {
1311 Some(Value::List(items)) => {
1312 assert_eq!(items.len(), 2);
1313 assert!(matches!(&items[0], Value::String(s) if s == "kedi"));
1314 assert!(matches!(&items[1], Value::String(s) if s == "pisi"));
1315 }
1316 other => panic!("expected Value::List, got {:?}", other),
1317 }
1318 }
1319
1320 #[test]
1321 fn update_builder_chains() {
1322 let filter = Expr::Predicate(Predicate::Equals {
1323 field: "status".into(),
1324 value: Value::String("active".into()),
1325 });
1326 let b = UpdateBuilder::new("notes", filter)
1327 .set("priority", Value::Integer(1))
1328 .unset("draft")
1329 .add_tag("urgent")
1330 .remove_tag("stale");
1331 assert_eq!(b.set_fields.len(), 1);
1332 assert_eq!(b.unset_fields.len(), 1);
1333 assert_eq!(b.add_tags.len(), 1);
1334 assert_eq!(b.remove_tags.len(), 1);
1335 }
1336
1337 #[test]
1338 fn delete_builder_trash_moves_to_dot_trash() {
1339 use std::fs;
1340 use tempfile::TempDir;
1341 let dir = TempDir::new().unwrap();
1342 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1343 fs::create_dir(dir.path().join("notes")).unwrap();
1344 fs::write(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
1345 let vault = Vault::with_root(dir.path().to_path_buf());
1346 let filter = Expr::Predicate(Predicate::Equals {
1347 field: "status".into(),
1348 value: Value::String("stale".into()),
1349 });
1350 let builder = DeleteBuilder::new("notes", filter);
1351 let report = builder.execute(&vault).unwrap();
1352 assert_eq!(report.changes.len(), 1);
1353 assert_eq!(report.errors.len(), 0);
1354 assert!(!dir.path().join("notes/a.md").exists());
1355 assert!(dir.path().join(".trash/a.md").exists());
1356 }
1357
1358 #[test]
1359 fn delete_builder_permanent_removes_file() {
1360 use std::fs;
1361 use tempfile::TempDir;
1362 let dir = TempDir::new().unwrap();
1363 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1364 fs::create_dir(dir.path().join("notes")).unwrap();
1365 fs::write(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
1366 let vault = Vault::with_root(dir.path().to_path_buf());
1367 let filter = Expr::Predicate(Predicate::Equals {
1368 field: "status".into(),
1369 value: Value::String("stale".into()),
1370 });
1371 let builder = DeleteBuilder::new("notes", filter).permanent(true);
1372 builder.execute(&vault).unwrap();
1373 assert!(!dir.path().join("notes/a.md").exists());
1374 assert!(!dir.path().join(".trash/a.md").exists());
1375 }
1376
1377 #[test]
1378 fn move_builder_relocates_files() {
1379 use std::fs;
1380 use tempfile::TempDir;
1381 let dir = TempDir::new().unwrap();
1382 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1383 fs::create_dir(dir.path().join("notes")).unwrap();
1384 fs::write(
1385 dir.path().join("notes/a.md"),
1386 "---\nstatus: archived\n---\n",
1387 )
1388 .unwrap();
1389 let vault = Vault::with_root(dir.path().to_path_buf());
1390 let filter = Expr::Predicate(Predicate::Equals {
1391 field: "status".into(),
1392 value: Value::String("archived".into()),
1393 });
1394 let builder = MoveBuilder::new("notes", "archive", filter);
1395 let report = builder.execute(&vault).unwrap();
1396 assert_eq!(report.changes.len(), 1);
1397 assert_eq!(report.errors.len(), 0);
1398 assert!(!dir.path().join("notes/a.md").exists());
1399 assert!(dir.path().join("archive/a.md").exists());
1400 }
1401
1402 #[test]
1403 fn rename_builder_renames_and_rewrites_links() {
1404 use std::fs;
1405 use tempfile::TempDir;
1406 let dir = TempDir::new().unwrap();
1407 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1408 fs::create_dir(dir.path().join("notes")).unwrap();
1409 fs::write(
1410 dir.path().join("notes/old.md"),
1411 "---\nstatus: x\n---\nBody\n",
1412 )
1413 .unwrap();
1414 fs::write(
1415 dir.path().join("notes/source.md"),
1416 "---\nstatus: y\n---\nLinks to [[old]] and [[old|alias]] and [[old#section]].\n",
1417 )
1418 .unwrap();
1419 let vault = Vault::with_root(dir.path().to_path_buf());
1420
1421 let builder = RenameBuilder::new("notes", "old", "new");
1422 let report = builder.execute(&vault).unwrap();
1423 assert_eq!(report.changes.len(), 2);
1425 assert_eq!(report.errors.len(), 0);
1426 assert!(!dir.path().join("notes/old.md").exists());
1427 assert!(dir.path().join("notes/new.md").exists());
1428 let source_after = fs::read_to_string(dir.path().join("notes/source.md")).unwrap();
1429 assert!(source_after.contains("[[new]]"));
1430 assert!(source_after.contains("[[new|alias]]"));
1431 assert!(source_after.contains("[[new#section]]"));
1432 assert!(!source_after.contains("[[old"));
1433 }
1434
1435 #[test]
1436 fn rename_builder_target_conflict_returns_error() {
1437 use std::fs;
1438 use tempfile::TempDir;
1439 let dir = TempDir::new().unwrap();
1440 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1441 fs::create_dir(dir.path().join("notes")).unwrap();
1442 fs::write(dir.path().join("notes/old.md"), "---\nstatus: x\n---\n").unwrap();
1443 fs::write(dir.path().join("notes/new.md"), "---\nstatus: y\n---\n").unwrap();
1444 let vault = Vault::with_root(dir.path().to_path_buf());
1445 let report = RenameBuilder::new("notes", "old", "new")
1446 .execute(&vault)
1447 .unwrap();
1448 assert_eq!(report.changes.len(), 0);
1449 assert_eq!(report.errors.len(), 1);
1450 assert!(dir.path().join("notes/old.md").exists());
1452 }
1453
1454 #[test]
1455 fn update_builder_plan_and_execute_against_a_temp_vault() {
1456 use std::fs;
1457 use tempfile::TempDir;
1458
1459 let dir = TempDir::new().unwrap();
1460 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1461 fs::create_dir(dir.path().join("notes")).unwrap();
1462 fs::write(
1463 dir.path().join("notes/a.md"),
1464 "---\nstatus: active\n---\nBody A\n",
1465 )
1466 .unwrap();
1467 fs::write(
1468 dir.path().join("notes/b.md"),
1469 "---\nstatus: pending\n---\nBody B\n",
1470 )
1471 .unwrap();
1472
1473 let vault = Vault::with_root(dir.path().to_path_buf());
1474
1475 let filter = Expr::Predicate(Predicate::Equals {
1476 field: "status".into(),
1477 value: Value::String("active".into()),
1478 });
1479 let builder = UpdateBuilder::new("notes", filter).set("priority", Value::Integer(1));
1480
1481 let plan_report = builder.plan(&vault).unwrap();
1483 assert_eq!(plan_report.changes.len(), 1);
1484 assert_eq!(plan_report.errors.len(), 0);
1485 assert!(plan_report.changes[0].path.ends_with("a.md"));
1486 let before = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
1487 assert!(!before.contains("priority"));
1488
1489 let exec_report = builder.execute(&vault).unwrap();
1491 assert_eq!(exec_report.changes.len(), 1);
1492 let after = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
1493 assert!(after.contains("priority"));
1494 let b_after = fs::read_to_string(dir.path().join("notes/b.md")).unwrap();
1496 assert!(!b_after.contains("priority"));
1497 }
1498
1499 #[test]
1500 fn write_options_fsync_propagates_through_update_builder() {
1501 use std::fs;
1509 use tempfile::TempDir;
1510
1511 let f1 = Expr::Predicate(Predicate::Equals {
1513 field: "x".into(),
1514 value: Value::Integer(1),
1515 });
1516 let b = UpdateBuilder::new("notes", f1).fsync(true);
1517 assert!(b.write_options.fsync);
1518
1519 let f2 = Expr::Predicate(Predicate::Equals {
1520 field: "x".into(),
1521 value: Value::Integer(1),
1522 });
1523 let b =
1524 UpdateBuilder::new("notes", f2).write_options(crate::writer::WriteOptions::durable());
1525 assert!(b.write_options.fsync);
1526
1527 let dir = TempDir::new().unwrap();
1529 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1530 fs::create_dir(dir.path().join("notes")).unwrap();
1531 fs::write(
1532 dir.path().join("notes/durable.md"),
1533 "---\nstatus: active\n---\nBody.\n",
1534 )
1535 .unwrap();
1536 let vault = Vault::with_root(dir.path().to_path_buf());
1537
1538 let f3 = Expr::Predicate(Predicate::Equals {
1539 field: "status".into(),
1540 value: Value::String("active".into()),
1541 });
1542 let report = UpdateBuilder::new("notes", f3)
1543 .set("priority", Value::Integer(99))
1544 .fsync(true)
1545 .execute(&vault)
1546 .unwrap();
1547 assert_eq!(report.changes.len(), 1);
1548 assert_eq!(report.errors.len(), 0);
1549
1550 let after = fs::read_to_string(dir.path().join("notes/durable.md")).unwrap();
1551 assert!(after.contains("priority: 99"));
1552 assert!(after.contains("status: active"));
1553 }
1554
1555 #[test]
1556 fn rename_clean_run_leaves_no_journal_behind() {
1557 use std::fs;
1560 use tempfile::TempDir;
1561 let dir = TempDir::new().unwrap();
1562 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1563 fs::create_dir(dir.path().join("notes")).unwrap();
1564 fs::write(
1565 dir.path().join("notes/old.md"),
1566 "---\nstatus: x\n---\nBody\n",
1567 )
1568 .unwrap();
1569 fs::write(
1570 dir.path().join("notes/source.md"),
1571 "---\nstatus: y\n---\nLinks to [[old]].\n",
1572 )
1573 .unwrap();
1574 let vault = Vault::with_root(dir.path().to_path_buf());
1575
1576 RenameBuilder::new("notes", "old", "new")
1577 .execute(&vault)
1578 .unwrap();
1579
1580 let pending = crate::journal::list_pending(dir.path()).unwrap();
1581 assert!(
1582 pending.is_empty(),
1583 "successful rename must not leave journals behind: {:?}",
1584 pending
1585 );
1586 }
1587
1588 #[test]
1589 fn rename_recovers_from_pre_existing_journal() {
1590 use std::fs;
1594 use tempfile::TempDir;
1595 let dir = TempDir::new().unwrap();
1596 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1597 fs::create_dir(dir.path().join("notes")).unwrap();
1598 let source = dir.path().join("notes/Stanford.md");
1599 let dest = dir.path().join("notes/Stanford University.md");
1600 let backlink = dir.path().join("notes/Application.md");
1601 fs::write(&source, "---\nkind: university\n---\nMain note.\n").unwrap();
1602 fs::write(
1603 &backlink,
1604 "---\nkind: application\n---\nApplied to [[Stanford]].\n",
1605 )
1606 .unwrap();
1607
1608 let journal = crate::journal::RenameJournal {
1610 source: source.clone(),
1611 dest: dest.clone(),
1612 from_name: "Stanford".into(),
1613 to_name: "Stanford University".into(),
1614 backlinks: vec![backlink.clone()],
1615 };
1616 crate::journal::write(dir.path(), &journal).unwrap();
1617
1618 let vault = Vault::with_root(dir.path().to_path_buf());
1619 let recovered = vault.recover().unwrap();
1620 assert_eq!(recovered, 1, "expected exactly one journal replayed");
1621
1622 assert!(!source.exists());
1624 assert!(dest.is_file());
1625 let backlink_content = fs::read_to_string(&backlink).unwrap();
1626 assert!(backlink_content.contains("[[Stanford University]]"));
1627 assert!(!backlink_content.contains("[[Stanford]]"));
1628 assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
1629 }
1630
1631 #[test]
1632 fn rename_replays_pending_journal_before_starting_new_rename() {
1633 use std::fs;
1637 use tempfile::TempDir;
1638 let dir = TempDir::new().unwrap();
1639 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1640 fs::create_dir(dir.path().join("notes")).unwrap();
1641
1642 let a = dir.path().join("notes/A.md");
1646 let b = dir.path().join("notes/B.md");
1647 let c = dir.path().join("notes/C.md");
1648 let d = dir.path().join("notes/D.md");
1649 fs::write(&a, "---\n---\nA body.\n").unwrap();
1650 fs::write(&c, "---\n---\nC body.\n").unwrap();
1651
1652 crate::journal::write(
1654 dir.path(),
1655 &crate::journal::RenameJournal {
1656 source: a.clone(),
1657 dest: b.clone(),
1658 from_name: "A".into(),
1659 to_name: "B".into(),
1660 backlinks: vec![],
1661 },
1662 )
1663 .unwrap();
1664
1665 let vault = Vault::with_root(dir.path().to_path_buf());
1667 RenameBuilder::new("notes", "C", "D")
1668 .execute(&vault)
1669 .unwrap();
1670
1671 assert!(!a.exists(), "A.md should be gone (replayed journal)");
1674 assert!(b.is_file(), "B.md should exist (replayed journal)");
1675 assert!(!c.exists(), "C.md should be gone (new rename)");
1676 assert!(d.is_file(), "D.md should exist (new rename)");
1677
1678 assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
1680 }
1681
1682 #[test]
1683 fn concurrent_updates_serialize_via_vault_lock() {
1684 use std::fs;
1692 use std::sync::Arc;
1693 use std::thread;
1694 use tempfile::TempDir;
1695
1696 let dir = TempDir::new().unwrap();
1697 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1698 fs::create_dir(dir.path().join("notes")).unwrap();
1699 fs::write(
1700 dir.path().join("notes/race.md"),
1701 "---\nstatus: active\n---\nBody.\n",
1702 )
1703 .unwrap();
1704
1705 let vault_path = Arc::new(dir.path().to_path_buf());
1706
1707 let p1 = Arc::clone(&vault_path);
1708 let t1 = thread::spawn(move || {
1709 let vault = Vault::with_root((*p1).clone());
1710 let filter = Expr::Predicate(Predicate::Equals {
1711 field: "status".into(),
1712 value: Value::String("active".into()),
1713 });
1714 UpdateBuilder::new("notes", filter)
1715 .set("touched_by_t1", Value::Integer(1))
1716 .execute(&vault)
1717 .expect("t1 execute")
1718 });
1719
1720 let p2 = Arc::clone(&vault_path);
1721 let t2 = thread::spawn(move || {
1722 let vault = Vault::with_root((*p2).clone());
1723 let filter = Expr::Predicate(Predicate::Equals {
1724 field: "status".into(),
1725 value: Value::String("active".into()),
1726 });
1727 UpdateBuilder::new("notes", filter)
1728 .set("touched_by_t2", Value::Integer(1))
1729 .execute(&vault)
1730 .expect("t2 execute")
1731 });
1732
1733 let r1 = t1.join().unwrap();
1734 let r2 = t2.join().unwrap();
1735 assert_eq!(r1.errors.len(), 0);
1736 assert_eq!(r2.errors.len(), 0);
1737
1738 let final_content = fs::read_to_string(dir.path().join("notes/race.md")).unwrap();
1742 assert!(
1743 final_content.contains("touched_by_t1"),
1744 "t1's edit lost; concurrent writer race: {}",
1745 final_content
1746 );
1747 assert!(
1748 final_content.contains("touched_by_t2"),
1749 "t2's edit lost; concurrent writer race: {}",
1750 final_content
1751 );
1752 }
1753
1754 #[test]
1755 fn atomic_write_does_not_leave_partial_files_on_failed_writes() {
1756 use std::fs;
1762 use tempfile::TempDir;
1763
1764 let dir = TempDir::new().unwrap();
1765 let target = dir.path().join("subdir/that-does-not-exist/x.md");
1766 let result = crate::writer::atomic_write(&target, "new content");
1769 assert!(
1770 result.is_err(),
1771 "expected atomic_write to fail when parent dir doesn't exist"
1772 );
1773
1774 let real_dir = dir.path().join("real");
1777 fs::create_dir(&real_dir).unwrap();
1778 let real_target = real_dir.join("x.md");
1779 fs::write(&real_target, "original").unwrap();
1780 crate::writer::atomic_write(&real_target, "replacement").unwrap();
1781 let after = fs::read_to_string(&real_target).unwrap();
1782 assert_eq!(after, "replacement");
1783
1784 let leftovers: Vec<_> = fs::read_dir(&real_dir)
1786 .unwrap()
1787 .flatten()
1788 .filter(|e| e.file_name().to_string_lossy().starts_with(".tmp"))
1789 .collect();
1790 assert!(
1791 leftovers.is_empty(),
1792 "expected no tempfile leftovers, found: {:?}",
1793 leftovers.iter().map(|e| e.path()).collect::<Vec<_>>()
1794 );
1795 }
1796
1797 use crate::schema::{CollectionSchema, FieldSchema};
1800
1801 fn movie_schema() -> CollectionSchema {
1802 let mut fields = std::collections::BTreeMap::new();
1803 fields.insert(
1804 "db-table".into(),
1805 FieldSchema {
1806 field_type: "string".into(),
1807 enum_values: vec![Value::String("movie".into())],
1808 min: None,
1809 max: None,
1810 default: Some(Value::String("movie".into())),
1811 default_expr: None,
1812 },
1813 );
1814 fields.insert(
1815 "status".into(),
1816 FieldSchema {
1817 field_type: "string".into(),
1818 enum_values: vec![
1819 Value::String("to-watch".into()),
1820 Value::String("watched".into()),
1821 ],
1822 min: None,
1823 max: None,
1824 default: Some(Value::String("to-watch".into())),
1825 default_expr: None,
1826 },
1827 );
1828 fields.insert(
1829 "director".into(),
1830 FieldSchema {
1831 field_type: "string".into(),
1832 enum_values: vec![],
1833 min: None,
1834 max: None,
1835 default: None,
1836 default_expr: None,
1837 },
1838 );
1839 fields.insert(
1840 "year".into(),
1841 FieldSchema {
1842 field_type: "integer".into(),
1843 enum_values: vec![],
1844 min: None,
1845 max: None,
1846 default: None,
1847 default_expr: None,
1848 },
1849 );
1850 CollectionSchema {
1851 description: None,
1852 folder: "Notes/movie".into(),
1853 filter: vec![],
1854 required: vec![
1855 "db-table".into(),
1856 "director".into(),
1857 "status".into(),
1858 "year".into(),
1859 ],
1860 fields,
1861 }
1862 }
1863
1864 fn vault_with_obsidian() -> tempfile::TempDir {
1865 let dir = tempfile::TempDir::new().unwrap();
1866 std::fs::create_dir(dir.path().join(".obsidian")).unwrap();
1867 dir
1868 }
1869
1870 #[test]
1871 fn create_without_schema_writes_minimal_file() {
1872 let dir = vault_with_obsidian();
1873 let vault = Vault::with_root(dir.path().to_path_buf());
1874 let report = CreateBuilder::new("Notes/movie", "Dune")
1875 .execute(&vault)
1876 .unwrap();
1877 assert_eq!(report.errors.len(), 0);
1878 assert_eq!(report.changes.len(), 1);
1879 let written = dir.path().join("Notes/movie/Dune.md");
1880 assert!(written.is_file());
1881 let content = std::fs::read_to_string(&written).unwrap();
1882 assert!(content.contains("---\n---"));
1884 assert!(content.contains("# Dune"));
1885 }
1886
1887 #[test]
1888 fn create_with_set_writes_typed_frontmatter() {
1889 let dir = vault_with_obsidian();
1890 let vault = Vault::with_root(dir.path().to_path_buf());
1891 CreateBuilder::new("Notes/movie", "Dune")
1892 .set("director", Value::String("Denis Villeneuve".into()))
1893 .set("year", Value::Integer(2021))
1894 .execute(&vault)
1895 .unwrap();
1896 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1897 assert!(content.contains("director: Denis Villeneuve"));
1898 assert!(content.contains("year: 2021"));
1900 }
1901
1902 #[test]
1903 fn create_fills_schema_defaults() {
1904 let dir = vault_with_obsidian();
1905 let vault = Vault::with_root(dir.path().to_path_buf());
1906 CreateBuilder::new("Notes/movie", "Dune")
1907 .with_schema(movie_schema())
1908 .set("director", Value::String("Denis Villeneuve".into()))
1909 .set("year", Value::Integer(2021))
1910 .execute(&vault)
1911 .unwrap();
1912 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1913 assert!(content.contains("db-table: movie"));
1915 assert!(content.contains("status: to-watch"));
1916 assert!(content.contains("director: Denis Villeneuve"));
1918 assert!(content.contains("year: 2021"));
1919 }
1920
1921 #[test]
1922 fn create_set_overrides_default() {
1923 let dir = vault_with_obsidian();
1924 let vault = Vault::with_root(dir.path().to_path_buf());
1925 CreateBuilder::new("Notes/movie", "Watched")
1926 .with_schema(movie_schema())
1927 .set("director", Value::String("X".into()))
1928 .set("year", Value::Integer(2020))
1929 .set("status", Value::String("watched".into()))
1930 .execute(&vault)
1931 .unwrap();
1932 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Watched.md")).unwrap();
1933 assert!(content.contains("status: watched"));
1934 assert!(!content.contains("status: to-watch"));
1935 }
1936
1937 #[test]
1938 fn create_rejects_missing_required_before_writing() {
1939 let dir = vault_with_obsidian();
1940 let vault = Vault::with_root(dir.path().to_path_buf());
1941 let report = CreateBuilder::new("Notes/movie", "Blank")
1943 .with_schema(movie_schema())
1944 .execute(&vault)
1945 .unwrap();
1946 assert!(!report.errors.is_empty());
1947 assert!(report.errors.iter().any(|e| e.message.contains("director")));
1948 assert!(report.errors.iter().any(|e| e.message.contains("year")));
1949 assert!(!dir.path().join("Notes/movie/Blank.md").exists());
1951 }
1952
1953 #[test]
1954 fn create_rejects_existing_file() {
1955 let dir = vault_with_obsidian();
1956 std::fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
1957 std::fs::write(dir.path().join("Notes/movie/Dune.md"), "existing\n").unwrap();
1958 let vault = Vault::with_root(dir.path().to_path_buf());
1959 let report = CreateBuilder::new("Notes/movie", "Dune")
1960 .execute(&vault)
1961 .unwrap();
1962 assert_eq!(report.errors.len(), 1);
1963 assert!(report.errors[0].message.contains("already exists"));
1964 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1966 assert_eq!(content, "existing\n");
1967 }
1968
1969 #[test]
1970 fn create_resolves_default_expr_today() {
1971 let dir = vault_with_obsidian();
1972 let vault = Vault::with_root(dir.path().to_path_buf());
1973 let mut fields = std::collections::BTreeMap::new();
1974 fields.insert(
1975 "due".into(),
1976 FieldSchema {
1977 field_type: "date".into(),
1978 enum_values: vec![],
1979 min: None,
1980 max: None,
1981 default: None,
1982 default_expr: Some("today".into()),
1983 },
1984 );
1985 let schema = CollectionSchema {
1986 description: None,
1987 folder: "tasks".into(),
1988 filter: vec![],
1989 required: vec![],
1990 fields,
1991 };
1992 CreateBuilder::new("tasks", "t1")
1993 .with_schema(schema)
1994 .execute(&vault)
1995 .unwrap();
1996 let content = std::fs::read_to_string(dir.path().join("tasks/t1.md")).unwrap();
1997 let today = crate::record::today_string();
1999 assert!(
2000 content.contains(&format!("due: {}", today)),
2001 "expected due={} in: {}",
2002 today,
2003 content
2004 );
2005 }
2006
2007 #[test]
2008 fn create_plan_does_not_touch_disk() {
2009 let dir = vault_with_obsidian();
2010 let vault = Vault::with_root(dir.path().to_path_buf());
2011 let (report, content) = CreateBuilder::new("Notes/movie", "Dune")
2012 .with_schema(movie_schema())
2013 .set("director", Value::String("DV".into()))
2014 .set("year", Value::Integer(2021))
2015 .plan_with_content(&vault)
2016 .unwrap();
2017 assert_eq!(report.errors.len(), 0);
2018 assert_eq!(report.changes.len(), 1);
2019 assert!(!dir.path().join("Notes/movie/Dune.md").exists());
2020 let c = content.unwrap();
2021 assert!(c.contains("director: DV"));
2022 assert!(c.contains("db-table: movie")); }
2024
2025 #[test]
2026 fn create_from_template_preserves_body_and_merges_frontmatter() {
2027 let dir = vault_with_obsidian();
2028 std::fs::create_dir_all(dir.path().join("templates")).unwrap();
2029 std::fs::write(
2030 dir.path().join("templates/movie.md"),
2031 "---\nstatus: to-watch\naliases: []\n---\n\n# Title\n\nReview goes here.\n",
2032 )
2033 .unwrap();
2034 let vault = Vault::with_root(dir.path().to_path_buf());
2035 CreateBuilder::new("Notes/movie", "Dune")
2036 .template("templates/movie.md")
2037 .set("year", Value::Integer(2021))
2038 .execute(&vault)
2039 .unwrap();
2040 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2041 assert!(content.contains("status: to-watch"));
2043 assert!(content.contains("year: 2021"));
2045 assert!(content.contains("Review goes here"));
2047 }
2048
2049 use crate::schema::VaultSchema;
2058
2059 fn vault_schema_movies() -> VaultSchema {
2060 let mut vs = VaultSchema {
2063 collections: std::collections::BTreeMap::new(),
2064 };
2065 vs.collections.insert("movies".into(), movie_schema());
2066 vs
2067 }
2068
2069 fn vault_schema_catchall_and_movies() -> VaultSchema {
2070 let mut collections = std::collections::BTreeMap::new();
2074
2075 let mut catchall_fields = std::collections::BTreeMap::new();
2076 catchall_fields.insert(
2077 "db-table".into(),
2078 FieldSchema {
2079 field_type: "string".into(),
2080 enum_values: vec![Value::String("movie".into()), Value::String("book".into())],
2081 min: None,
2082 max: None,
2083 default: None,
2084 default_expr: None,
2085 },
2086 );
2087 collections.insert(
2088 "Notes".into(),
2089 CollectionSchema {
2090 description: None,
2091 folder: "Notes".into(),
2092 filter: vec![],
2093 required: vec!["db-table".into()],
2094 fields: catchall_fields,
2095 },
2096 );
2097 collections.insert("movies".into(), {
2098 let mut m = movie_schema();
2099 m.filter = vec!["db-table = movie".into()];
2100 m
2101 });
2102
2103 VaultSchema { collections }
2104 }
2105
2106 #[test]
2107 fn update_rejects_type_mismatch() {
2108 use std::fs;
2109 let dir = vault_with_obsidian();
2110 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2111 fs::write(
2112 dir.path().join("Notes/movie/Dune.md"),
2113 "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\nBody\n",
2114 )
2115 .unwrap();
2116 let vault = Vault::with_root(dir.path().to_path_buf());
2117
2118 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2119 field: "director".into(),
2120 value: Value::String("DV".into()),
2121 });
2122 let report = UpdateBuilder::new("Notes/movie", filter)
2123 .set("year", Value::String("nope".into()))
2124 .with_vault_schema(vault_schema_movies())
2125 .execute(&vault)
2126 .unwrap();
2127
2128 assert!(report.changes.is_empty(), "no write should be reported");
2129 assert!(
2130 report
2131 .errors
2132 .iter()
2133 .any(|e| e.message.contains("year") && e.message.contains("integer")),
2134 "expected year/integer type-mismatch error, got: {:?}",
2135 report.errors
2136 );
2137 let content = fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2139 assert!(content.contains("year: 2021"));
2140 assert!(!content.contains("year: nope"));
2141 }
2142
2143 #[test]
2144 fn update_rejects_enum_violation() {
2145 use std::fs;
2146 let dir = vault_with_obsidian();
2147 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2148 fs::write(
2149 dir.path().join("Notes/movie/Dune.md"),
2150 "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\n",
2151 )
2152 .unwrap();
2153 let vault = Vault::with_root(dir.path().to_path_buf());
2154
2155 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2156 field: "director".into(),
2157 value: Value::String("DV".into()),
2158 });
2159 let report = UpdateBuilder::new("Notes/movie", filter)
2160 .set("status", Value::String("watching".into()))
2161 .with_vault_schema(vault_schema_movies())
2162 .execute(&vault)
2163 .unwrap();
2164
2165 assert!(report.changes.is_empty());
2166 assert!(
2167 report
2168 .errors
2169 .iter()
2170 .any(|e| e.message.contains("status") && e.message.contains("watching")),
2171 "expected status enum violation, got: {:?}",
2172 report.errors
2173 );
2174 }
2175
2176 #[test]
2177 fn update_passes_when_unconstrained_field_changes() {
2178 use std::fs;
2182 let dir = vault_with_obsidian();
2183 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2184 fs::write(
2185 dir.path().join("Notes/movie/Dune.md"),
2186 "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\n",
2187 )
2188 .unwrap();
2189 let vault = Vault::with_root(dir.path().to_path_buf());
2190
2191 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2192 field: "director".into(),
2193 value: Value::String("DV".into()),
2194 });
2195 let report = UpdateBuilder::new("Notes/movie", filter)
2196 .set("notes-to-self", Value::String("rewatch".into()))
2197 .with_vault_schema(vault_schema_movies())
2198 .execute(&vault)
2199 .unwrap();
2200 assert!(
2201 report.errors.is_empty(),
2202 "no errors expected, got: {:?}",
2203 report.errors
2204 );
2205 assert_eq!(report.changes.len(), 1);
2206 let content = fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2207 assert!(content.contains("notes-to-self: rewatch"));
2208 }
2209
2210 #[test]
2211 fn update_surfaces_preexisting_violation() {
2212 use std::fs;
2217 let dir = vault_with_obsidian();
2218 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2219 fs::write(
2220 dir.path().join("Notes/movie/Old.md"),
2221 "---\ndb-table: movie\nstatus: to-watch\nyear: 2021\n---\n",
2223 )
2224 .unwrap();
2225 let vault = Vault::with_root(dir.path().to_path_buf());
2226
2227 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2228 field: "db-table".into(),
2229 value: Value::String("movie".into()),
2230 });
2231 let report = UpdateBuilder::new("Notes/movie", filter)
2232 .set("year", Value::Integer(2022))
2233 .with_vault_schema(vault_schema_movies())
2234 .execute(&vault)
2235 .unwrap();
2236 assert!(
2237 report.errors.iter().any(|e| e.message.contains("director")),
2238 "expected pre-existing required-field violation, got: {:?}",
2239 report.errors
2240 );
2241 let content = fs::read_to_string(dir.path().join("Notes/movie/Old.md")).unwrap();
2243 assert!(content.contains("year: 2021"));
2244 }
2245
2246 #[test]
2247 fn update_skips_one_blocks_one_in_batch() {
2248 use std::fs;
2255 let dir = vault_with_obsidian();
2256 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2257 fs::write(
2258 dir.path().join("Notes/movie/Good.md"),
2259 "---\ndb-table: movie\ndirector: A\nstatus: to-watch\nyear: 2021\n---\n",
2260 )
2261 .unwrap();
2262 fs::write(
2263 dir.path().join("Notes/movie/Bad.md"),
2264 "---\ndb-table: movie\nstatus: to-watch\nyear: 2021\n---\n",
2266 )
2267 .unwrap();
2268 let vault = Vault::with_root(dir.path().to_path_buf());
2269
2270 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2271 field: "db-table".into(),
2272 value: Value::String("movie".into()),
2273 });
2274 let report = UpdateBuilder::new("Notes/movie", filter)
2275 .set("year", Value::Integer(2022))
2276 .with_vault_schema(vault_schema_movies())
2277 .execute(&vault)
2278 .unwrap();
2279 assert_eq!(report.changes.len(), 1, "exactly one record should write");
2280 assert!(report.changes[0].path.ends_with("Good.md"));
2281 assert!(report.errors.iter().any(|e| e.path.ends_with("Bad.md")));
2282 assert!(
2284 fs::read_to_string(dir.path().join("Notes/movie/Good.md"))
2285 .unwrap()
2286 .contains("year: 2022")
2287 );
2288 assert!(
2289 fs::read_to_string(dir.path().join("Notes/movie/Bad.md"))
2290 .unwrap()
2291 .contains("year: 2021")
2292 );
2293 }
2294
2295 #[test]
2296 fn update_validates_against_catchall_and_subfolder() {
2297 use std::fs;
2303 let dir = vault_with_obsidian();
2304 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2305 fs::write(
2306 dir.path().join("Notes/movie/Dune.md"),
2307 "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\n",
2308 )
2309 .unwrap();
2310 let vault = Vault::with_root(dir.path().to_path_buf());
2311
2312 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2313 field: "director".into(),
2314 value: Value::String("DV".into()),
2315 });
2316 let report = UpdateBuilder::new("Notes/movie", filter)
2317 .unset("db-table")
2318 .with_vault_schema(vault_schema_catchall_and_movies())
2319 .execute(&vault)
2320 .unwrap();
2321
2322 assert!(report.changes.is_empty());
2327 assert!(
2328 report.errors.iter().any(|e| e.message.contains("db-table")),
2329 "expected db-table missing error from catch-all, got: {:?}",
2330 report.errors
2331 );
2332 }
2333
2334 #[test]
2335 fn create_rejects_type_mismatch() {
2336 let dir = vault_with_obsidian();
2337 let vault = Vault::with_root(dir.path().to_path_buf());
2338 let report = CreateBuilder::new("Notes/movie", "Dune")
2339 .with_vault_schema(vault_schema_movies())
2340 .set("director", Value::String("DV".into()))
2341 .set("year", Value::String("not-a-year".into()))
2342 .execute(&vault)
2343 .unwrap();
2344 assert!(
2345 report
2346 .errors
2347 .iter()
2348 .any(|e| e.message.contains("year") && e.message.contains("integer")),
2349 "expected year/integer type error, got: {:?}",
2350 report.errors
2351 );
2352 assert!(!dir.path().join("Notes/movie/Dune.md").exists());
2353 }
2354
2355 #[test]
2356 fn create_validates_against_multiple_applicable_collections() {
2357 let dir = vault_with_obsidian();
2362 let vault = Vault::with_root(dir.path().to_path_buf());
2363 let report = CreateBuilder::new("Notes/movie", "X")
2364 .with_vault_schema(vault_schema_catchall_and_movies())
2365 .set("db-table", Value::String("movie".into()))
2366 .execute(&vault)
2367 .unwrap();
2368 assert!(report.errors.iter().any(|e| e.message.contains("director")));
2369 assert!(report.errors.iter().any(|e| e.message.contains("year")));
2370 assert!(!dir.path().join("Notes/movie/X.md").exists());
2371 }
2372}