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 {
238 Value::List(_) | Value::Map(_) => {
239 writer::set_field_block(&content, field, value)?
240 }
241 _ => {
242 let value_str = render_value_for_yaml(value);
243 writer::set_field(&content, field, &value_str)?
244 }
245 };
246 description_parts.push(format!("{}", change));
247 wr_changes.push(change);
248 content = new_content;
249 }
250 for field in &self.unset_fields {
251 let (new_content, change) = writer::unset_field(&content, field)?;
252 description_parts.push(format!("{}", change));
253 wr_changes.push(change);
254 content = new_content;
255 }
256 for tag in &self.add_tags {
257 let (new_content, change) = writer::add_tag(&content, tag)?;
258 description_parts.push(format!("{}", change));
259 wr_changes.push(change);
260 content = new_content;
261 }
262 for tag in &self.remove_tags {
263 let (new_content, change) = writer::remove_tag(&content, tag)?;
264 description_parts.push(format!("{}", change));
265 wr_changes.push(change);
266 content = new_content;
267 }
268 Ok(())
269 })();
270
271 match result {
272 Ok(_) => {
273 if !wr_changes.is_empty() {
274 writes.push(WriteResult {
275 path: record.path.clone(),
276 original_content,
277 modified_content: content,
278 changes: wr_changes,
279 });
280 changes.push(PlannedChange {
281 path: record.path.clone(),
282 description: description_parts.join("; "),
283 });
284 }
285 }
286 Err(e) => errors.push(MutationError {
287 path: record.path.clone(),
288 message: e.to_string(),
289 }),
290 }
291 }
292
293 Ok((MutationReport { changes, errors }, writes))
294 }
295
296 fn project_fields(
304 &self,
305 original: &std::collections::BTreeMap<String, Value>,
306 ) -> std::collections::BTreeMap<String, Value> {
307 let mut fields = original.clone();
308 for (k, v) in &self.set_fields {
309 fields.insert(k.clone(), v.clone());
310 }
311 for k in &self.unset_fields {
312 fields.remove(k);
313 }
314 if !self.add_tags.is_empty() || !self.remove_tags.is_empty() {
315 let mut tags_list: Vec<Value> = match fields.get("tags") {
316 Some(Value::List(l)) => l.clone(),
317 _ => Vec::new(),
318 };
319 for t in &self.add_tags {
320 tags_list.push(Value::String(t.clone()));
321 }
322 for t in &self.remove_tags {
323 if let Some(idx) = tags_list
324 .iter()
325 .position(|v| matches!(v, Value::String(s) if s == t))
326 {
327 tags_list.remove(idx);
328 }
329 }
330 fields.insert("tags".to_string(), Value::List(tags_list));
331 }
332 fields
333 }
334}
335
336#[derive(Debug, Clone)]
342pub struct DeleteBuilder {
343 filter: Expr,
344 folder: String,
345 permanent: bool,
346 write_options: writer::WriteOptions,
347}
348
349impl DeleteBuilder {
350 pub fn new(folder: impl Into<String>, filter: Expr) -> Self {
351 Self {
352 filter,
353 folder: folder.into(),
354 permanent: false,
355 write_options: writer::WriteOptions::default(),
356 }
357 }
358
359 pub fn permanent(mut self, yes: bool) -> Self {
360 self.permanent = yes;
361 self
362 }
363
364 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
366 self.write_options = opts;
367 self
368 }
369
370 pub fn fsync(mut self, yes: bool) -> Self {
373 self.write_options.fsync = yes;
374 self
375 }
376
377 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
378 let folder_path = vault.resolve_folder(&self.folder)?;
379 let load = vault.load_records(&folder_path, false, false)?;
380 let needs_links = crate::filter::expr_uses_links(&self.filter);
381 let link_index = if needs_links {
382 Some(crate::links::LinkGraph::build_with_root(
383 &load.records,
384 Some(&vault.root),
385 ))
386 } else {
387 None
388 };
389
390 let mut changes = Vec::new();
391 for r in &load.records {
392 if !crate::filter::evaluate_expr(&self.filter, r, &vault.root, link_index.as_ref()) {
393 continue;
394 }
395 changes.push(PlannedChange {
396 path: r.path.clone(),
397 description: if self.permanent {
398 "delete (permanent)".to_string()
399 } else {
400 "move to .trash/".to_string()
401 },
402 });
403 }
404 Ok(MutationReport {
405 changes,
406 errors: Vec::new(),
407 })
408 }
409
410 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
411 crate::lock::with_lock(&vault.root, || {
412 let report = self.plan(vault)?;
413 let mut errors = Vec::new();
414 let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
416 std::collections::BTreeSet::new();
417
418 if self.permanent {
419 for change in &report.changes {
420 if let Err(e) = std::fs::remove_file(&change.path) {
421 errors.push(MutationError {
422 path: change.path.clone(),
423 message: format!("remove failed: {}", e),
424 });
425 } else if let Some(parent) = change.path.parent() {
426 dirs_to_fsync.insert(parent.to_path_buf());
427 }
428 }
429 } else {
430 let trash_dir = vault.root.join(".trash");
431 if !report.changes.is_empty() {
432 std::fs::create_dir_all(&trash_dir).map_err(VaultdbError::Io)?;
433 }
434 for change in &report.changes {
435 let dest = unique_in_dir(&trash_dir, &change.path);
436 if let Err(e) = std::fs::rename(&change.path, &dest) {
437 errors.push(MutationError {
438 path: change.path.clone(),
439 message: format!("trash failed: {}", e),
440 });
441 } else {
442 if let Some(parent) = change.path.parent() {
443 dirs_to_fsync.insert(parent.to_path_buf());
444 }
445 dirs_to_fsync.insert(trash_dir.clone());
446 }
447 }
448 }
449
450 if self.write_options.fsync {
453 for d in &dirs_to_fsync {
454 if let Err(e) = writer::fsync_dir(d) {
455 errors.push(MutationError {
456 path: d.clone(),
457 message: format!("fsync_dir failed: {}", e),
458 });
459 }
460 }
461 }
462
463 Ok(MutationReport {
464 changes: report.changes,
465 errors,
466 })
467 })
468 }
469}
470
471fn unique_in_dir(dir: &std::path::Path, src: &std::path::Path) -> PathBuf {
472 let filename = src.file_name().and_then(|n| n.to_str()).unwrap_or("file");
473 let candidate = dir.join(filename);
474 if !candidate.exists() {
475 return candidate;
476 }
477 let stem = src.file_stem().and_then(|n| n.to_str()).unwrap_or("file");
478 let ext = src.extension().and_then(|n| n.to_str()).unwrap_or("md");
479 let mut i = 1;
480 loop {
481 let c = dir.join(format!("{}-{}.{}", stem, i, ext));
482 if !c.exists() {
483 return c;
484 }
485 i += 1;
486 }
487}
488
489#[derive(Debug, Clone)]
495pub struct MoveBuilder {
496 filter: Expr,
497 folder: String,
498 to_folder: String,
499 write_options: writer::WriteOptions,
500}
501
502impl MoveBuilder {
503 pub fn new(folder: impl Into<String>, to_folder: impl Into<String>, filter: Expr) -> Self {
504 Self {
505 filter,
506 folder: folder.into(),
507 to_folder: to_folder.into(),
508 write_options: writer::WriteOptions::default(),
509 }
510 }
511
512 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
514 self.write_options = opts;
515 self
516 }
517
518 pub fn fsync(mut self, yes: bool) -> Self {
520 self.write_options.fsync = yes;
521 self
522 }
523
524 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
525 let folder_path = vault.resolve_folder(&self.folder)?;
526 let to_path = vault.root.join(&self.to_folder);
527 let load = vault.load_records(&folder_path, false, false)?;
528 let needs_links = crate::filter::expr_uses_links(&self.filter);
529 let link_index = if needs_links {
530 Some(crate::links::LinkGraph::build_with_root(
531 &load.records,
532 Some(&vault.root),
533 ))
534 } else {
535 None
536 };
537
538 let mut changes = Vec::new();
539 let mut errors = Vec::new();
540
541 for r in &load.records {
542 if !crate::filter::evaluate_expr(&self.filter, r, &vault.root, link_index.as_ref()) {
543 continue;
544 }
545 let filename = match r.path.file_name() {
546 Some(n) => n,
547 None => continue,
548 };
549 let dest = to_path.join(filename);
550 if dest.exists() {
551 errors.push(MutationError {
552 path: r.path.clone(),
553 message: format!(
554 "move conflict: {} already exists in {}",
555 filename.to_string_lossy(),
556 self.to_folder
557 ),
558 });
559 continue;
560 }
561 changes.push(PlannedChange {
562 path: r.path.clone(),
563 description: format!("move to {}", dest.display()),
564 });
565 }
566 Ok(MutationReport { changes, errors })
567 }
568
569 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
570 crate::lock::with_lock(&vault.root, || {
571 let to_path = vault.root.join(&self.to_folder);
572 let report = self.plan(vault)?;
573 if !report.changes.is_empty() {
574 std::fs::create_dir_all(&to_path).map_err(VaultdbError::Io)?;
575 }
576 let mut errors = report.errors;
577 let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
578 std::collections::BTreeSet::new();
579 for change in &report.changes {
580 let filename = match change.path.file_name() {
581 Some(n) => n,
582 None => continue,
583 };
584 let dest = to_path.join(filename);
585 if let Err(e) = std::fs::rename(&change.path, &dest) {
586 errors.push(MutationError {
587 path: change.path.clone(),
588 message: format!("rename failed: {}", e),
589 });
590 } else {
591 if let Some(parent) = change.path.parent() {
592 dirs_to_fsync.insert(parent.to_path_buf());
593 }
594 dirs_to_fsync.insert(to_path.clone());
595 }
596 }
597
598 if self.write_options.fsync {
599 for d in &dirs_to_fsync {
600 if let Err(e) = writer::fsync_dir(d) {
601 errors.push(MutationError {
602 path: d.clone(),
603 message: format!("fsync_dir failed: {}", e),
604 });
605 }
606 }
607 }
608
609 Ok(MutationReport {
610 changes: report.changes,
611 errors,
612 })
613 })
614 }
615}
616
617#[derive(Debug, Clone)]
626pub struct RenameBuilder {
627 folder: String,
628 from: String,
629 to: String,
630 write_options: writer::WriteOptions,
631}
632
633impl RenameBuilder {
634 pub fn new(folder: impl Into<String>, from: impl Into<String>, to: impl Into<String>) -> Self {
635 Self {
636 folder: folder.into(),
637 from: from.into(),
638 to: to.into(),
639 write_options: writer::WriteOptions::default(),
640 }
641 }
642
643 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
645 self.write_options = opts;
646 self
647 }
648
649 pub fn fsync(mut self, yes: bool) -> Self {
652 self.write_options.fsync = yes;
653 self
654 }
655
656 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
657 let folder_path = vault.resolve_folder(&self.folder)?;
658 let source = folder_path.join(format!("{}.md", self.from));
659 let dest = folder_path.join(format!("{}.md", self.to));
660
661 let mut changes = Vec::new();
662 let mut errors = Vec::new();
663
664 if !source.is_file() {
665 errors.push(MutationError {
666 path: source.clone(),
667 message: format!("source `{}` not found", self.from),
668 });
669 return Ok(MutationReport { changes, errors });
670 }
671 if dest.exists() {
672 errors.push(MutationError {
673 path: dest.clone(),
674 message: format!("target `{}.md` already exists", self.to),
675 });
676 return Ok(MutationReport { changes, errors });
677 }
678
679 changes.push(PlannedChange {
680 path: source.clone(),
681 description: format!("rename to {}", dest.display()),
682 });
683
684 let all = vault.load_records_with_content(&vault.root, true, false)?;
687 let graph = crate::links::LinkGraph::build_with_root(&all.records, Some(&vault.root));
688 for source_name in graph.incoming_links(&self.from) {
689 if let Some(record) = graph.record_by_name(source_name) {
690 changes.push(PlannedChange {
691 path: record.path.clone(),
692 description: format!("rewrite [[{}]] -> [[{}]]", self.from, self.to),
693 });
694 }
695 }
696
697 Ok(MutationReport { changes, errors })
698 }
699
700 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
701 crate::lock::with_lock(&vault.root, || {
702 crate::journal::replay_all(&vault.root)?;
709
710 let folder_path = vault.resolve_folder(&self.folder)?;
711 let source = folder_path.join(format!("{}.md", self.from));
712 let dest = folder_path.join(format!("{}.md", self.to));
713
714 let report = self.plan(vault)?;
715 if !report.errors.is_empty() {
717 return Ok(report);
718 }
719
720 let backlinks: Vec<PathBuf> = report
725 .changes
726 .iter()
727 .skip(1) .map(|c| c.path.clone())
729 .collect();
730 let journal = crate::journal::RenameJournal {
731 source: source.clone(),
732 dest: dest.clone(),
733 from_name: self.from.clone(),
734 to_name: self.to.clone(),
735 backlinks,
736 };
737 let journal_path = crate::journal::write(&vault.root, &journal)?;
738
739 if let Err(e) = std::fs::rename(&source, &dest) {
742 crate::journal::delete(&journal_path);
743 return Ok(MutationReport {
744 changes: report.changes,
745 errors: vec![MutationError {
746 path: source,
747 message: format!("rename failed: {}", e),
748 }],
749 });
750 }
751
752 let mut errors = Vec::new();
756 let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
757 std::collections::BTreeSet::new();
758 if let Some(parent) = source.parent() {
759 dirs_to_fsync.insert(parent.to_path_buf());
760 }
761 if let Some(parent) = dest.parent() {
762 dirs_to_fsync.insert(parent.to_path_buf());
763 }
764 for change in report.changes.iter().skip(1) {
765 let path = &change.path;
766 let content = match std::fs::read_to_string(path) {
767 Ok(c) => c,
768 Err(e) => {
769 errors.push(MutationError {
770 path: path.clone(),
771 message: format!("read failed: {}", e),
772 });
773 continue;
774 }
775 };
776 let new_content = rewrite_wikilinks(&content, &self.from, &self.to);
777 if new_content == content {
778 continue;
779 }
780 if let Err(e) = writer::atomic_write_with(path, &new_content, self.write_options) {
781 errors.push(MutationError {
782 path: path.clone(),
783 message: format!("write failed: {}", e),
784 });
785 }
786 }
787
788 if self.write_options.fsync {
793 for d in &dirs_to_fsync {
794 if let Err(e) = writer::fsync_dir(d) {
795 errors.push(MutationError {
796 path: d.clone(),
797 message: format!("fsync_dir failed: {}", e),
798 });
799 }
800 }
801 }
802
803 if errors.is_empty() {
807 crate::journal::delete(&journal_path);
808 }
809
810 Ok(MutationReport {
811 changes: report.changes,
812 errors,
813 })
814 })
815 }
816}
817
818#[derive(Debug, Clone)]
837pub struct CreateBuilder {
838 folder: String,
839 name: String,
840 template: Option<String>,
841 set_fields: Vec<(String, Value)>,
842 vault_schema: Option<crate::schema::VaultSchema>,
843 write_options: writer::WriteOptions,
844}
845
846impl CreateBuilder {
847 pub fn new(folder: impl Into<String>, name: impl Into<String>) -> Self {
848 Self {
849 folder: folder.into(),
850 name: name.into(),
851 template: None,
852 set_fields: Vec::new(),
853 vault_schema: None,
854 write_options: writer::WriteOptions::default(),
855 }
856 }
857
858 pub fn template(mut self, path: impl Into<String>) -> Self {
862 self.template = Some(path.into());
863 self
864 }
865
866 pub fn set(mut self, field: impl Into<String>, value: Value) -> Self {
869 self.set_fields.push((field.into(), value));
870 self
871 }
872
873 pub fn with_schema(self, schema: crate::schema::CollectionSchema) -> Self {
880 let mut vs = crate::schema::VaultSchema {
881 collections: std::collections::BTreeMap::new(),
882 };
883 vs.collections.insert("__single__".to_string(), schema);
884 self.with_vault_schema(vs)
885 }
886
887 pub fn with_vault_schema(mut self, schema: crate::schema::VaultSchema) -> Self {
894 self.vault_schema = Some(schema);
895 self
896 }
897
898 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
899 self.write_options = opts;
900 self
901 }
902
903 pub fn fsync(mut self, yes: bool) -> Self {
904 self.write_options.fsync = yes;
905 self
906 }
907
908 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
910 let (report, _) = self.compute(vault)?;
911 Ok(report)
912 }
913
914 pub fn plan_with_content(&self, vault: &Vault) -> Result<(MutationReport, Option<String>)> {
918 let (report, write) = self.compute(vault)?;
919 Ok((report, write.map(|w| w.modified_content)))
920 }
921
922 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
926 crate::lock::with_lock(&vault.root, || {
927 let (report, write) = self.compute(vault)?;
928 if !report.errors.is_empty() {
929 return Ok(report);
930 }
931 if let Some(w) = write {
932 if let Some(parent) = w.path.parent()
933 && !parent.exists()
934 {
935 std::fs::create_dir_all(parent).map_err(VaultdbError::Io)?;
936 }
937 writer::atomic_create_with(&w.path, &w.modified_content, self.write_options)
943 .map_err(VaultdbError::Io)?;
944 }
945 Ok(report)
946 })
947 }
948
949 fn compute(&self, vault: &Vault) -> Result<(MutationReport, Option<WriteResult>)> {
950 let folder_path = vault.root.join(&self.folder);
955 let filename = format!("{}.md", self.name);
956 let dest = folder_path.join(&filename);
957
958 let mut changes = Vec::new();
959 let mut errors = Vec::new();
960
961 if dest.exists() {
962 errors.push(MutationError {
963 path: dest.clone(),
964 message: format!("file already exists: {}", dest.display()),
965 });
966 return Ok((MutationReport { changes, errors }, None));
967 }
968
969 let (mut fields, body) = match &self.template {
973 Some(tmpl) => {
974 let tmpl_path = vault.root.join(tmpl);
975 if !tmpl_path.is_file() {
976 errors.push(MutationError {
977 path: tmpl_path.clone(),
978 message: format!("template not found: {}", tmpl_path.display()),
979 });
980 return Ok((MutationReport { changes, errors }, None));
981 }
982 let raw = std::fs::read_to_string(&tmpl_path).map_err(VaultdbError::Io)?;
983 split_template(&raw)
984 }
985 None => (
986 std::collections::BTreeMap::<String, Value>::new(),
987 format!("\n# {}\n", self.name),
988 ),
989 };
990
991 for (k, v) in &self.set_fields {
993 fields.insert(k.clone(), v.clone());
994 }
995
996 if let Some(vault_schema) = &self.vault_schema {
1003 let projected = crate::record::Record {
1004 path: dest.clone(),
1005 fields: fields.clone(),
1006 raw_content: None,
1007 };
1008 let applicable =
1009 match vault_schema.applicable_collections(&self.folder, &projected, &vault.root) {
1010 Ok(cols) => cols,
1011 Err(e) => {
1012 errors.push(MutationError {
1013 path: dest.clone(),
1014 message: format!("evaluating schema applicability: {}", e),
1015 });
1016 return Ok((MutationReport { changes, errors }, None));
1017 }
1018 };
1019
1020 for col in &applicable {
1024 for (fname, fs) in &col.fields {
1025 if fields.contains_key(fname) {
1026 continue;
1027 }
1028 if let Some(default) = &fs.default {
1029 fields.insert(fname.clone(), default.clone());
1030 } else if let Some(expr) = &fs.default_expr {
1031 match crate::schema::resolve_default_expr(expr) {
1032 Ok(v) => {
1033 fields.insert(fname.clone(), v);
1034 }
1035 Err(e) => {
1036 errors.push(MutationError {
1037 path: dest.clone(),
1038 message: format!(
1039 "resolving default_expr for '{}': {}",
1040 fname, e
1041 ),
1042 });
1043 }
1044 }
1045 }
1046 }
1047 }
1048
1049 let mut seen = std::collections::BTreeSet::<(String, String)>::new();
1054 for col in &applicable {
1055 for v in crate::schema::validate_record(&filename, &fields, col) {
1056 if seen.insert((v.field.clone(), v.message.clone())) {
1057 errors.push(MutationError {
1058 path: dest.clone(),
1059 message: format!("schema: {} — {}", v.field, v.message),
1060 });
1061 }
1062 }
1063 }
1064 }
1065
1066 if !errors.is_empty() {
1067 return Ok((MutationReport { changes, errors }, None));
1068 }
1069
1070 let frontmatter_yaml = if fields.is_empty() {
1074 String::new()
1075 } else {
1076 serde_yaml::to_string(&fields)
1077 .map_err(|e| VaultdbError::SchemaError(format!("rendering frontmatter: {}", e)))?
1078 };
1079 let content = if frontmatter_yaml.is_empty() {
1080 format!("---\n---\n{}", body)
1081 } else {
1082 format!("---\n{}---\n{}", frontmatter_yaml, body)
1083 };
1084
1085 let field_count = fields.len();
1086 let field_summary: String = fields.keys().cloned().collect::<Vec<_>>().join(", ");
1087 let description = if field_count == 0 {
1088 "create (no frontmatter fields)".to_string()
1089 } else {
1090 format!("create with {} field(s): {}", field_count, field_summary)
1091 };
1092
1093 changes.push(PlannedChange {
1094 path: dest.clone(),
1095 description,
1096 });
1097
1098 let write = WriteResult {
1099 path: dest,
1100 original_content: String::new(),
1101 modified_content: content,
1102 changes: Vec::new(),
1103 };
1104
1105 Ok((MutationReport { changes, errors }, Some(write)))
1106 }
1107}
1108
1109fn split_template(raw: &str) -> (std::collections::BTreeMap<String, Value>, String) {
1113 use crate::frontmatter::{extract_frontmatter, parse_frontmatter};
1114 match extract_frontmatter(raw) {
1115 Some((yaml_text, body_start)) => {
1116 let fields = parse_frontmatter(yaml_text).unwrap_or_default();
1117 let body = raw[body_start..].to_string();
1118 (fields, body)
1119 }
1120 None => (std::collections::BTreeMap::new(), raw.to_string()),
1121 }
1122}
1123
1124pub(crate) fn rewrite_wikilinks(content: &str, from: &str, to: &str) -> String {
1127 content
1128 .replace(&format!("[[{}]]", from), &format!("[[{}]]", to))
1129 .replace(&format!("[[{}|", from), &format!("[[{}|", to))
1130 .replace(&format!("[[{}#", from), &format!("[[{}#", to))
1131}
1132
1133fn render_value_for_yaml(v: &Value) -> String {
1139 match v {
1140 Value::Null => "null".to_string(),
1141 Value::Bool(b) => b.to_string(),
1142 Value::Integer(i) => i.to_string(),
1143 Value::Float(f) => f.to_string(),
1144 Value::String(s) => writer::quote_value(s),
1145 Value::List(_) | Value::Map(_) => {
1146 let yaml = serde_yaml::to_string(v).unwrap_or_default();
1147 yaml.trim_end().to_string()
1148 }
1149 }
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154 use super::*;
1155 use crate::query::Predicate;
1156
1157 #[test]
1163 fn update_builder_writes_list_as_block_yaml() {
1164 use std::fs;
1165 use tempfile::TempDir;
1166 let dir = TempDir::new().unwrap();
1167 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1168 fs::create_dir(dir.path().join("notes")).unwrap();
1169 fs::write(
1170 dir.path().join("notes/cat.md"),
1171 "---\nanlam: kedi\n---\n\n# 猫\n",
1172 )
1173 .unwrap();
1174 let vault = Vault::with_root(dir.path().to_path_buf());
1175
1176 let filter = Expr::Predicate(Predicate::Equals {
1177 field: "_name".into(),
1178 value: Value::String("cat".into()),
1179 });
1180 let anlamlar = Value::List(vec![
1181 Value::String("kedi".into()),
1182 Value::String("pisi".into()),
1183 ]);
1184 let report = UpdateBuilder::new("notes", filter)
1185 .set("anlamlar", anlamlar)
1186 .execute(&vault)
1187 .unwrap();
1188 assert_eq!(report.errors.len(), 0);
1189 assert_eq!(report.changes.len(), 1);
1190
1191 let written = fs::read_to_string(dir.path().join("notes/cat.md")).unwrap();
1192 assert!(
1194 written.contains("anlamlar:\n- kedi\n- pisi"),
1195 "got:\n{}",
1196 written
1197 );
1198 assert!(!written.contains("anlamlar: '- kedi"), "got:\n{}", written);
1200
1201 let records = vault
1205 .load_records(&dir.path().join("notes"), false, false)
1206 .unwrap()
1207 .records;
1208 let cat = &records[0];
1209 match cat.fields.get("anlamlar") {
1210 Some(Value::List(items)) => {
1211 assert_eq!(items.len(), 2);
1212 assert!(matches!(&items[0], Value::String(s) if s == "kedi"));
1213 assert!(matches!(&items[1], Value::String(s) if s == "pisi"));
1214 }
1215 other => panic!("expected Value::List, got {:?}", other),
1216 }
1217 }
1218
1219 #[test]
1220 fn update_builder_chains() {
1221 let filter = Expr::Predicate(Predicate::Equals {
1222 field: "status".into(),
1223 value: Value::String("active".into()),
1224 });
1225 let b = UpdateBuilder::new("notes", filter)
1226 .set("priority", Value::Integer(1))
1227 .unset("draft")
1228 .add_tag("urgent")
1229 .remove_tag("stale");
1230 assert_eq!(b.set_fields.len(), 1);
1231 assert_eq!(b.unset_fields.len(), 1);
1232 assert_eq!(b.add_tags.len(), 1);
1233 assert_eq!(b.remove_tags.len(), 1);
1234 }
1235
1236 #[test]
1237 fn delete_builder_trash_moves_to_dot_trash() {
1238 use std::fs;
1239 use tempfile::TempDir;
1240 let dir = TempDir::new().unwrap();
1241 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1242 fs::create_dir(dir.path().join("notes")).unwrap();
1243 fs::write(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
1244 let vault = Vault::with_root(dir.path().to_path_buf());
1245 let filter = Expr::Predicate(Predicate::Equals {
1246 field: "status".into(),
1247 value: Value::String("stale".into()),
1248 });
1249 let builder = DeleteBuilder::new("notes", filter);
1250 let report = builder.execute(&vault).unwrap();
1251 assert_eq!(report.changes.len(), 1);
1252 assert_eq!(report.errors.len(), 0);
1253 assert!(!dir.path().join("notes/a.md").exists());
1254 assert!(dir.path().join(".trash/a.md").exists());
1255 }
1256
1257 #[test]
1258 fn delete_builder_permanent_removes_file() {
1259 use std::fs;
1260 use tempfile::TempDir;
1261 let dir = TempDir::new().unwrap();
1262 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1263 fs::create_dir(dir.path().join("notes")).unwrap();
1264 fs::write(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
1265 let vault = Vault::with_root(dir.path().to_path_buf());
1266 let filter = Expr::Predicate(Predicate::Equals {
1267 field: "status".into(),
1268 value: Value::String("stale".into()),
1269 });
1270 let builder = DeleteBuilder::new("notes", filter).permanent(true);
1271 builder.execute(&vault).unwrap();
1272 assert!(!dir.path().join("notes/a.md").exists());
1273 assert!(!dir.path().join(".trash/a.md").exists());
1274 }
1275
1276 #[test]
1277 fn move_builder_relocates_files() {
1278 use std::fs;
1279 use tempfile::TempDir;
1280 let dir = TempDir::new().unwrap();
1281 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1282 fs::create_dir(dir.path().join("notes")).unwrap();
1283 fs::write(
1284 dir.path().join("notes/a.md"),
1285 "---\nstatus: archived\n---\n",
1286 )
1287 .unwrap();
1288 let vault = Vault::with_root(dir.path().to_path_buf());
1289 let filter = Expr::Predicate(Predicate::Equals {
1290 field: "status".into(),
1291 value: Value::String("archived".into()),
1292 });
1293 let builder = MoveBuilder::new("notes", "archive", filter);
1294 let report = builder.execute(&vault).unwrap();
1295 assert_eq!(report.changes.len(), 1);
1296 assert_eq!(report.errors.len(), 0);
1297 assert!(!dir.path().join("notes/a.md").exists());
1298 assert!(dir.path().join("archive/a.md").exists());
1299 }
1300
1301 #[test]
1302 fn rename_builder_renames_and_rewrites_links() {
1303 use std::fs;
1304 use tempfile::TempDir;
1305 let dir = TempDir::new().unwrap();
1306 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1307 fs::create_dir(dir.path().join("notes")).unwrap();
1308 fs::write(
1309 dir.path().join("notes/old.md"),
1310 "---\nstatus: x\n---\nBody\n",
1311 )
1312 .unwrap();
1313 fs::write(
1314 dir.path().join("notes/source.md"),
1315 "---\nstatus: y\n---\nLinks to [[old]] and [[old|alias]] and [[old#section]].\n",
1316 )
1317 .unwrap();
1318 let vault = Vault::with_root(dir.path().to_path_buf());
1319
1320 let builder = RenameBuilder::new("notes", "old", "new");
1321 let report = builder.execute(&vault).unwrap();
1322 assert_eq!(report.changes.len(), 2);
1324 assert_eq!(report.errors.len(), 0);
1325 assert!(!dir.path().join("notes/old.md").exists());
1326 assert!(dir.path().join("notes/new.md").exists());
1327 let source_after = fs::read_to_string(dir.path().join("notes/source.md")).unwrap();
1328 assert!(source_after.contains("[[new]]"));
1329 assert!(source_after.contains("[[new|alias]]"));
1330 assert!(source_after.contains("[[new#section]]"));
1331 assert!(!source_after.contains("[[old"));
1332 }
1333
1334 #[test]
1335 fn rename_builder_target_conflict_returns_error() {
1336 use std::fs;
1337 use tempfile::TempDir;
1338 let dir = TempDir::new().unwrap();
1339 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1340 fs::create_dir(dir.path().join("notes")).unwrap();
1341 fs::write(dir.path().join("notes/old.md"), "---\nstatus: x\n---\n").unwrap();
1342 fs::write(dir.path().join("notes/new.md"), "---\nstatus: y\n---\n").unwrap();
1343 let vault = Vault::with_root(dir.path().to_path_buf());
1344 let report = RenameBuilder::new("notes", "old", "new")
1345 .execute(&vault)
1346 .unwrap();
1347 assert_eq!(report.changes.len(), 0);
1348 assert_eq!(report.errors.len(), 1);
1349 assert!(dir.path().join("notes/old.md").exists());
1351 }
1352
1353 #[test]
1354 fn update_builder_plan_and_execute_against_a_temp_vault() {
1355 use std::fs;
1356 use tempfile::TempDir;
1357
1358 let dir = TempDir::new().unwrap();
1359 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1360 fs::create_dir(dir.path().join("notes")).unwrap();
1361 fs::write(
1362 dir.path().join("notes/a.md"),
1363 "---\nstatus: active\n---\nBody A\n",
1364 )
1365 .unwrap();
1366 fs::write(
1367 dir.path().join("notes/b.md"),
1368 "---\nstatus: pending\n---\nBody B\n",
1369 )
1370 .unwrap();
1371
1372 let vault = Vault::with_root(dir.path().to_path_buf());
1373
1374 let filter = Expr::Predicate(Predicate::Equals {
1375 field: "status".into(),
1376 value: Value::String("active".into()),
1377 });
1378 let builder = UpdateBuilder::new("notes", filter).set("priority", Value::Integer(1));
1379
1380 let plan_report = builder.plan(&vault).unwrap();
1382 assert_eq!(plan_report.changes.len(), 1);
1383 assert_eq!(plan_report.errors.len(), 0);
1384 assert!(plan_report.changes[0].path.ends_with("a.md"));
1385 let before = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
1386 assert!(!before.contains("priority"));
1387
1388 let exec_report = builder.execute(&vault).unwrap();
1390 assert_eq!(exec_report.changes.len(), 1);
1391 let after = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
1392 assert!(after.contains("priority"));
1393 let b_after = fs::read_to_string(dir.path().join("notes/b.md")).unwrap();
1395 assert!(!b_after.contains("priority"));
1396 }
1397
1398 #[test]
1399 fn write_options_fsync_propagates_through_update_builder() {
1400 use std::fs;
1408 use tempfile::TempDir;
1409
1410 let f1 = Expr::Predicate(Predicate::Equals {
1412 field: "x".into(),
1413 value: Value::Integer(1),
1414 });
1415 let b = UpdateBuilder::new("notes", f1).fsync(true);
1416 assert!(b.write_options.fsync);
1417
1418 let f2 = Expr::Predicate(Predicate::Equals {
1419 field: "x".into(),
1420 value: Value::Integer(1),
1421 });
1422 let b =
1423 UpdateBuilder::new("notes", f2).write_options(crate::writer::WriteOptions::durable());
1424 assert!(b.write_options.fsync);
1425
1426 let dir = TempDir::new().unwrap();
1428 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1429 fs::create_dir(dir.path().join("notes")).unwrap();
1430 fs::write(
1431 dir.path().join("notes/durable.md"),
1432 "---\nstatus: active\n---\nBody.\n",
1433 )
1434 .unwrap();
1435 let vault = Vault::with_root(dir.path().to_path_buf());
1436
1437 let f3 = Expr::Predicate(Predicate::Equals {
1438 field: "status".into(),
1439 value: Value::String("active".into()),
1440 });
1441 let report = UpdateBuilder::new("notes", f3)
1442 .set("priority", Value::Integer(99))
1443 .fsync(true)
1444 .execute(&vault)
1445 .unwrap();
1446 assert_eq!(report.changes.len(), 1);
1447 assert_eq!(report.errors.len(), 0);
1448
1449 let after = fs::read_to_string(dir.path().join("notes/durable.md")).unwrap();
1450 assert!(after.contains("priority: 99"));
1451 assert!(after.contains("status: active"));
1452 }
1453
1454 #[test]
1455 fn rename_clean_run_leaves_no_journal_behind() {
1456 use std::fs;
1459 use tempfile::TempDir;
1460 let dir = TempDir::new().unwrap();
1461 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1462 fs::create_dir(dir.path().join("notes")).unwrap();
1463 fs::write(
1464 dir.path().join("notes/old.md"),
1465 "---\nstatus: x\n---\nBody\n",
1466 )
1467 .unwrap();
1468 fs::write(
1469 dir.path().join("notes/source.md"),
1470 "---\nstatus: y\n---\nLinks to [[old]].\n",
1471 )
1472 .unwrap();
1473 let vault = Vault::with_root(dir.path().to_path_buf());
1474
1475 RenameBuilder::new("notes", "old", "new")
1476 .execute(&vault)
1477 .unwrap();
1478
1479 let pending = crate::journal::list_pending(dir.path()).unwrap();
1480 assert!(
1481 pending.is_empty(),
1482 "successful rename must not leave journals behind: {:?}",
1483 pending
1484 );
1485 }
1486
1487 #[test]
1488 fn rename_recovers_from_pre_existing_journal() {
1489 use std::fs;
1493 use tempfile::TempDir;
1494 let dir = TempDir::new().unwrap();
1495 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1496 fs::create_dir(dir.path().join("notes")).unwrap();
1497 let source = dir.path().join("notes/Stanford.md");
1498 let dest = dir.path().join("notes/Stanford University.md");
1499 let backlink = dir.path().join("notes/Application.md");
1500 fs::write(&source, "---\nkind: university\n---\nMain note.\n").unwrap();
1501 fs::write(
1502 &backlink,
1503 "---\nkind: application\n---\nApplied to [[Stanford]].\n",
1504 )
1505 .unwrap();
1506
1507 let journal = crate::journal::RenameJournal {
1509 source: source.clone(),
1510 dest: dest.clone(),
1511 from_name: "Stanford".into(),
1512 to_name: "Stanford University".into(),
1513 backlinks: vec![backlink.clone()],
1514 };
1515 crate::journal::write(dir.path(), &journal).unwrap();
1516
1517 let vault = Vault::with_root(dir.path().to_path_buf());
1518 let recovered = vault.recover().unwrap();
1519 assert_eq!(recovered, 1, "expected exactly one journal replayed");
1520
1521 assert!(!source.exists());
1523 assert!(dest.is_file());
1524 let backlink_content = fs::read_to_string(&backlink).unwrap();
1525 assert!(backlink_content.contains("[[Stanford University]]"));
1526 assert!(!backlink_content.contains("[[Stanford]]"));
1527 assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
1528 }
1529
1530 #[test]
1531 fn rename_replays_pending_journal_before_starting_new_rename() {
1532 use std::fs;
1536 use tempfile::TempDir;
1537 let dir = TempDir::new().unwrap();
1538 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1539 fs::create_dir(dir.path().join("notes")).unwrap();
1540
1541 let a = dir.path().join("notes/A.md");
1545 let b = dir.path().join("notes/B.md");
1546 let c = dir.path().join("notes/C.md");
1547 let d = dir.path().join("notes/D.md");
1548 fs::write(&a, "---\n---\nA body.\n").unwrap();
1549 fs::write(&c, "---\n---\nC body.\n").unwrap();
1550
1551 crate::journal::write(
1553 dir.path(),
1554 &crate::journal::RenameJournal {
1555 source: a.clone(),
1556 dest: b.clone(),
1557 from_name: "A".into(),
1558 to_name: "B".into(),
1559 backlinks: vec![],
1560 },
1561 )
1562 .unwrap();
1563
1564 let vault = Vault::with_root(dir.path().to_path_buf());
1566 RenameBuilder::new("notes", "C", "D")
1567 .execute(&vault)
1568 .unwrap();
1569
1570 assert!(!a.exists(), "A.md should be gone (replayed journal)");
1573 assert!(b.is_file(), "B.md should exist (replayed journal)");
1574 assert!(!c.exists(), "C.md should be gone (new rename)");
1575 assert!(d.is_file(), "D.md should exist (new rename)");
1576
1577 assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
1579 }
1580
1581 #[test]
1582 fn concurrent_updates_serialize_via_vault_lock() {
1583 use std::fs;
1591 use std::sync::Arc;
1592 use std::thread;
1593 use tempfile::TempDir;
1594
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 fs::write(
1599 dir.path().join("notes/race.md"),
1600 "---\nstatus: active\n---\nBody.\n",
1601 )
1602 .unwrap();
1603
1604 let vault_path = Arc::new(dir.path().to_path_buf());
1605
1606 let p1 = Arc::clone(&vault_path);
1607 let t1 = thread::spawn(move || {
1608 let vault = Vault::with_root((*p1).clone());
1609 let filter = Expr::Predicate(Predicate::Equals {
1610 field: "status".into(),
1611 value: Value::String("active".into()),
1612 });
1613 UpdateBuilder::new("notes", filter)
1614 .set("touched_by_t1", Value::Integer(1))
1615 .execute(&vault)
1616 .expect("t1 execute")
1617 });
1618
1619 let p2 = Arc::clone(&vault_path);
1620 let t2 = thread::spawn(move || {
1621 let vault = Vault::with_root((*p2).clone());
1622 let filter = Expr::Predicate(Predicate::Equals {
1623 field: "status".into(),
1624 value: Value::String("active".into()),
1625 });
1626 UpdateBuilder::new("notes", filter)
1627 .set("touched_by_t2", Value::Integer(1))
1628 .execute(&vault)
1629 .expect("t2 execute")
1630 });
1631
1632 let r1 = t1.join().unwrap();
1633 let r2 = t2.join().unwrap();
1634 assert_eq!(r1.errors.len(), 0);
1635 assert_eq!(r2.errors.len(), 0);
1636
1637 let final_content = fs::read_to_string(dir.path().join("notes/race.md")).unwrap();
1641 assert!(
1642 final_content.contains("touched_by_t1"),
1643 "t1's edit lost; concurrent writer race: {}",
1644 final_content
1645 );
1646 assert!(
1647 final_content.contains("touched_by_t2"),
1648 "t2's edit lost; concurrent writer race: {}",
1649 final_content
1650 );
1651 }
1652
1653 #[test]
1654 fn atomic_write_does_not_leave_partial_files_on_failed_writes() {
1655 use std::fs;
1661 use tempfile::TempDir;
1662
1663 let dir = TempDir::new().unwrap();
1664 let target = dir.path().join("subdir/that-does-not-exist/x.md");
1665 let result = crate::writer::atomic_write(&target, "new content");
1668 assert!(
1669 result.is_err(),
1670 "expected atomic_write to fail when parent dir doesn't exist"
1671 );
1672
1673 let real_dir = dir.path().join("real");
1676 fs::create_dir(&real_dir).unwrap();
1677 let real_target = real_dir.join("x.md");
1678 fs::write(&real_target, "original").unwrap();
1679 crate::writer::atomic_write(&real_target, "replacement").unwrap();
1680 let after = fs::read_to_string(&real_target).unwrap();
1681 assert_eq!(after, "replacement");
1682
1683 let leftovers: Vec<_> = fs::read_dir(&real_dir)
1685 .unwrap()
1686 .flatten()
1687 .filter(|e| e.file_name().to_string_lossy().starts_with(".tmp"))
1688 .collect();
1689 assert!(
1690 leftovers.is_empty(),
1691 "expected no tempfile leftovers, found: {:?}",
1692 leftovers.iter().map(|e| e.path()).collect::<Vec<_>>()
1693 );
1694 }
1695
1696 use crate::schema::{CollectionSchema, FieldSchema};
1699
1700 fn movie_schema() -> CollectionSchema {
1701 let mut fields = std::collections::BTreeMap::new();
1702 fields.insert(
1703 "db-table".into(),
1704 FieldSchema {
1705 field_type: "string".into(),
1706 enum_values: vec![Value::String("movie".into())],
1707 min: None,
1708 max: None,
1709 default: Some(Value::String("movie".into())),
1710 default_expr: None,
1711 },
1712 );
1713 fields.insert(
1714 "status".into(),
1715 FieldSchema {
1716 field_type: "string".into(),
1717 enum_values: vec![
1718 Value::String("to-watch".into()),
1719 Value::String("watched".into()),
1720 ],
1721 min: None,
1722 max: None,
1723 default: Some(Value::String("to-watch".into())),
1724 default_expr: None,
1725 },
1726 );
1727 fields.insert(
1728 "director".into(),
1729 FieldSchema {
1730 field_type: "string".into(),
1731 enum_values: vec![],
1732 min: None,
1733 max: None,
1734 default: None,
1735 default_expr: None,
1736 },
1737 );
1738 fields.insert(
1739 "year".into(),
1740 FieldSchema {
1741 field_type: "integer".into(),
1742 enum_values: vec![],
1743 min: None,
1744 max: None,
1745 default: None,
1746 default_expr: None,
1747 },
1748 );
1749 CollectionSchema {
1750 description: None,
1751 folder: "Notes/movie".into(),
1752 filter: vec![],
1753 required: vec![
1754 "db-table".into(),
1755 "director".into(),
1756 "status".into(),
1757 "year".into(),
1758 ],
1759 fields,
1760 }
1761 }
1762
1763 fn vault_with_obsidian() -> tempfile::TempDir {
1764 let dir = tempfile::TempDir::new().unwrap();
1765 std::fs::create_dir(dir.path().join(".obsidian")).unwrap();
1766 dir
1767 }
1768
1769 #[test]
1770 fn create_without_schema_writes_minimal_file() {
1771 let dir = vault_with_obsidian();
1772 let vault = Vault::with_root(dir.path().to_path_buf());
1773 let report = CreateBuilder::new("Notes/movie", "Dune")
1774 .execute(&vault)
1775 .unwrap();
1776 assert_eq!(report.errors.len(), 0);
1777 assert_eq!(report.changes.len(), 1);
1778 let written = dir.path().join("Notes/movie/Dune.md");
1779 assert!(written.is_file());
1780 let content = std::fs::read_to_string(&written).unwrap();
1781 assert!(content.contains("---\n---"));
1783 assert!(content.contains("# Dune"));
1784 }
1785
1786 #[test]
1787 fn create_with_set_writes_typed_frontmatter() {
1788 let dir = vault_with_obsidian();
1789 let vault = Vault::with_root(dir.path().to_path_buf());
1790 CreateBuilder::new("Notes/movie", "Dune")
1791 .set("director", Value::String("Denis Villeneuve".into()))
1792 .set("year", Value::Integer(2021))
1793 .execute(&vault)
1794 .unwrap();
1795 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1796 assert!(content.contains("director: Denis Villeneuve"));
1797 assert!(content.contains("year: 2021"));
1799 }
1800
1801 #[test]
1802 fn create_fills_schema_defaults() {
1803 let dir = vault_with_obsidian();
1804 let vault = Vault::with_root(dir.path().to_path_buf());
1805 CreateBuilder::new("Notes/movie", "Dune")
1806 .with_schema(movie_schema())
1807 .set("director", Value::String("Denis Villeneuve".into()))
1808 .set("year", Value::Integer(2021))
1809 .execute(&vault)
1810 .unwrap();
1811 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1812 assert!(content.contains("db-table: movie"));
1814 assert!(content.contains("status: to-watch"));
1815 assert!(content.contains("director: Denis Villeneuve"));
1817 assert!(content.contains("year: 2021"));
1818 }
1819
1820 #[test]
1821 fn create_set_overrides_default() {
1822 let dir = vault_with_obsidian();
1823 let vault = Vault::with_root(dir.path().to_path_buf());
1824 CreateBuilder::new("Notes/movie", "Watched")
1825 .with_schema(movie_schema())
1826 .set("director", Value::String("X".into()))
1827 .set("year", Value::Integer(2020))
1828 .set("status", Value::String("watched".into()))
1829 .execute(&vault)
1830 .unwrap();
1831 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Watched.md")).unwrap();
1832 assert!(content.contains("status: watched"));
1833 assert!(!content.contains("status: to-watch"));
1834 }
1835
1836 #[test]
1837 fn create_rejects_missing_required_before_writing() {
1838 let dir = vault_with_obsidian();
1839 let vault = Vault::with_root(dir.path().to_path_buf());
1840 let report = CreateBuilder::new("Notes/movie", "Blank")
1842 .with_schema(movie_schema())
1843 .execute(&vault)
1844 .unwrap();
1845 assert!(!report.errors.is_empty());
1846 assert!(report.errors.iter().any(|e| e.message.contains("director")));
1847 assert!(report.errors.iter().any(|e| e.message.contains("year")));
1848 assert!(!dir.path().join("Notes/movie/Blank.md").exists());
1850 }
1851
1852 #[test]
1853 fn create_rejects_existing_file() {
1854 let dir = vault_with_obsidian();
1855 std::fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
1856 std::fs::write(dir.path().join("Notes/movie/Dune.md"), "existing\n").unwrap();
1857 let vault = Vault::with_root(dir.path().to_path_buf());
1858 let report = CreateBuilder::new("Notes/movie", "Dune")
1859 .execute(&vault)
1860 .unwrap();
1861 assert_eq!(report.errors.len(), 1);
1862 assert!(report.errors[0].message.contains("already exists"));
1863 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1865 assert_eq!(content, "existing\n");
1866 }
1867
1868 #[test]
1869 fn create_resolves_default_expr_today() {
1870 let dir = vault_with_obsidian();
1871 let vault = Vault::with_root(dir.path().to_path_buf());
1872 let mut fields = std::collections::BTreeMap::new();
1873 fields.insert(
1874 "due".into(),
1875 FieldSchema {
1876 field_type: "date".into(),
1877 enum_values: vec![],
1878 min: None,
1879 max: None,
1880 default: None,
1881 default_expr: Some("today".into()),
1882 },
1883 );
1884 let schema = CollectionSchema {
1885 description: None,
1886 folder: "tasks".into(),
1887 filter: vec![],
1888 required: vec![],
1889 fields,
1890 };
1891 CreateBuilder::new("tasks", "t1")
1892 .with_schema(schema)
1893 .execute(&vault)
1894 .unwrap();
1895 let content = std::fs::read_to_string(dir.path().join("tasks/t1.md")).unwrap();
1896 let today = crate::record::today_string();
1898 assert!(
1899 content.contains(&format!("due: {}", today)),
1900 "expected due={} in: {}",
1901 today,
1902 content
1903 );
1904 }
1905
1906 #[test]
1907 fn create_plan_does_not_touch_disk() {
1908 let dir = vault_with_obsidian();
1909 let vault = Vault::with_root(dir.path().to_path_buf());
1910 let (report, content) = CreateBuilder::new("Notes/movie", "Dune")
1911 .with_schema(movie_schema())
1912 .set("director", Value::String("DV".into()))
1913 .set("year", Value::Integer(2021))
1914 .plan_with_content(&vault)
1915 .unwrap();
1916 assert_eq!(report.errors.len(), 0);
1917 assert_eq!(report.changes.len(), 1);
1918 assert!(!dir.path().join("Notes/movie/Dune.md").exists());
1919 let c = content.unwrap();
1920 assert!(c.contains("director: DV"));
1921 assert!(c.contains("db-table: movie")); }
1923
1924 #[test]
1925 fn create_from_template_preserves_body_and_merges_frontmatter() {
1926 let dir = vault_with_obsidian();
1927 std::fs::create_dir_all(dir.path().join("templates")).unwrap();
1928 std::fs::write(
1929 dir.path().join("templates/movie.md"),
1930 "---\nstatus: to-watch\naliases: []\n---\n\n# Title\n\nReview goes here.\n",
1931 )
1932 .unwrap();
1933 let vault = Vault::with_root(dir.path().to_path_buf());
1934 CreateBuilder::new("Notes/movie", "Dune")
1935 .template("templates/movie.md")
1936 .set("year", Value::Integer(2021))
1937 .execute(&vault)
1938 .unwrap();
1939 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1940 assert!(content.contains("status: to-watch"));
1942 assert!(content.contains("year: 2021"));
1944 assert!(content.contains("Review goes here"));
1946 }
1947
1948 use crate::schema::VaultSchema;
1957
1958 fn vault_schema_movies() -> VaultSchema {
1959 let mut vs = VaultSchema {
1962 collections: std::collections::BTreeMap::new(),
1963 };
1964 vs.collections.insert("movies".into(), movie_schema());
1965 vs
1966 }
1967
1968 fn vault_schema_catchall_and_movies() -> VaultSchema {
1969 let mut collections = std::collections::BTreeMap::new();
1973
1974 let mut catchall_fields = std::collections::BTreeMap::new();
1975 catchall_fields.insert(
1976 "db-table".into(),
1977 FieldSchema {
1978 field_type: "string".into(),
1979 enum_values: vec![Value::String("movie".into()), Value::String("book".into())],
1980 min: None,
1981 max: None,
1982 default: None,
1983 default_expr: None,
1984 },
1985 );
1986 collections.insert(
1987 "Notes".into(),
1988 CollectionSchema {
1989 description: None,
1990 folder: "Notes".into(),
1991 filter: vec![],
1992 required: vec!["db-table".into()],
1993 fields: catchall_fields,
1994 },
1995 );
1996 collections.insert("movies".into(), {
1997 let mut m = movie_schema();
1998 m.filter = vec!["db-table = movie".into()];
1999 m
2000 });
2001
2002 VaultSchema { collections }
2003 }
2004
2005 #[test]
2006 fn update_rejects_type_mismatch() {
2007 use std::fs;
2008 let dir = vault_with_obsidian();
2009 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2010 fs::write(
2011 dir.path().join("Notes/movie/Dune.md"),
2012 "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\nBody\n",
2013 )
2014 .unwrap();
2015 let vault = Vault::with_root(dir.path().to_path_buf());
2016
2017 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2018 field: "director".into(),
2019 value: Value::String("DV".into()),
2020 });
2021 let report = UpdateBuilder::new("Notes/movie", filter)
2022 .set("year", Value::String("nope".into()))
2023 .with_vault_schema(vault_schema_movies())
2024 .execute(&vault)
2025 .unwrap();
2026
2027 assert!(report.changes.is_empty(), "no write should be reported");
2028 assert!(
2029 report
2030 .errors
2031 .iter()
2032 .any(|e| e.message.contains("year") && e.message.contains("integer")),
2033 "expected year/integer type-mismatch error, got: {:?}",
2034 report.errors
2035 );
2036 let content = fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2038 assert!(content.contains("year: 2021"));
2039 assert!(!content.contains("year: nope"));
2040 }
2041
2042 #[test]
2043 fn update_rejects_enum_violation() {
2044 use std::fs;
2045 let dir = vault_with_obsidian();
2046 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2047 fs::write(
2048 dir.path().join("Notes/movie/Dune.md"),
2049 "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\n",
2050 )
2051 .unwrap();
2052 let vault = Vault::with_root(dir.path().to_path_buf());
2053
2054 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2055 field: "director".into(),
2056 value: Value::String("DV".into()),
2057 });
2058 let report = UpdateBuilder::new("Notes/movie", filter)
2059 .set("status", Value::String("watching".into()))
2060 .with_vault_schema(vault_schema_movies())
2061 .execute(&vault)
2062 .unwrap();
2063
2064 assert!(report.changes.is_empty());
2065 assert!(
2066 report
2067 .errors
2068 .iter()
2069 .any(|e| e.message.contains("status") && e.message.contains("watching")),
2070 "expected status enum violation, got: {:?}",
2071 report.errors
2072 );
2073 }
2074
2075 #[test]
2076 fn update_passes_when_unconstrained_field_changes() {
2077 use std::fs;
2081 let dir = vault_with_obsidian();
2082 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2083 fs::write(
2084 dir.path().join("Notes/movie/Dune.md"),
2085 "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\n",
2086 )
2087 .unwrap();
2088 let vault = Vault::with_root(dir.path().to_path_buf());
2089
2090 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2091 field: "director".into(),
2092 value: Value::String("DV".into()),
2093 });
2094 let report = UpdateBuilder::new("Notes/movie", filter)
2095 .set("notes-to-self", Value::String("rewatch".into()))
2096 .with_vault_schema(vault_schema_movies())
2097 .execute(&vault)
2098 .unwrap();
2099 assert!(
2100 report.errors.is_empty(),
2101 "no errors expected, got: {:?}",
2102 report.errors
2103 );
2104 assert_eq!(report.changes.len(), 1);
2105 let content = fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
2106 assert!(content.contains("notes-to-self: rewatch"));
2107 }
2108
2109 #[test]
2110 fn update_surfaces_preexisting_violation() {
2111 use std::fs;
2116 let dir = vault_with_obsidian();
2117 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2118 fs::write(
2119 dir.path().join("Notes/movie/Old.md"),
2120 "---\ndb-table: movie\nstatus: to-watch\nyear: 2021\n---\n",
2122 )
2123 .unwrap();
2124 let vault = Vault::with_root(dir.path().to_path_buf());
2125
2126 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2127 field: "db-table".into(),
2128 value: Value::String("movie".into()),
2129 });
2130 let report = UpdateBuilder::new("Notes/movie", filter)
2131 .set("year", Value::Integer(2022))
2132 .with_vault_schema(vault_schema_movies())
2133 .execute(&vault)
2134 .unwrap();
2135 assert!(
2136 report.errors.iter().any(|e| e.message.contains("director")),
2137 "expected pre-existing required-field violation, got: {:?}",
2138 report.errors
2139 );
2140 let content = fs::read_to_string(dir.path().join("Notes/movie/Old.md")).unwrap();
2142 assert!(content.contains("year: 2021"));
2143 }
2144
2145 #[test]
2146 fn update_skips_one_blocks_one_in_batch() {
2147 use std::fs;
2154 let dir = vault_with_obsidian();
2155 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2156 fs::write(
2157 dir.path().join("Notes/movie/Good.md"),
2158 "---\ndb-table: movie\ndirector: A\nstatus: to-watch\nyear: 2021\n---\n",
2159 )
2160 .unwrap();
2161 fs::write(
2162 dir.path().join("Notes/movie/Bad.md"),
2163 "---\ndb-table: movie\nstatus: to-watch\nyear: 2021\n---\n",
2165 )
2166 .unwrap();
2167 let vault = Vault::with_root(dir.path().to_path_buf());
2168
2169 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2170 field: "db-table".into(),
2171 value: Value::String("movie".into()),
2172 });
2173 let report = UpdateBuilder::new("Notes/movie", filter)
2174 .set("year", Value::Integer(2022))
2175 .with_vault_schema(vault_schema_movies())
2176 .execute(&vault)
2177 .unwrap();
2178 assert_eq!(report.changes.len(), 1, "exactly one record should write");
2179 assert!(report.changes[0].path.ends_with("Good.md"));
2180 assert!(report.errors.iter().any(|e| e.path.ends_with("Bad.md")));
2181 assert!(
2183 fs::read_to_string(dir.path().join("Notes/movie/Good.md"))
2184 .unwrap()
2185 .contains("year: 2022")
2186 );
2187 assert!(
2188 fs::read_to_string(dir.path().join("Notes/movie/Bad.md"))
2189 .unwrap()
2190 .contains("year: 2021")
2191 );
2192 }
2193
2194 #[test]
2195 fn update_validates_against_catchall_and_subfolder() {
2196 use std::fs;
2202 let dir = vault_with_obsidian();
2203 fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
2204 fs::write(
2205 dir.path().join("Notes/movie/Dune.md"),
2206 "---\ndb-table: movie\ndirector: DV\nstatus: to-watch\nyear: 2021\n---\n",
2207 )
2208 .unwrap();
2209 let vault = Vault::with_root(dir.path().to_path_buf());
2210
2211 let filter = Expr::Predicate(crate::query::Predicate::Equals {
2212 field: "director".into(),
2213 value: Value::String("DV".into()),
2214 });
2215 let report = UpdateBuilder::new("Notes/movie", filter)
2216 .unset("db-table")
2217 .with_vault_schema(vault_schema_catchall_and_movies())
2218 .execute(&vault)
2219 .unwrap();
2220
2221 assert!(report.changes.is_empty());
2226 assert!(
2227 report.errors.iter().any(|e| e.message.contains("db-table")),
2228 "expected db-table missing error from catch-all, got: {:?}",
2229 report.errors
2230 );
2231 }
2232
2233 #[test]
2234 fn create_rejects_type_mismatch() {
2235 let dir = vault_with_obsidian();
2236 let vault = Vault::with_root(dir.path().to_path_buf());
2237 let report = CreateBuilder::new("Notes/movie", "Dune")
2238 .with_vault_schema(vault_schema_movies())
2239 .set("director", Value::String("DV".into()))
2240 .set("year", Value::String("not-a-year".into()))
2241 .execute(&vault)
2242 .unwrap();
2243 assert!(
2244 report
2245 .errors
2246 .iter()
2247 .any(|e| e.message.contains("year") && e.message.contains("integer")),
2248 "expected year/integer type error, got: {:?}",
2249 report.errors
2250 );
2251 assert!(!dir.path().join("Notes/movie/Dune.md").exists());
2252 }
2253
2254 #[test]
2255 fn create_validates_against_multiple_applicable_collections() {
2256 let dir = vault_with_obsidian();
2261 let vault = Vault::with_root(dir.path().to_path_buf());
2262 let report = CreateBuilder::new("Notes/movie", "X")
2263 .with_vault_schema(vault_schema_catchall_and_movies())
2264 .set("db-table", Value::String("movie".into()))
2265 .execute(&vault)
2266 .unwrap();
2267 assert!(report.errors.iter().any(|e| e.message.contains("director")));
2268 assert!(report.errors.iter().any(|e| e.message.contains("year")));
2269 assert!(!dir.path().join("Notes/movie/X.md").exists());
2270 }
2271}