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 write_options: writer::WriteOptions,
56}
57
58impl UpdateBuilder {
59 pub fn new(folder: impl Into<String>, filter: Expr) -> Self {
60 Self {
61 filter,
62 folder: folder.into(),
63 set_fields: Vec::new(),
64 unset_fields: Vec::new(),
65 add_tags: Vec::new(),
66 remove_tags: Vec::new(),
67 write_options: writer::WriteOptions::default(),
68 }
69 }
70
71 pub fn set(mut self, field: impl Into<String>, value: Value) -> Self {
72 self.set_fields.push((field.into(), value));
73 self
74 }
75
76 pub fn unset(mut self, field: impl Into<String>) -> Self {
77 self.unset_fields.push(field.into());
78 self
79 }
80
81 pub fn add_tag(mut self, tag: impl Into<String>) -> Self {
82 self.add_tags.push(tag.into());
83 self
84 }
85
86 pub fn remove_tag(mut self, tag: impl Into<String>) -> Self {
87 self.remove_tags.push(tag.into());
88 self
89 }
90
91 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
94 self.write_options = opts;
95 self
96 }
97
98 pub fn fsync(mut self, yes: bool) -> Self {
101 self.write_options.fsync = yes;
102 self
103 }
104
105 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
107 let (report, _writes) = self.compute(vault)?;
108 Ok(report)
109 }
110
111 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
119 crate::lock::with_lock(&vault.root, || {
120 let (report, writes) = self.compute(vault)?;
121 for w in &writes {
122 writer::apply_with(w, self.write_options).map_err(VaultdbError::Io)?;
123 }
124 Ok(report)
125 })
126 }
127
128 fn compute(&self, vault: &Vault) -> Result<(MutationReport, Vec<WriteResult>)> {
129 let folder_path = vault.resolve_folder(&self.folder)?;
130 let load = vault.load_records_with_content(&folder_path, false, false)?;
131 let needs_links = crate::filter::expr_uses_links(&self.filter);
132 let link_index = if needs_links {
133 Some(crate::links::LinkGraph::build_with_root(
134 &load.records,
135 Some(&vault.root),
136 ))
137 } else {
138 None
139 };
140
141 let mut changes = Vec::new();
142 let mut errors = Vec::new();
143 let mut writes = Vec::new();
144
145 for record in &load.records {
146 if !crate::filter::evaluate_expr(&self.filter, record, &vault.root, link_index.as_ref())
147 {
148 continue;
149 }
150
151 let mut content = record
156 .raw_content
157 .as_ref()
158 .ok_or_else(|| {
159 VaultdbError::Internal(format!(
160 "record at {} has no raw_content; UpdateBuilder loaded without content",
161 record.path.display()
162 ))
163 })?
164 .clone();
165 let original_content = content.clone();
166 let mut wr_changes = Vec::new();
167 let mut description_parts: Vec<String> = Vec::new();
168
169 let result: Result<()> = (|| {
170 for (field, value) in &self.set_fields {
171 let (new_content, change) = match value {
177 Value::List(_) | Value::Map(_) => {
178 writer::set_field_block(&content, field, value)?
179 }
180 _ => {
181 let value_str = render_value_for_yaml(value);
182 writer::set_field(&content, field, &value_str)?
183 }
184 };
185 description_parts.push(format!("{}", change));
186 wr_changes.push(change);
187 content = new_content;
188 }
189 for field in &self.unset_fields {
190 let (new_content, change) = writer::unset_field(&content, field)?;
191 description_parts.push(format!("{}", change));
192 wr_changes.push(change);
193 content = new_content;
194 }
195 for tag in &self.add_tags {
196 let (new_content, change) = writer::add_tag(&content, tag)?;
197 description_parts.push(format!("{}", change));
198 wr_changes.push(change);
199 content = new_content;
200 }
201 for tag in &self.remove_tags {
202 let (new_content, change) = writer::remove_tag(&content, tag)?;
203 description_parts.push(format!("{}", change));
204 wr_changes.push(change);
205 content = new_content;
206 }
207 Ok(())
208 })();
209
210 match result {
211 Ok(_) => {
212 if !wr_changes.is_empty() {
213 writes.push(WriteResult {
214 path: record.path.clone(),
215 original_content,
216 modified_content: content,
217 changes: wr_changes,
218 });
219 changes.push(PlannedChange {
220 path: record.path.clone(),
221 description: description_parts.join("; "),
222 });
223 }
224 }
225 Err(e) => errors.push(MutationError {
226 path: record.path.clone(),
227 message: e.to_string(),
228 }),
229 }
230 }
231
232 Ok((MutationReport { changes, errors }, writes))
233 }
234}
235
236#[derive(Debug, Clone)]
242pub struct DeleteBuilder {
243 filter: Expr,
244 folder: String,
245 permanent: bool,
246 write_options: writer::WriteOptions,
247}
248
249impl DeleteBuilder {
250 pub fn new(folder: impl Into<String>, filter: Expr) -> Self {
251 Self {
252 filter,
253 folder: folder.into(),
254 permanent: false,
255 write_options: writer::WriteOptions::default(),
256 }
257 }
258
259 pub fn permanent(mut self, yes: bool) -> Self {
260 self.permanent = yes;
261 self
262 }
263
264 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
266 self.write_options = opts;
267 self
268 }
269
270 pub fn fsync(mut self, yes: bool) -> Self {
273 self.write_options.fsync = yes;
274 self
275 }
276
277 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
278 let folder_path = vault.resolve_folder(&self.folder)?;
279 let load = vault.load_records(&folder_path, false, false)?;
280 let needs_links = crate::filter::expr_uses_links(&self.filter);
281 let link_index = if needs_links {
282 Some(crate::links::LinkGraph::build_with_root(
283 &load.records,
284 Some(&vault.root),
285 ))
286 } else {
287 None
288 };
289
290 let mut changes = Vec::new();
291 for r in &load.records {
292 if !crate::filter::evaluate_expr(&self.filter, r, &vault.root, link_index.as_ref()) {
293 continue;
294 }
295 changes.push(PlannedChange {
296 path: r.path.clone(),
297 description: if self.permanent {
298 "delete (permanent)".to_string()
299 } else {
300 "move to .trash/".to_string()
301 },
302 });
303 }
304 Ok(MutationReport {
305 changes,
306 errors: Vec::new(),
307 })
308 }
309
310 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
311 crate::lock::with_lock(&vault.root, || {
312 let report = self.plan(vault)?;
313 let mut errors = Vec::new();
314 let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
316 std::collections::BTreeSet::new();
317
318 if self.permanent {
319 for change in &report.changes {
320 if let Err(e) = std::fs::remove_file(&change.path) {
321 errors.push(MutationError {
322 path: change.path.clone(),
323 message: format!("remove failed: {}", e),
324 });
325 } else if let Some(parent) = change.path.parent() {
326 dirs_to_fsync.insert(parent.to_path_buf());
327 }
328 }
329 } else {
330 let trash_dir = vault.root.join(".trash");
331 if !report.changes.is_empty() {
332 std::fs::create_dir_all(&trash_dir).map_err(VaultdbError::Io)?;
333 }
334 for change in &report.changes {
335 let dest = unique_in_dir(&trash_dir, &change.path);
336 if let Err(e) = std::fs::rename(&change.path, &dest) {
337 errors.push(MutationError {
338 path: change.path.clone(),
339 message: format!("trash failed: {}", e),
340 });
341 } else {
342 if let Some(parent) = change.path.parent() {
343 dirs_to_fsync.insert(parent.to_path_buf());
344 }
345 dirs_to_fsync.insert(trash_dir.clone());
346 }
347 }
348 }
349
350 if self.write_options.fsync {
353 for d in &dirs_to_fsync {
354 if let Err(e) = writer::fsync_dir(d) {
355 errors.push(MutationError {
356 path: d.clone(),
357 message: format!("fsync_dir failed: {}", e),
358 });
359 }
360 }
361 }
362
363 Ok(MutationReport {
364 changes: report.changes,
365 errors,
366 })
367 })
368 }
369}
370
371fn unique_in_dir(dir: &std::path::Path, src: &std::path::Path) -> PathBuf {
372 let filename = src.file_name().and_then(|n| n.to_str()).unwrap_or("file");
373 let candidate = dir.join(filename);
374 if !candidate.exists() {
375 return candidate;
376 }
377 let stem = src.file_stem().and_then(|n| n.to_str()).unwrap_or("file");
378 let ext = src.extension().and_then(|n| n.to_str()).unwrap_or("md");
379 let mut i = 1;
380 loop {
381 let c = dir.join(format!("{}-{}.{}", stem, i, ext));
382 if !c.exists() {
383 return c;
384 }
385 i += 1;
386 }
387}
388
389#[derive(Debug, Clone)]
395pub struct MoveBuilder {
396 filter: Expr,
397 folder: String,
398 to_folder: String,
399 write_options: writer::WriteOptions,
400}
401
402impl MoveBuilder {
403 pub fn new(folder: impl Into<String>, to_folder: impl Into<String>, filter: Expr) -> Self {
404 Self {
405 filter,
406 folder: folder.into(),
407 to_folder: to_folder.into(),
408 write_options: writer::WriteOptions::default(),
409 }
410 }
411
412 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
414 self.write_options = opts;
415 self
416 }
417
418 pub fn fsync(mut self, yes: bool) -> Self {
420 self.write_options.fsync = yes;
421 self
422 }
423
424 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
425 let folder_path = vault.resolve_folder(&self.folder)?;
426 let to_path = vault.root.join(&self.to_folder);
427 let load = vault.load_records(&folder_path, false, false)?;
428 let needs_links = crate::filter::expr_uses_links(&self.filter);
429 let link_index = if needs_links {
430 Some(crate::links::LinkGraph::build_with_root(
431 &load.records,
432 Some(&vault.root),
433 ))
434 } else {
435 None
436 };
437
438 let mut changes = Vec::new();
439 let mut errors = Vec::new();
440
441 for r in &load.records {
442 if !crate::filter::evaluate_expr(&self.filter, r, &vault.root, link_index.as_ref()) {
443 continue;
444 }
445 let filename = match r.path.file_name() {
446 Some(n) => n,
447 None => continue,
448 };
449 let dest = to_path.join(filename);
450 if dest.exists() {
451 errors.push(MutationError {
452 path: r.path.clone(),
453 message: format!(
454 "move conflict: {} already exists in {}",
455 filename.to_string_lossy(),
456 self.to_folder
457 ),
458 });
459 continue;
460 }
461 changes.push(PlannedChange {
462 path: r.path.clone(),
463 description: format!("move to {}", dest.display()),
464 });
465 }
466 Ok(MutationReport { changes, errors })
467 }
468
469 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
470 crate::lock::with_lock(&vault.root, || {
471 let to_path = vault.root.join(&self.to_folder);
472 let report = self.plan(vault)?;
473 if !report.changes.is_empty() {
474 std::fs::create_dir_all(&to_path).map_err(VaultdbError::Io)?;
475 }
476 let mut errors = report.errors;
477 let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
478 std::collections::BTreeSet::new();
479 for change in &report.changes {
480 let filename = match change.path.file_name() {
481 Some(n) => n,
482 None => continue,
483 };
484 let dest = to_path.join(filename);
485 if let Err(e) = std::fs::rename(&change.path, &dest) {
486 errors.push(MutationError {
487 path: change.path.clone(),
488 message: format!("rename failed: {}", e),
489 });
490 } else {
491 if let Some(parent) = change.path.parent() {
492 dirs_to_fsync.insert(parent.to_path_buf());
493 }
494 dirs_to_fsync.insert(to_path.clone());
495 }
496 }
497
498 if self.write_options.fsync {
499 for d in &dirs_to_fsync {
500 if let Err(e) = writer::fsync_dir(d) {
501 errors.push(MutationError {
502 path: d.clone(),
503 message: format!("fsync_dir failed: {}", e),
504 });
505 }
506 }
507 }
508
509 Ok(MutationReport {
510 changes: report.changes,
511 errors,
512 })
513 })
514 }
515}
516
517#[derive(Debug, Clone)]
526pub struct RenameBuilder {
527 folder: String,
528 from: String,
529 to: String,
530 write_options: writer::WriteOptions,
531}
532
533impl RenameBuilder {
534 pub fn new(folder: impl Into<String>, from: impl Into<String>, to: impl Into<String>) -> Self {
535 Self {
536 folder: folder.into(),
537 from: from.into(),
538 to: to.into(),
539 write_options: writer::WriteOptions::default(),
540 }
541 }
542
543 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
545 self.write_options = opts;
546 self
547 }
548
549 pub fn fsync(mut self, yes: bool) -> Self {
552 self.write_options.fsync = yes;
553 self
554 }
555
556 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
557 let folder_path = vault.resolve_folder(&self.folder)?;
558 let source = folder_path.join(format!("{}.md", self.from));
559 let dest = folder_path.join(format!("{}.md", self.to));
560
561 let mut changes = Vec::new();
562 let mut errors = Vec::new();
563
564 if !source.is_file() {
565 errors.push(MutationError {
566 path: source.clone(),
567 message: format!("source `{}` not found", self.from),
568 });
569 return Ok(MutationReport { changes, errors });
570 }
571 if dest.exists() {
572 errors.push(MutationError {
573 path: dest.clone(),
574 message: format!("target `{}.md` already exists", self.to),
575 });
576 return Ok(MutationReport { changes, errors });
577 }
578
579 changes.push(PlannedChange {
580 path: source.clone(),
581 description: format!("rename to {}", dest.display()),
582 });
583
584 let all = vault.load_records_with_content(&vault.root, true, false)?;
587 let graph = crate::links::LinkGraph::build_with_root(&all.records, Some(&vault.root));
588 for source_name in graph.incoming_links(&self.from) {
589 if let Some(record) = graph.record_by_name(source_name) {
590 changes.push(PlannedChange {
591 path: record.path.clone(),
592 description: format!("rewrite [[{}]] -> [[{}]]", self.from, self.to),
593 });
594 }
595 }
596
597 Ok(MutationReport { changes, errors })
598 }
599
600 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
601 crate::lock::with_lock(&vault.root, || {
602 crate::journal::replay_all(&vault.root)?;
609
610 let folder_path = vault.resolve_folder(&self.folder)?;
611 let source = folder_path.join(format!("{}.md", self.from));
612 let dest = folder_path.join(format!("{}.md", self.to));
613
614 let report = self.plan(vault)?;
615 if !report.errors.is_empty() {
617 return Ok(report);
618 }
619
620 let backlinks: Vec<PathBuf> = report
625 .changes
626 .iter()
627 .skip(1) .map(|c| c.path.clone())
629 .collect();
630 let journal = crate::journal::RenameJournal {
631 source: source.clone(),
632 dest: dest.clone(),
633 from_name: self.from.clone(),
634 to_name: self.to.clone(),
635 backlinks,
636 };
637 let journal_path = crate::journal::write(&vault.root, &journal)?;
638
639 if let Err(e) = std::fs::rename(&source, &dest) {
642 crate::journal::delete(&journal_path);
643 return Ok(MutationReport {
644 changes: report.changes,
645 errors: vec![MutationError {
646 path: source,
647 message: format!("rename failed: {}", e),
648 }],
649 });
650 }
651
652 let mut errors = Vec::new();
656 let mut dirs_to_fsync: std::collections::BTreeSet<PathBuf> =
657 std::collections::BTreeSet::new();
658 if let Some(parent) = source.parent() {
659 dirs_to_fsync.insert(parent.to_path_buf());
660 }
661 if let Some(parent) = dest.parent() {
662 dirs_to_fsync.insert(parent.to_path_buf());
663 }
664 for change in report.changes.iter().skip(1) {
665 let path = &change.path;
666 let content = match std::fs::read_to_string(path) {
667 Ok(c) => c,
668 Err(e) => {
669 errors.push(MutationError {
670 path: path.clone(),
671 message: format!("read failed: {}", e),
672 });
673 continue;
674 }
675 };
676 let new_content = rewrite_wikilinks(&content, &self.from, &self.to);
677 if new_content == content {
678 continue;
679 }
680 if let Err(e) = writer::atomic_write_with(path, &new_content, self.write_options) {
681 errors.push(MutationError {
682 path: path.clone(),
683 message: format!("write failed: {}", e),
684 });
685 }
686 }
687
688 if self.write_options.fsync {
693 for d in &dirs_to_fsync {
694 if let Err(e) = writer::fsync_dir(d) {
695 errors.push(MutationError {
696 path: d.clone(),
697 message: format!("fsync_dir failed: {}", e),
698 });
699 }
700 }
701 }
702
703 if errors.is_empty() {
707 crate::journal::delete(&journal_path);
708 }
709
710 Ok(MutationReport {
711 changes: report.changes,
712 errors,
713 })
714 })
715 }
716}
717
718#[derive(Debug, Clone)]
737pub struct CreateBuilder {
738 folder: String,
739 name: String,
740 template: Option<String>,
741 set_fields: Vec<(String, Value)>,
742 schema: Option<crate::schema::CollectionSchema>,
743 write_options: writer::WriteOptions,
744}
745
746impl CreateBuilder {
747 pub fn new(folder: impl Into<String>, name: impl Into<String>) -> Self {
748 Self {
749 folder: folder.into(),
750 name: name.into(),
751 template: None,
752 set_fields: Vec::new(),
753 schema: None,
754 write_options: writer::WriteOptions::default(),
755 }
756 }
757
758 pub fn template(mut self, path: impl Into<String>) -> Self {
762 self.template = Some(path.into());
763 self
764 }
765
766 pub fn set(mut self, field: impl Into<String>, value: Value) -> Self {
769 self.set_fields.push((field.into(), value));
770 self
771 }
772
773 pub fn with_schema(mut self, schema: crate::schema::CollectionSchema) -> Self {
778 self.schema = Some(schema);
779 self
780 }
781
782 pub fn write_options(mut self, opts: writer::WriteOptions) -> Self {
783 self.write_options = opts;
784 self
785 }
786
787 pub fn fsync(mut self, yes: bool) -> Self {
788 self.write_options.fsync = yes;
789 self
790 }
791
792 pub fn plan(&self, vault: &Vault) -> Result<MutationReport> {
794 let (report, _) = self.compute(vault)?;
795 Ok(report)
796 }
797
798 pub fn plan_with_content(&self, vault: &Vault) -> Result<(MutationReport, Option<String>)> {
802 let (report, write) = self.compute(vault)?;
803 Ok((report, write.map(|w| w.modified_content)))
804 }
805
806 pub fn execute(self, vault: &Vault) -> Result<MutationReport> {
810 crate::lock::with_lock(&vault.root, || {
811 let (report, write) = self.compute(vault)?;
812 if !report.errors.is_empty() {
813 return Ok(report);
814 }
815 if let Some(w) = write {
816 if let Some(parent) = w.path.parent()
817 && !parent.exists()
818 {
819 std::fs::create_dir_all(parent).map_err(VaultdbError::Io)?;
820 }
821 writer::atomic_write_with(&w.path, &w.modified_content, self.write_options)
822 .map_err(VaultdbError::Io)?;
823 }
824 Ok(report)
825 })
826 }
827
828 fn compute(&self, vault: &Vault) -> Result<(MutationReport, Option<WriteResult>)> {
829 let folder_path = vault.root.join(&self.folder);
834 let filename = format!("{}.md", self.name);
835 let dest = folder_path.join(&filename);
836
837 let mut changes = Vec::new();
838 let mut errors = Vec::new();
839
840 if dest.exists() {
841 errors.push(MutationError {
842 path: dest.clone(),
843 message: format!("file already exists: {}", dest.display()),
844 });
845 return Ok((MutationReport { changes, errors }, None));
846 }
847
848 let (mut fields, body) = match &self.template {
852 Some(tmpl) => {
853 let tmpl_path = vault.root.join(tmpl);
854 if !tmpl_path.is_file() {
855 errors.push(MutationError {
856 path: tmpl_path.clone(),
857 message: format!("template not found: {}", tmpl_path.display()),
858 });
859 return Ok((MutationReport { changes, errors }, None));
860 }
861 let raw = std::fs::read_to_string(&tmpl_path).map_err(VaultdbError::Io)?;
862 split_template(&raw)
863 }
864 None => (
865 std::collections::BTreeMap::<String, Value>::new(),
866 format!("\n# {}\n", self.name),
867 ),
868 };
869
870 for (k, v) in &self.set_fields {
872 fields.insert(k.clone(), v.clone());
873 }
874
875 if let Some(schema) = &self.schema {
877 for (name, fs) in &schema.fields {
878 if fields.contains_key(name) {
879 continue;
880 }
881 if let Some(default) = &fs.default {
882 fields.insert(name.clone(), default.clone());
883 } else if let Some(expr) = &fs.default_expr {
884 match crate::schema::resolve_default_expr(expr) {
885 Ok(v) => {
886 fields.insert(name.clone(), v);
887 }
888 Err(e) => {
889 errors.push(MutationError {
890 path: dest.clone(),
891 message: format!("resolving default_expr for '{}': {}", name, e),
892 });
893 }
894 }
895 }
896 }
897
898 for req in &schema.required {
900 let satisfied = matches!(fields.get(req), Some(v) if !matches!(v, Value::Null));
901 if !satisfied {
902 errors.push(MutationError {
903 path: dest.clone(),
904 message: format!("required field missing: '{}'", req),
905 });
906 }
907 }
908 }
909
910 if !errors.is_empty() {
911 return Ok((MutationReport { changes, errors }, None));
912 }
913
914 let frontmatter_yaml = if fields.is_empty() {
918 String::new()
919 } else {
920 serde_yaml::to_string(&fields)
921 .map_err(|e| VaultdbError::SchemaError(format!("rendering frontmatter: {}", e)))?
922 };
923 let content = if frontmatter_yaml.is_empty() {
924 format!("---\n---\n{}", body)
925 } else {
926 format!("---\n{}---\n{}", frontmatter_yaml, body)
927 };
928
929 let field_count = fields.len();
930 let field_summary: String = fields.keys().cloned().collect::<Vec<_>>().join(", ");
931 let description = if field_count == 0 {
932 "create (no frontmatter fields)".to_string()
933 } else {
934 format!("create with {} field(s): {}", field_count, field_summary)
935 };
936
937 changes.push(PlannedChange {
938 path: dest.clone(),
939 description,
940 });
941
942 let write = WriteResult {
943 path: dest,
944 original_content: String::new(),
945 modified_content: content,
946 changes: Vec::new(),
947 };
948
949 Ok((MutationReport { changes, errors }, Some(write)))
950 }
951}
952
953fn split_template(raw: &str) -> (std::collections::BTreeMap<String, Value>, String) {
957 use crate::frontmatter::{extract_frontmatter, parse_frontmatter};
958 match extract_frontmatter(raw) {
959 Some((yaml_text, body_start)) => {
960 let fields = parse_frontmatter(yaml_text).unwrap_or_default();
961 let body = raw[body_start..].to_string();
962 (fields, body)
963 }
964 None => (std::collections::BTreeMap::new(), raw.to_string()),
965 }
966}
967
968pub(crate) fn rewrite_wikilinks(content: &str, from: &str, to: &str) -> String {
971 content
972 .replace(&format!("[[{}]]", from), &format!("[[{}]]", to))
973 .replace(&format!("[[{}|", from), &format!("[[{}|", to))
974 .replace(&format!("[[{}#", from), &format!("[[{}#", to))
975}
976
977fn render_value_for_yaml(v: &Value) -> String {
983 match v {
984 Value::Null => "null".to_string(),
985 Value::Bool(b) => b.to_string(),
986 Value::Integer(i) => i.to_string(),
987 Value::Float(f) => f.to_string(),
988 Value::String(s) => writer::quote_value(s),
989 Value::List(_) | Value::Map(_) => {
990 let yaml = serde_yaml::to_string(v).unwrap_or_default();
991 yaml.trim_end().to_string()
992 }
993 }
994}
995
996#[cfg(test)]
997mod tests {
998 use super::*;
999 use crate::query::Predicate;
1000
1001 #[test]
1007 fn update_builder_writes_list_as_block_yaml() {
1008 use std::fs;
1009 use tempfile::TempDir;
1010 let dir = TempDir::new().unwrap();
1011 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1012 fs::create_dir(dir.path().join("notes")).unwrap();
1013 fs::write(
1014 dir.path().join("notes/cat.md"),
1015 "---\nanlam: kedi\n---\n\n# 猫\n",
1016 )
1017 .unwrap();
1018 let vault = Vault::with_root(dir.path().to_path_buf());
1019
1020 let filter = Expr::Predicate(Predicate::Equals {
1021 field: "_name".into(),
1022 value: Value::String("cat".into()),
1023 });
1024 let anlamlar = Value::List(vec![
1025 Value::String("kedi".into()),
1026 Value::String("pisi".into()),
1027 ]);
1028 let report = UpdateBuilder::new("notes", filter)
1029 .set("anlamlar", anlamlar)
1030 .execute(&vault)
1031 .unwrap();
1032 assert_eq!(report.errors.len(), 0);
1033 assert_eq!(report.changes.len(), 1);
1034
1035 let written = fs::read_to_string(dir.path().join("notes/cat.md")).unwrap();
1036 assert!(
1038 written.contains("anlamlar:\n- kedi\n- pisi"),
1039 "got:\n{}",
1040 written
1041 );
1042 assert!(!written.contains("anlamlar: '- kedi"), "got:\n{}", written);
1044
1045 let records = vault
1049 .load_records(&dir.path().join("notes"), false, false)
1050 .unwrap()
1051 .records;
1052 let cat = &records[0];
1053 match cat.fields.get("anlamlar") {
1054 Some(Value::List(items)) => {
1055 assert_eq!(items.len(), 2);
1056 assert!(matches!(&items[0], Value::String(s) if s == "kedi"));
1057 assert!(matches!(&items[1], Value::String(s) if s == "pisi"));
1058 }
1059 other => panic!("expected Value::List, got {:?}", other),
1060 }
1061 }
1062
1063 #[test]
1064 fn update_builder_chains() {
1065 let filter = Expr::Predicate(Predicate::Equals {
1066 field: "status".into(),
1067 value: Value::String("active".into()),
1068 });
1069 let b = UpdateBuilder::new("notes", filter)
1070 .set("priority", Value::Integer(1))
1071 .unset("draft")
1072 .add_tag("urgent")
1073 .remove_tag("stale");
1074 assert_eq!(b.set_fields.len(), 1);
1075 assert_eq!(b.unset_fields.len(), 1);
1076 assert_eq!(b.add_tags.len(), 1);
1077 assert_eq!(b.remove_tags.len(), 1);
1078 }
1079
1080 #[test]
1081 fn delete_builder_trash_moves_to_dot_trash() {
1082 use std::fs;
1083 use tempfile::TempDir;
1084 let dir = TempDir::new().unwrap();
1085 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1086 fs::create_dir(dir.path().join("notes")).unwrap();
1087 fs::write(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
1088 let vault = Vault::with_root(dir.path().to_path_buf());
1089 let filter = Expr::Predicate(Predicate::Equals {
1090 field: "status".into(),
1091 value: Value::String("stale".into()),
1092 });
1093 let builder = DeleteBuilder::new("notes", filter);
1094 let report = builder.execute(&vault).unwrap();
1095 assert_eq!(report.changes.len(), 1);
1096 assert_eq!(report.errors.len(), 0);
1097 assert!(!dir.path().join("notes/a.md").exists());
1098 assert!(dir.path().join(".trash/a.md").exists());
1099 }
1100
1101 #[test]
1102 fn delete_builder_permanent_removes_file() {
1103 use std::fs;
1104 use tempfile::TempDir;
1105 let dir = TempDir::new().unwrap();
1106 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1107 fs::create_dir(dir.path().join("notes")).unwrap();
1108 fs::write(dir.path().join("notes/a.md"), "---\nstatus: stale\n---\n").unwrap();
1109 let vault = Vault::with_root(dir.path().to_path_buf());
1110 let filter = Expr::Predicate(Predicate::Equals {
1111 field: "status".into(),
1112 value: Value::String("stale".into()),
1113 });
1114 let builder = DeleteBuilder::new("notes", filter).permanent(true);
1115 builder.execute(&vault).unwrap();
1116 assert!(!dir.path().join("notes/a.md").exists());
1117 assert!(!dir.path().join(".trash/a.md").exists());
1118 }
1119
1120 #[test]
1121 fn move_builder_relocates_files() {
1122 use std::fs;
1123 use tempfile::TempDir;
1124 let dir = TempDir::new().unwrap();
1125 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1126 fs::create_dir(dir.path().join("notes")).unwrap();
1127 fs::write(
1128 dir.path().join("notes/a.md"),
1129 "---\nstatus: archived\n---\n",
1130 )
1131 .unwrap();
1132 let vault = Vault::with_root(dir.path().to_path_buf());
1133 let filter = Expr::Predicate(Predicate::Equals {
1134 field: "status".into(),
1135 value: Value::String("archived".into()),
1136 });
1137 let builder = MoveBuilder::new("notes", "archive", filter);
1138 let report = builder.execute(&vault).unwrap();
1139 assert_eq!(report.changes.len(), 1);
1140 assert_eq!(report.errors.len(), 0);
1141 assert!(!dir.path().join("notes/a.md").exists());
1142 assert!(dir.path().join("archive/a.md").exists());
1143 }
1144
1145 #[test]
1146 fn rename_builder_renames_and_rewrites_links() {
1147 use std::fs;
1148 use tempfile::TempDir;
1149 let dir = TempDir::new().unwrap();
1150 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1151 fs::create_dir(dir.path().join("notes")).unwrap();
1152 fs::write(
1153 dir.path().join("notes/old.md"),
1154 "---\nstatus: x\n---\nBody\n",
1155 )
1156 .unwrap();
1157 fs::write(
1158 dir.path().join("notes/source.md"),
1159 "---\nstatus: y\n---\nLinks to [[old]] and [[old|alias]] and [[old#section]].\n",
1160 )
1161 .unwrap();
1162 let vault = Vault::with_root(dir.path().to_path_buf());
1163
1164 let builder = RenameBuilder::new("notes", "old", "new");
1165 let report = builder.execute(&vault).unwrap();
1166 assert_eq!(report.changes.len(), 2);
1168 assert_eq!(report.errors.len(), 0);
1169 assert!(!dir.path().join("notes/old.md").exists());
1170 assert!(dir.path().join("notes/new.md").exists());
1171 let source_after = fs::read_to_string(dir.path().join("notes/source.md")).unwrap();
1172 assert!(source_after.contains("[[new]]"));
1173 assert!(source_after.contains("[[new|alias]]"));
1174 assert!(source_after.contains("[[new#section]]"));
1175 assert!(!source_after.contains("[[old"));
1176 }
1177
1178 #[test]
1179 fn rename_builder_target_conflict_returns_error() {
1180 use std::fs;
1181 use tempfile::TempDir;
1182 let dir = TempDir::new().unwrap();
1183 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1184 fs::create_dir(dir.path().join("notes")).unwrap();
1185 fs::write(dir.path().join("notes/old.md"), "---\nstatus: x\n---\n").unwrap();
1186 fs::write(dir.path().join("notes/new.md"), "---\nstatus: y\n---\n").unwrap();
1187 let vault = Vault::with_root(dir.path().to_path_buf());
1188 let report = RenameBuilder::new("notes", "old", "new")
1189 .execute(&vault)
1190 .unwrap();
1191 assert_eq!(report.changes.len(), 0);
1192 assert_eq!(report.errors.len(), 1);
1193 assert!(dir.path().join("notes/old.md").exists());
1195 }
1196
1197 #[test]
1198 fn update_builder_plan_and_execute_against_a_temp_vault() {
1199 use std::fs;
1200 use tempfile::TempDir;
1201
1202 let dir = TempDir::new().unwrap();
1203 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1204 fs::create_dir(dir.path().join("notes")).unwrap();
1205 fs::write(
1206 dir.path().join("notes/a.md"),
1207 "---\nstatus: active\n---\nBody A\n",
1208 )
1209 .unwrap();
1210 fs::write(
1211 dir.path().join("notes/b.md"),
1212 "---\nstatus: pending\n---\nBody B\n",
1213 )
1214 .unwrap();
1215
1216 let vault = Vault::with_root(dir.path().to_path_buf());
1217
1218 let filter = Expr::Predicate(Predicate::Equals {
1219 field: "status".into(),
1220 value: Value::String("active".into()),
1221 });
1222 let builder = UpdateBuilder::new("notes", filter).set("priority", Value::Integer(1));
1223
1224 let plan_report = builder.plan(&vault).unwrap();
1226 assert_eq!(plan_report.changes.len(), 1);
1227 assert_eq!(plan_report.errors.len(), 0);
1228 assert!(plan_report.changes[0].path.ends_with("a.md"));
1229 let before = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
1230 assert!(!before.contains("priority"));
1231
1232 let exec_report = builder.execute(&vault).unwrap();
1234 assert_eq!(exec_report.changes.len(), 1);
1235 let after = fs::read_to_string(dir.path().join("notes/a.md")).unwrap();
1236 assert!(after.contains("priority"));
1237 let b_after = fs::read_to_string(dir.path().join("notes/b.md")).unwrap();
1239 assert!(!b_after.contains("priority"));
1240 }
1241
1242 #[test]
1243 fn write_options_fsync_propagates_through_update_builder() {
1244 use std::fs;
1252 use tempfile::TempDir;
1253
1254 let f1 = Expr::Predicate(Predicate::Equals {
1256 field: "x".into(),
1257 value: Value::Integer(1),
1258 });
1259 let b = UpdateBuilder::new("notes", f1).fsync(true);
1260 assert!(b.write_options.fsync);
1261
1262 let f2 = Expr::Predicate(Predicate::Equals {
1263 field: "x".into(),
1264 value: Value::Integer(1),
1265 });
1266 let b =
1267 UpdateBuilder::new("notes", f2).write_options(crate::writer::WriteOptions::durable());
1268 assert!(b.write_options.fsync);
1269
1270 let dir = TempDir::new().unwrap();
1272 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1273 fs::create_dir(dir.path().join("notes")).unwrap();
1274 fs::write(
1275 dir.path().join("notes/durable.md"),
1276 "---\nstatus: active\n---\nBody.\n",
1277 )
1278 .unwrap();
1279 let vault = Vault::with_root(dir.path().to_path_buf());
1280
1281 let f3 = Expr::Predicate(Predicate::Equals {
1282 field: "status".into(),
1283 value: Value::String("active".into()),
1284 });
1285 let report = UpdateBuilder::new("notes", f3)
1286 .set("priority", Value::Integer(99))
1287 .fsync(true)
1288 .execute(&vault)
1289 .unwrap();
1290 assert_eq!(report.changes.len(), 1);
1291 assert_eq!(report.errors.len(), 0);
1292
1293 let after = fs::read_to_string(dir.path().join("notes/durable.md")).unwrap();
1294 assert!(after.contains("priority: 99"));
1295 assert!(after.contains("status: active"));
1296 }
1297
1298 #[test]
1299 fn rename_clean_run_leaves_no_journal_behind() {
1300 use std::fs;
1303 use tempfile::TempDir;
1304 let dir = TempDir::new().unwrap();
1305 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1306 fs::create_dir(dir.path().join("notes")).unwrap();
1307 fs::write(
1308 dir.path().join("notes/old.md"),
1309 "---\nstatus: x\n---\nBody\n",
1310 )
1311 .unwrap();
1312 fs::write(
1313 dir.path().join("notes/source.md"),
1314 "---\nstatus: y\n---\nLinks to [[old]].\n",
1315 )
1316 .unwrap();
1317 let vault = Vault::with_root(dir.path().to_path_buf());
1318
1319 RenameBuilder::new("notes", "old", "new")
1320 .execute(&vault)
1321 .unwrap();
1322
1323 let pending = crate::journal::list_pending(dir.path()).unwrap();
1324 assert!(
1325 pending.is_empty(),
1326 "successful rename must not leave journals behind: {:?}",
1327 pending
1328 );
1329 }
1330
1331 #[test]
1332 fn rename_recovers_from_pre_existing_journal() {
1333 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 let source = dir.path().join("notes/Stanford.md");
1342 let dest = dir.path().join("notes/Stanford University.md");
1343 let backlink = dir.path().join("notes/Application.md");
1344 fs::write(&source, "---\nkind: university\n---\nMain note.\n").unwrap();
1345 fs::write(
1346 &backlink,
1347 "---\nkind: application\n---\nApplied to [[Stanford]].\n",
1348 )
1349 .unwrap();
1350
1351 let journal = crate::journal::RenameJournal {
1353 source: source.clone(),
1354 dest: dest.clone(),
1355 from_name: "Stanford".into(),
1356 to_name: "Stanford University".into(),
1357 backlinks: vec![backlink.clone()],
1358 };
1359 crate::journal::write(dir.path(), &journal).unwrap();
1360
1361 let vault = Vault::with_root(dir.path().to_path_buf());
1362 let recovered = vault.recover().unwrap();
1363 assert_eq!(recovered, 1, "expected exactly one journal replayed");
1364
1365 assert!(!source.exists());
1367 assert!(dest.is_file());
1368 let backlink_content = fs::read_to_string(&backlink).unwrap();
1369 assert!(backlink_content.contains("[[Stanford University]]"));
1370 assert!(!backlink_content.contains("[[Stanford]]"));
1371 assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
1372 }
1373
1374 #[test]
1375 fn rename_replays_pending_journal_before_starting_new_rename() {
1376 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
1385 let a = dir.path().join("notes/A.md");
1389 let b = dir.path().join("notes/B.md");
1390 let c = dir.path().join("notes/C.md");
1391 let d = dir.path().join("notes/D.md");
1392 fs::write(&a, "---\n---\nA body.\n").unwrap();
1393 fs::write(&c, "---\n---\nC body.\n").unwrap();
1394
1395 crate::journal::write(
1397 dir.path(),
1398 &crate::journal::RenameJournal {
1399 source: a.clone(),
1400 dest: b.clone(),
1401 from_name: "A".into(),
1402 to_name: "B".into(),
1403 backlinks: vec![],
1404 },
1405 )
1406 .unwrap();
1407
1408 let vault = Vault::with_root(dir.path().to_path_buf());
1410 RenameBuilder::new("notes", "C", "D")
1411 .execute(&vault)
1412 .unwrap();
1413
1414 assert!(!a.exists(), "A.md should be gone (replayed journal)");
1417 assert!(b.is_file(), "B.md should exist (replayed journal)");
1418 assert!(!c.exists(), "C.md should be gone (new rename)");
1419 assert!(d.is_file(), "D.md should exist (new rename)");
1420
1421 assert!(crate::journal::list_pending(dir.path()).unwrap().is_empty());
1423 }
1424
1425 #[test]
1426 fn concurrent_updates_serialize_via_vault_lock() {
1427 use std::fs;
1435 use std::sync::Arc;
1436 use std::thread;
1437 use tempfile::TempDir;
1438
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(
1443 dir.path().join("notes/race.md"),
1444 "---\nstatus: active\n---\nBody.\n",
1445 )
1446 .unwrap();
1447
1448 let vault_path = Arc::new(dir.path().to_path_buf());
1449
1450 let p1 = Arc::clone(&vault_path);
1451 let t1 = thread::spawn(move || {
1452 let vault = Vault::with_root((*p1).clone());
1453 let filter = Expr::Predicate(Predicate::Equals {
1454 field: "status".into(),
1455 value: Value::String("active".into()),
1456 });
1457 UpdateBuilder::new("notes", filter)
1458 .set("touched_by_t1", Value::Integer(1))
1459 .execute(&vault)
1460 .expect("t1 execute")
1461 });
1462
1463 let p2 = Arc::clone(&vault_path);
1464 let t2 = thread::spawn(move || {
1465 let vault = Vault::with_root((*p2).clone());
1466 let filter = Expr::Predicate(Predicate::Equals {
1467 field: "status".into(),
1468 value: Value::String("active".into()),
1469 });
1470 UpdateBuilder::new("notes", filter)
1471 .set("touched_by_t2", Value::Integer(1))
1472 .execute(&vault)
1473 .expect("t2 execute")
1474 });
1475
1476 let r1 = t1.join().unwrap();
1477 let r2 = t2.join().unwrap();
1478 assert_eq!(r1.errors.len(), 0);
1479 assert_eq!(r2.errors.len(), 0);
1480
1481 let final_content = fs::read_to_string(dir.path().join("notes/race.md")).unwrap();
1485 assert!(
1486 final_content.contains("touched_by_t1"),
1487 "t1's edit lost; concurrent writer race: {}",
1488 final_content
1489 );
1490 assert!(
1491 final_content.contains("touched_by_t2"),
1492 "t2's edit lost; concurrent writer race: {}",
1493 final_content
1494 );
1495 }
1496
1497 #[test]
1498 fn atomic_write_does_not_leave_partial_files_on_failed_writes() {
1499 use std::fs;
1505 use tempfile::TempDir;
1506
1507 let dir = TempDir::new().unwrap();
1508 let target = dir.path().join("subdir/that-does-not-exist/x.md");
1509 let result = crate::writer::atomic_write(&target, "new content");
1512 assert!(
1513 result.is_err(),
1514 "expected atomic_write to fail when parent dir doesn't exist"
1515 );
1516
1517 let real_dir = dir.path().join("real");
1520 fs::create_dir(&real_dir).unwrap();
1521 let real_target = real_dir.join("x.md");
1522 fs::write(&real_target, "original").unwrap();
1523 crate::writer::atomic_write(&real_target, "replacement").unwrap();
1524 let after = fs::read_to_string(&real_target).unwrap();
1525 assert_eq!(after, "replacement");
1526
1527 let leftovers: Vec<_> = fs::read_dir(&real_dir)
1529 .unwrap()
1530 .flatten()
1531 .filter(|e| e.file_name().to_string_lossy().starts_with(".tmp"))
1532 .collect();
1533 assert!(
1534 leftovers.is_empty(),
1535 "expected no tempfile leftovers, found: {:?}",
1536 leftovers.iter().map(|e| e.path()).collect::<Vec<_>>()
1537 );
1538 }
1539
1540 use crate::schema::{CollectionSchema, FieldSchema};
1543
1544 fn movie_schema() -> CollectionSchema {
1545 let mut fields = std::collections::BTreeMap::new();
1546 fields.insert(
1547 "db-table".into(),
1548 FieldSchema {
1549 field_type: "string".into(),
1550 enum_values: vec![Value::String("movie".into())],
1551 min: None,
1552 max: None,
1553 default: Some(Value::String("movie".into())),
1554 default_expr: None,
1555 },
1556 );
1557 fields.insert(
1558 "status".into(),
1559 FieldSchema {
1560 field_type: "string".into(),
1561 enum_values: vec![
1562 Value::String("to-watch".into()),
1563 Value::String("watched".into()),
1564 ],
1565 min: None,
1566 max: None,
1567 default: Some(Value::String("to-watch".into())),
1568 default_expr: None,
1569 },
1570 );
1571 fields.insert(
1572 "director".into(),
1573 FieldSchema {
1574 field_type: "string".into(),
1575 enum_values: vec![],
1576 min: None,
1577 max: None,
1578 default: None,
1579 default_expr: None,
1580 },
1581 );
1582 fields.insert(
1583 "year".into(),
1584 FieldSchema {
1585 field_type: "integer".into(),
1586 enum_values: vec![],
1587 min: None,
1588 max: None,
1589 default: None,
1590 default_expr: None,
1591 },
1592 );
1593 CollectionSchema {
1594 description: None,
1595 folder: "Notes/movie".into(),
1596 filter: vec![],
1597 required: vec![
1598 "db-table".into(),
1599 "director".into(),
1600 "status".into(),
1601 "year".into(),
1602 ],
1603 fields,
1604 }
1605 }
1606
1607 fn vault_with_obsidian() -> tempfile::TempDir {
1608 let dir = tempfile::TempDir::new().unwrap();
1609 std::fs::create_dir(dir.path().join(".obsidian")).unwrap();
1610 dir
1611 }
1612
1613 #[test]
1614 fn create_without_schema_writes_minimal_file() {
1615 let dir = vault_with_obsidian();
1616 let vault = Vault::with_root(dir.path().to_path_buf());
1617 let report = CreateBuilder::new("Notes/movie", "Dune")
1618 .execute(&vault)
1619 .unwrap();
1620 assert_eq!(report.errors.len(), 0);
1621 assert_eq!(report.changes.len(), 1);
1622 let written = dir.path().join("Notes/movie/Dune.md");
1623 assert!(written.is_file());
1624 let content = std::fs::read_to_string(&written).unwrap();
1625 assert!(content.contains("---\n---"));
1627 assert!(content.contains("# Dune"));
1628 }
1629
1630 #[test]
1631 fn create_with_set_writes_typed_frontmatter() {
1632 let dir = vault_with_obsidian();
1633 let vault = Vault::with_root(dir.path().to_path_buf());
1634 CreateBuilder::new("Notes/movie", "Dune")
1635 .set("director", Value::String("Denis Villeneuve".into()))
1636 .set("year", Value::Integer(2021))
1637 .execute(&vault)
1638 .unwrap();
1639 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1640 assert!(content.contains("director: Denis Villeneuve"));
1641 assert!(content.contains("year: 2021"));
1643 }
1644
1645 #[test]
1646 fn create_fills_schema_defaults() {
1647 let dir = vault_with_obsidian();
1648 let vault = Vault::with_root(dir.path().to_path_buf());
1649 CreateBuilder::new("Notes/movie", "Dune")
1650 .with_schema(movie_schema())
1651 .set("director", Value::String("Denis Villeneuve".into()))
1652 .set("year", Value::Integer(2021))
1653 .execute(&vault)
1654 .unwrap();
1655 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1656 assert!(content.contains("db-table: movie"));
1658 assert!(content.contains("status: to-watch"));
1659 assert!(content.contains("director: Denis Villeneuve"));
1661 assert!(content.contains("year: 2021"));
1662 }
1663
1664 #[test]
1665 fn create_set_overrides_default() {
1666 let dir = vault_with_obsidian();
1667 let vault = Vault::with_root(dir.path().to_path_buf());
1668 CreateBuilder::new("Notes/movie", "Watched")
1669 .with_schema(movie_schema())
1670 .set("director", Value::String("X".into()))
1671 .set("year", Value::Integer(2020))
1672 .set("status", Value::String("watched".into()))
1673 .execute(&vault)
1674 .unwrap();
1675 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Watched.md")).unwrap();
1676 assert!(content.contains("status: watched"));
1677 assert!(!content.contains("status: to-watch"));
1678 }
1679
1680 #[test]
1681 fn create_rejects_missing_required_before_writing() {
1682 let dir = vault_with_obsidian();
1683 let vault = Vault::with_root(dir.path().to_path_buf());
1684 let report = CreateBuilder::new("Notes/movie", "Blank")
1686 .with_schema(movie_schema())
1687 .execute(&vault)
1688 .unwrap();
1689 assert!(!report.errors.is_empty());
1690 assert!(report.errors.iter().any(|e| e.message.contains("director")));
1691 assert!(report.errors.iter().any(|e| e.message.contains("year")));
1692 assert!(!dir.path().join("Notes/movie/Blank.md").exists());
1694 }
1695
1696 #[test]
1697 fn create_rejects_existing_file() {
1698 let dir = vault_with_obsidian();
1699 std::fs::create_dir_all(dir.path().join("Notes/movie")).unwrap();
1700 std::fs::write(dir.path().join("Notes/movie/Dune.md"), "existing\n").unwrap();
1701 let vault = Vault::with_root(dir.path().to_path_buf());
1702 let report = CreateBuilder::new("Notes/movie", "Dune")
1703 .execute(&vault)
1704 .unwrap();
1705 assert_eq!(report.errors.len(), 1);
1706 assert!(report.errors[0].message.contains("already exists"));
1707 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1709 assert_eq!(content, "existing\n");
1710 }
1711
1712 #[test]
1713 fn create_resolves_default_expr_today() {
1714 let dir = vault_with_obsidian();
1715 let vault = Vault::with_root(dir.path().to_path_buf());
1716 let mut fields = std::collections::BTreeMap::new();
1717 fields.insert(
1718 "due".into(),
1719 FieldSchema {
1720 field_type: "date".into(),
1721 enum_values: vec![],
1722 min: None,
1723 max: None,
1724 default: None,
1725 default_expr: Some("today".into()),
1726 },
1727 );
1728 let schema = CollectionSchema {
1729 description: None,
1730 folder: "tasks".into(),
1731 filter: vec![],
1732 required: vec![],
1733 fields,
1734 };
1735 CreateBuilder::new("tasks", "t1")
1736 .with_schema(schema)
1737 .execute(&vault)
1738 .unwrap();
1739 let content = std::fs::read_to_string(dir.path().join("tasks/t1.md")).unwrap();
1740 let today = crate::record::today_string();
1742 assert!(
1743 content.contains(&format!("due: {}", today)),
1744 "expected due={} in: {}",
1745 today,
1746 content
1747 );
1748 }
1749
1750 #[test]
1751 fn create_plan_does_not_touch_disk() {
1752 let dir = vault_with_obsidian();
1753 let vault = Vault::with_root(dir.path().to_path_buf());
1754 let (report, content) = CreateBuilder::new("Notes/movie", "Dune")
1755 .with_schema(movie_schema())
1756 .set("director", Value::String("DV".into()))
1757 .set("year", Value::Integer(2021))
1758 .plan_with_content(&vault)
1759 .unwrap();
1760 assert_eq!(report.errors.len(), 0);
1761 assert_eq!(report.changes.len(), 1);
1762 assert!(!dir.path().join("Notes/movie/Dune.md").exists());
1763 let c = content.unwrap();
1764 assert!(c.contains("director: DV"));
1765 assert!(c.contains("db-table: movie")); }
1767
1768 #[test]
1769 fn create_from_template_preserves_body_and_merges_frontmatter() {
1770 let dir = vault_with_obsidian();
1771 std::fs::create_dir_all(dir.path().join("templates")).unwrap();
1772 std::fs::write(
1773 dir.path().join("templates/movie.md"),
1774 "---\nstatus: to-watch\naliases: []\n---\n\n# Title\n\nReview goes here.\n",
1775 )
1776 .unwrap();
1777 let vault = Vault::with_root(dir.path().to_path_buf());
1778 CreateBuilder::new("Notes/movie", "Dune")
1779 .template("templates/movie.md")
1780 .set("year", Value::Integer(2021))
1781 .execute(&vault)
1782 .unwrap();
1783 let content = std::fs::read_to_string(dir.path().join("Notes/movie/Dune.md")).unwrap();
1784 assert!(content.contains("status: to-watch"));
1786 assert!(content.contains("year: 2021"));
1788 assert!(content.contains("Review goes here"));
1790 }
1791}