1use std::collections::{BTreeSet, HashMap};
9use std::sync::LazyLock;
10
11use omgbase_format::hash::hex;
12use omgbase_mutate::{ErrorCode, Expect, MutationError, Op};
13use omgbase_properties::{Value as YamlValue, parse_document};
14use omgbase_reconcile::Config;
15use regex::Regex;
16use rusqlite::{OptionalExtension, params};
17use serde_json::{Map, Value, json};
18
19use crate::Store;
20use crate::derived::fts_delete_doc;
21use crate::doc_store::DocStore;
22use crate::error::Result;
23use crate::graph::{adopt_phantoms, rebuild_doc_edges};
24use crate::links::{InboundLink, doc_dir_of, inbound_links_to, retarget_links_in_raw};
25use crate::mutate::{ApplyOrigin, ApplyRequest, Diff, find_doc_by_ref};
26use crate::read::blob_text;
27use crate::writers::{NewCommit, Origin, new_commit};
28use crate::yaml_emit::stringify;
29
30#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct DocOpContext {
33 pub repo_id: String,
34 pub actor: Option<String>,
36 pub ts: String,
38}
39
40pub type Diffs = Vec<(String, Diff)>;
45
46#[must_use]
48pub fn diffs_json(diffs: &[(String, Diff)]) -> Value {
49 let mut m = Map::new();
50 for (path, d) in diffs {
51 m.insert(
52 path.clone(),
53 json!({ "before": d.before, "after": d.after }),
54 );
55 }
56 Value::Object(m)
57}
58
59fn assign_diffs(diffs: &mut Diffs, more: Diffs) {
62 for (path, d) in more {
63 if let Some(slot) = diffs.iter_mut().find(|(p, _)| *p == path) {
64 slot.1 = d;
65 } else {
66 diffs.push((path, d));
67 }
68 }
69}
70
71#[derive(Clone, Debug, PartialEq, Eq)]
73pub struct DocOpResult {
74 pub doc_id: String,
75 pub path: String,
76 pub committed: bool,
77 pub diffs: Option<Diffs>,
81}
82
83impl DocOpResult {
84 #[must_use]
85 pub fn to_json(&self) -> Value {
86 let mut m = Map::new();
87 m.insert("doc".to_owned(), json!(self.doc_id));
88 m.insert("path".to_owned(), json!(self.path));
89 m.insert("committed".to_owned(), json!(self.committed));
90 if let Some(diffs) = &self.diffs {
91 m.insert("diffs".to_owned(), diffs_json(diffs));
92 }
93 Value::Object(m)
94 }
95}
96
97#[derive(Clone, Debug, PartialEq, Eq)]
99pub struct Retargeted {
100 pub blocks: Vec<String>,
101 pub docs: Vec<String>,
102}
103
104#[derive(Clone, Debug, PartialEq, Eq)]
106pub struct DocMoveResult {
107 pub doc_id: String,
108 pub path: String,
109 pub committed: bool,
110 pub diffs: Option<Diffs>,
113 pub dangling: Vec<InboundLink>,
116 pub retargeted: Option<Retargeted>,
119}
120
121impl DocMoveResult {
122 #[must_use]
124 pub fn to_json(&self) -> Value {
125 let mut m = Map::new();
126 m.insert("doc".to_owned(), json!(self.doc_id));
127 m.insert("path".to_owned(), json!(self.path));
128 m.insert("committed".to_owned(), json!(self.committed));
129 if let Some(diffs) = &self.diffs {
130 m.insert("diffs".to_owned(), diffs_json(diffs));
131 }
132 m.insert(
133 "dangling".to_owned(),
134 Value::Array(self.dangling.iter().map(InboundLink::to_json).collect()),
135 );
136 m.insert(
137 "retargeted".to_owned(),
138 json!(
139 self.retargeted
140 .as_ref()
141 .map(|r| json!({ "blocks": r.blocks, "docs": r.docs }))
142 ),
143 );
144 Value::Object(m)
145 }
146}
147
148struct RetargetPlan {
153 ops: Vec<Op>,
154 blocks: Vec<String>,
155 docs: Vec<String>,
156 dangling: Vec<InboundLink>,
157}
158
159#[must_use]
161pub fn canonical(path: &str) -> String {
162 path.trim_start_matches('/').replace('\\', "/")
163}
164
165#[must_use]
169pub fn compose_file(markdown: &str, frontmatter: Option<&Map<String, Value>>) -> String {
170 let body = if markdown.ends_with('\n') || markdown.is_empty() {
171 markdown.to_owned()
172 } else {
173 format!("{markdown}\n")
174 };
175 let Some(fm) = frontmatter.filter(|m| !m.is_empty()) else {
176 return body;
177 };
178 let yaml = stringify(fm);
179 let yaml = yaml.strip_suffix('\n').unwrap_or(&yaml);
180 let sep = if body.starts_with('\n') { "" } else { "\n" };
181 format!("---\n{yaml}\n---\n{sep}{body}")
182}
183
184static FRONTMATTER: LazyLock<Regex> =
185 LazyLock::new(|| Regex::new(r"(?s)^---\r?\n(.*?)\r?\n---\r?\n?").expect("regex"));
186
187fn yaml_to_json(v: &YamlValue) -> Value {
188 v.to_json()
189}
190
191#[must_use]
194pub fn split_frontmatter(content: &str) -> (Map<String, Value>, String) {
195 let Some(caps) = FRONTMATTER.captures(content) else {
196 return (Map::new(), content.to_owned());
197 };
198 let whole = caps.get(0).expect("match");
199 let yaml = caps.get(1).map_or("", |m| m.as_str());
200 let fm = match parse_document(yaml) {
201 Some(YamlValue::Mapping(m)) => match yaml_to_json(&YamlValue::Mapping(m)) {
202 Value::Object(o) => o,
203 _ => Map::new(),
204 },
205 _ => Map::new(),
206 };
207 (fm, content[whole.end()..].to_owned())
208}
209
210fn patch_frontmatter(original: &str, set: Option<&Map<String, Value>>, unset: &[String]) -> String {
213 let (mut merged, body) = split_frontmatter(original);
214 if let Some(set) = set {
215 for (k, v) in set {
216 merged.insert(k.clone(), v.clone());
217 }
218 }
219 if !unset.is_empty() {
220 merged = merged
221 .into_iter()
222 .filter(|(k, _)| !unset.contains(k))
223 .collect();
224 }
225 compose_file(&body, Some(&merged))
226}
227
228fn doc_missing(r: &str) -> crate::error::Error {
229 MutationError::new(ErrorCode::DocMissing, format!("no document {r}")).into()
230}
231
232fn path_taken(msg: String) -> crate::error::Error {
233 MutationError::new(ErrorCode::PathTaken, msg).into()
234}
235
236pub struct DryRun<'s>(&'s mut Store);
244
245impl std::fmt::Debug for DryRun<'_> {
246 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247 f.write_str("DryRun(Store)")
248 }
249}
250
251impl DryRun<'_> {
252 pub fn docs_create(
254 &mut self,
255 ctx: &DocOpContext,
256 doc_store: &mut dyn DocStore,
257 path: &str,
258 markdown: &str,
259 frontmatter: Option<&Map<String, Value>>,
260 ) -> Result<DocOpResult> {
261 self.0
262 .create_doc(ctx, doc_store, path, markdown, frontmatter, true)
263 }
264
265 pub fn docs_move(
269 &mut self,
270 ctx: &DocOpContext,
271 doc_store: &mut dyn DocStore,
272 doc_ref: &str,
273 to_path: &str,
274 retarget_inbound: bool,
275 ) -> Result<DocMoveResult> {
276 self.0
277 .move_doc(ctx, doc_store, doc_ref, to_path, retarget_inbound, true)
278 }
279
280 pub fn docs_delete(
282 &mut self,
283 ctx: &DocOpContext,
284 doc_store: &mut dyn DocStore,
285 doc_ref: &str,
286 ) -> Result<DocOpResult> {
287 self.0.delete_doc(ctx, doc_store, doc_ref, true)
288 }
289
290 pub fn docs_set_meta(
292 &mut self,
293 ctx: &DocOpContext,
294 doc_store: &mut dyn DocStore,
295 doc_ref: &str,
296 set: Option<&Map<String, Value>>,
297 unset: &[String],
298 ) -> Result<DocOpResult> {
299 self.0
300 .set_meta_doc(ctx, doc_store, doc_ref, set, unset, true)
301 }
302}
303
304impl Store {
305 pub fn dry_run(&mut self) -> DryRun<'_> {
307 DryRun(self)
308 }
309
310 fn live_doc_at(&self, repo_id: &str, path: &str) -> Result<bool> {
311 Ok(self
312 .conn
313 .query_row(
314 "SELECT 1 FROM docs WHERE repo_id = ?1 AND path = ?2 AND deleted_commit IS NULL",
315 params![repo_id, path],
316 |_| Ok(()),
317 )
318 .optional()?
319 .is_some())
320 }
321
322 pub fn docs_create(
326 &mut self,
327 ctx: &DocOpContext,
328 doc_store: &mut dyn DocStore,
329 path: &str,
330 markdown: &str,
331 frontmatter: Option<&Map<String, Value>>,
332 ) -> Result<DocOpResult> {
333 self.create_doc(ctx, doc_store, path, markdown, frontmatter, false)
334 }
335
336 fn create_doc(
337 &mut self,
338 ctx: &DocOpContext,
339 doc_store: &mut dyn DocStore,
340 path: &str,
341 markdown: &str,
342 frontmatter: Option<&Map<String, Value>>,
343 dry_run: bool,
344 ) -> Result<DocOpResult> {
345 let rel = canonical(path);
346 if self.live_doc_at(&ctx.repo_id, &rel)? {
347 return Err(path_taken(format!("document already exists at {rel}")));
348 }
349 let content = compose_file(markdown, frontmatter);
350 if doc_store.exists(&rel) {
351 return Err(path_taken(format!("file already exists on disk at {rel}")));
352 }
353 if dry_run {
354 let doc_id = self.ids.at(&self.conn).mint("d")?;
355 let diffs = vec![(
356 rel.clone(),
357 Diff {
358 before: String::new(),
359 after: content,
360 },
361 )];
362 return Ok(DocOpResult {
363 doc_id,
364 path: rel,
365 committed: false,
366 diffs: Some(diffs),
367 });
368 }
369 doc_store.write(&rel, &content)?;
370 let reason = format!("create {rel}");
371 let c = self.reconciling_ingest(
372 &ctx.repo_id,
373 &rel,
374 &content,
375 &ctx.ts,
376 Origin::Api,
377 ctx.actor.as_deref(),
378 Some(&reason),
379 &Config::default(),
380 )?;
381 Ok(DocOpResult {
382 doc_id: c.doc_id,
383 path: rel,
384 committed: true,
385 diffs: None,
386 })
387 }
388
389 pub fn docs_move(
396 &mut self,
397 ctx: &DocOpContext,
398 doc_store: &mut dyn DocStore,
399 doc_ref: &str,
400 to_path: &str,
401 retarget_inbound: bool,
402 ) -> Result<DocMoveResult> {
403 self.move_doc(ctx, doc_store, doc_ref, to_path, retarget_inbound, false)
404 }
405
406 fn move_doc(
407 &mut self,
408 ctx: &DocOpContext,
409 doc_store: &mut dyn DocStore,
410 doc_ref: &str,
411 to_path: &str,
412 retarget_inbound: bool,
413 dry_run: bool,
414 ) -> Result<DocMoveResult> {
415 let info = find_doc_by_ref(&self.conn, &ctx.repo_id, doc_ref)?
416 .ok_or_else(|| doc_missing(doc_ref))?;
417 let to_rel = canonical(to_path);
418 if self.live_doc_at(&ctx.repo_id, &to_rel)? {
419 return Err(path_taken(format!("a document already exists at {to_rel}")));
420 }
421 let inbound = inbound_links_to(&self.conn, &ctx.repo_id, &info.doc_id, &info.path)?;
422 if doc_store.exists(&to_rel) {
423 return Err(path_taken(format!(
424 "file already exists on disk at {to_rel}"
425 )));
426 }
427 let content = doc_store.read(&info.path)?.unwrap_or_default();
428 if dry_run {
429 let mut diffs: Diffs = vec![
430 (
431 info.path.clone(),
432 Diff {
433 before: content.clone(),
434 after: String::new(),
435 },
436 ),
437 (
438 to_rel.clone(),
439 Diff {
440 before: String::new(),
441 after: content,
442 },
443 ),
444 ];
445 if !retarget_inbound || inbound.is_empty() {
446 return Ok(DocMoveResult {
447 doc_id: info.doc_id,
448 path: to_rel,
449 committed: false,
450 diffs: Some(diffs),
451 dangling: inbound,
452 retargeted: None,
453 });
454 }
455 let plan = self.plan_inbound_retarget(&info.doc_id, &info.path, &to_rel, &inbound)?;
456 if !plan.ops.is_empty() {
457 let req = retarget_request(ctx, plan.ops, &info.path, &to_rel, true);
458 let preview = self.apply(&req, doc_store, &ctx.ts)?;
459 assign_diffs(&mut diffs, preview.diffs.unwrap_or_default());
460 }
461 return Ok(DocMoveResult {
462 doc_id: info.doc_id,
463 path: to_rel,
464 committed: false,
465 diffs: Some(diffs),
466 dangling: plan.dangling,
467 retargeted: Some(Retargeted {
468 blocks: plan.blocks,
469 docs: plan.docs,
470 }),
471 });
472 }
473 if doc_store.exists(&info.path) {
474 doc_store.rename(&info.path, &to_rel)?;
475 } else {
476 doc_store.write(&to_rel, &content)?;
477 }
478 {
479 let tx = self.conn.unchecked_transaction()?;
480 let reason = format!("move {} -> {to_rel}", info.path);
481 new_commit(
482 &tx,
483 &mut self.ids.at(&tx),
484 &NewCommit {
485 repo_id: &ctx.repo_id,
486 ts: &ctx.ts,
487 origin: Origin::Api,
488 actor: ctx.actor.as_deref(),
489 reason: Some(&reason),
490 checkpoint_id: None,
491 ops: None,
492 },
493 )?;
494 tx.execute(
495 "UPDATE docs SET path = ?1 WHERE doc_id = ?2",
496 params![to_rel, info.doc_id],
497 )?;
498 tx.execute(
499 "UPDATE revisions SET path = ?1 WHERE doc_id = ?2 AND rev_id = ?3",
500 params![to_rel, info.doc_id, info.current_rev],
501 )?;
502 let phantom = format!("phantom:{}", info.path);
503 let mut affected: Vec<String> = Vec::new();
504 {
505 let mut stmt = tx.prepare(
506 "SELECT DISTINCT src_doc FROM edges WHERE dst_node = ?1 AND to_commit IS NULL AND src_doc != ?1",
507 )?;
508 let it = stmt.query_map(params![info.doc_id], |r| r.get::<_, String>(0))?;
509 for src in it {
510 let src = src?;
511 if !affected.contains(&src) {
512 affected.push(src);
513 }
514 }
515 }
516 tx.execute(
517 "UPDATE edges SET dst_node = ?1 WHERE dst_node = ?2 AND to_commit IS NULL AND src_doc != ?2",
518 params![phantom, info.doc_id],
519 )?;
520 for l in &inbound {
521 let Some(block) = &l.block else {
522 continue;
523 };
524 if l.doc != info.doc_id {
525 continue;
526 }
527 tx.execute(
528 "UPDATE edges SET dst_node = ?1 WHERE dst_node = ?2 AND to_commit IS NULL AND src_doc = ?2 AND src_block = ?3 AND anchor IS ?4",
529 params![phantom, info.doc_id, block, l.anchor],
530 )?;
531 if !affected.contains(&info.doc_id) {
532 affected.push(info.doc_id.clone());
533 }
534 }
535 for d in &affected {
536 rebuild_doc_edges(&tx, d)?;
537 }
538 adopt_phantoms(&tx, &to_rel, &info.doc_id)?;
539 tx.commit()?;
540 }
541 let moved = DocMoveResult {
542 doc_id: info.doc_id.clone(),
543 path: to_rel.clone(),
544 committed: true,
545 diffs: None,
546 dangling: inbound.clone(),
547 retargeted: None,
548 };
549 if !retarget_inbound || inbound.is_empty() {
550 return Ok(moved);
551 }
552 let plan = self.plan_inbound_retarget(&info.doc_id, &info.path, &to_rel, &inbound)?;
557 if !plan.ops.is_empty() {
558 let req = retarget_request(ctx, plan.ops, &info.path, &to_rel, false);
559 self.apply(&req, doc_store, &ctx.ts)?;
560 }
561 Ok(DocMoveResult {
562 dangling: plan.dangling,
563 retargeted: Some(Retargeted {
564 blocks: plan.blocks,
565 docs: plan.docs,
566 }),
567 ..moved
568 })
569 }
570
571 fn plan_inbound_retarget(
576 &self,
577 doc_id: &str,
578 from_path: &str,
579 to_rel: &str,
580 inbound: &[InboundLink],
581 ) -> Result<RetargetPlan> {
582 let mut by_block_order: Vec<String> = Vec::new();
583 let mut by_block: HashMap<String, &InboundLink> = HashMap::new();
584 for l in inbound {
585 if let Some(b) = &l.block {
586 if !by_block.contains_key(b) {
587 by_block_order.push(b.clone());
588 by_block.insert(b.clone(), l);
589 }
590 }
591 }
592 let parent_of = |id: &str| -> Result<Option<String>> {
593 Ok(self
594 .conn
595 .query_row(
596 "SELECT parent_block FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
597 params![id],
598 |r| r.get::<_, Option<String>>(0),
599 )
600 .optional()?
601 .flatten())
602 };
603 let mut containers: BTreeSet<String> = BTreeSet::new();
604 for block_id in &by_block_order {
605 let mut cur = Some(block_id.clone());
606 while let Some(c) = cur {
607 cur = parent_of(&c)?;
608 if let Some(p) = &cur {
609 if by_block.contains_key(p) {
610 containers.insert(p.clone());
611 }
612 }
613 }
614 }
615 let mut ops: Vec<Op> = Vec::new();
616 let mut blocks: Vec<String> = Vec::new();
617 let mut docs: Vec<String> = Vec::new();
618 for block_id in &by_block_order {
619 if containers.contains(block_id) {
620 continue;
621 }
622 let src = by_block[block_id];
623 let row: Option<Vec<u8>> = self
624 .conn
625 .query_row(
626 "SELECT raw_hash FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
627 params![block_id],
628 |r| r.get(0),
629 )
630 .optional()?;
631 let Some(raw_hash) = row else {
632 continue;
633 };
634 let raw = blob_text(&self.conn, &raw_hash)?;
635 let write_dir = if src.doc == doc_id {
640 doc_dir_of(to_rel)
641 } else {
642 doc_dir_of(&src.path)
643 };
644 let Some(new_raw) =
645 retarget_links_in_raw(&raw, doc_dir_of(&src.path), write_dir, from_path, to_rel)
646 else {
647 continue;
648 };
649 ops.push(Op::Update {
650 block: block_id.clone(),
651 markdown: Some(new_raw),
652 attrs: None,
653 expect: Some(Expect::content(hex(&raw_hash))),
654 trivia: None,
655 child_ids: None,
656 });
657 blocks.push(block_id.clone());
658 if !docs.contains(&src.doc) {
659 docs.push(src.doc.clone());
660 }
661 }
662 let rewritten: BTreeSet<&str> = blocks
664 .iter()
665 .map(String::as_str)
666 .chain(containers.iter().map(String::as_str))
667 .collect();
668 let dangling = inbound
669 .iter()
670 .filter(|l| l.block.as_deref().is_none_or(|b| !rewritten.contains(b)))
671 .cloned()
672 .collect();
673 Ok(RetargetPlan {
674 ops,
675 blocks,
676 docs,
677 dangling,
678 })
679 }
680
681 pub fn docs_delete(
685 &mut self,
686 ctx: &DocOpContext,
687 doc_store: &mut dyn DocStore,
688 doc_ref: &str,
689 ) -> Result<DocOpResult> {
690 self.delete_doc(ctx, doc_store, doc_ref, false)
691 }
692
693 fn delete_doc(
694 &mut self,
695 ctx: &DocOpContext,
696 doc_store: &mut dyn DocStore,
697 doc_ref: &str,
698 dry_run: bool,
699 ) -> Result<DocOpResult> {
700 let info = find_doc_by_ref(&self.conn, &ctx.repo_id, doc_ref)?
701 .ok_or_else(|| doc_missing(doc_ref))?;
702 if dry_run {
703 let before = doc_store.read(&info.path)?.unwrap_or_default();
704 let diffs = vec![(
705 info.path.clone(),
706 Diff {
707 before,
708 after: String::new(),
709 },
710 )];
711 return Ok(DocOpResult {
712 doc_id: info.doc_id,
713 path: info.path,
714 committed: false,
715 diffs: Some(diffs),
716 });
717 }
718 {
719 let tx = self.conn.unchecked_transaction()?;
720 let reason = format!("delete {}", info.path);
721 let (commit_id, _) = new_commit(
722 &tx,
723 &mut self.ids.at(&tx),
724 &NewCommit {
725 repo_id: &ctx.repo_id,
726 ts: &ctx.ts,
727 origin: Origin::Api,
728 actor: ctx.actor.as_deref(),
729 reason: Some(&reason),
730 checkpoint_id: None,
731 ops: None,
732 },
733 )?;
734 fts_delete_doc(&tx, &info.doc_id)?;
735 tx.execute(
736 "UPDATE blocks SET deleted_commit = ?1 WHERE doc_id = ?2 AND deleted_commit IS NULL",
737 params![commit_id, info.doc_id],
738 )?;
739 tx.execute(
740 "UPDATE docs SET deleted_commit = ?1 WHERE doc_id = ?2",
741 params![commit_id, info.doc_id],
742 )?;
743 tx.commit()?;
744 }
745 doc_store.remove(&info.path)?;
746 Ok(DocOpResult {
747 doc_id: info.doc_id,
748 path: info.path,
749 committed: true,
750 diffs: None,
751 })
752 }
753
754 pub fn docs_set_meta(
758 &mut self,
759 ctx: &DocOpContext,
760 doc_store: &mut dyn DocStore,
761 doc_ref: &str,
762 set: Option<&Map<String, Value>>,
763 unset: &[String],
764 ) -> Result<DocOpResult> {
765 self.set_meta_doc(ctx, doc_store, doc_ref, set, unset, false)
766 }
767
768 fn set_meta_doc(
769 &mut self,
770 ctx: &DocOpContext,
771 doc_store: &mut dyn DocStore,
772 doc_ref: &str,
773 set: Option<&Map<String, Value>>,
774 unset: &[String],
775 dry_run: bool,
776 ) -> Result<DocOpResult> {
777 let info = find_doc_by_ref(&self.conn, &ctx.repo_id, doc_ref)?
778 .ok_or_else(|| doc_missing(doc_ref))?;
779 let original = doc_store.read(&info.path)?.unwrap_or_default();
780 let content = patch_frontmatter(&original, set, unset);
781 if dry_run {
782 let diffs = vec![(
783 info.path.clone(),
784 Diff {
785 before: original,
786 after: content,
787 },
788 )];
789 return Ok(DocOpResult {
790 doc_id: info.doc_id,
791 path: info.path,
792 committed: false,
793 diffs: Some(diffs),
794 });
795 }
796 doc_store.write(&info.path, &content)?;
797 let reason = format!("set_meta {}", info.path);
798 let c = self.reconciling_ingest(
799 &ctx.repo_id,
800 &info.path,
801 &content,
802 &ctx.ts,
803 Origin::Api,
804 ctx.actor.as_deref(),
805 Some(&reason),
806 &Config::default(),
807 )?;
808 Ok(DocOpResult {
809 doc_id: c.doc_id,
810 path: info.path,
811 committed: true,
812 diffs: None,
813 })
814 }
815}
816
817fn retarget_request(
820 ctx: &DocOpContext,
821 ops: Vec<Op>,
822 from_path: &str,
823 to_rel: &str,
824 dry_run: bool,
825) -> ApplyRequest {
826 ApplyRequest {
827 repo_id: ctx.repo_id.clone(),
828 ops,
829 origin: ApplyOrigin {
830 actor: ctx.actor.clone().unwrap_or_else(|| "api".to_owned()),
831 reason: Some(format!("retarget inbound links {from_path} -> {to_rel}")),
832 },
833 dry_run,
834 set_frontmatter: Vec::new(),
835 }
836}
837
838#[cfg(test)]
839mod tests {
840 use super::*;
841
842 #[test]
843 fn composes_files_with_and_without_frontmatter() {
844 assert_eq!(compose_file("# New\n\nHello.", None), "# New\n\nHello.\n");
845 assert_eq!(compose_file("", None), "");
846 let empty = Map::new();
847 assert_eq!(compose_file("Body only.\n", Some(&empty)), "Body only.\n");
848 let fm: Map<String, Value> =
849 serde_json::from_str(r#"{"title":"Hi","count":2,"draft":true,"tags":["a","b"]}"#)
850 .unwrap();
851 assert_eq!(
852 compose_file("# New\n", Some(&fm)),
853 "---\ntitle: Hi\ncount: 2\ndraft: true\ntags:\n - a\n - b\n---\n\n# New\n"
854 );
855 assert!(compose_file("\n# B\n", Some(&fm)).ends_with("---\n\n# B\n"));
856 }
857
858 #[test]
859 fn splits_frontmatter_like_the_reference_regex() {
860 let (fm, body) =
861 split_frontmatter("---\ntitle: Hello\ntags:\n - x\n---\n\n# Body\n\nText.\n");
862 assert_eq!(fm["title"], json!("Hello"));
863 assert_eq!(fm["tags"], json!(["x"]));
864 assert_eq!(body, "\n# Body\n\nText.\n");
865 let (fm, body) = split_frontmatter("# No fm\n");
866 assert!(fm.is_empty());
867 assert_eq!(body, "# No fm\n");
868 let (fm, body) = split_frontmatter("---\n- not a map\n---\nbody");
869 assert!(fm.is_empty());
870 assert_eq!(body, "body");
871 let (_, body) = split_frontmatter("---\r\na: 1\r\n---\r\nbody");
872 assert_eq!(body, "body");
873 }
874
875 #[test]
876 fn canonical_paths() {
877 assert_eq!(canonical("/dir\\sub\\x.md"), "dir/sub/x.md");
878 assert_eq!(canonical("//a.md"), "a.md");
879 assert_eq!(canonical("a.md"), "a.md");
880 }
881
882 #[test]
883 fn diffs_assign_like_object_assign_and_render_in_order() {
884 let d = |b: &str, a: &str| Diff {
885 before: b.to_owned(),
886 after: a.to_owned(),
887 };
888 let mut diffs: Diffs = vec![
889 ("b.md".to_owned(), d("x", "")),
890 ("n/b.md".to_owned(), d("", "x")),
891 ];
892 assign_diffs(
893 &mut diffs,
894 vec", "[b](n/b.md)")),
896 ("b.md".to_owned(), d("x", "y")),
897 ],
898 );
899 let keys: Vec<&str> = diffs.iter().map(|(p, _)| p.as_str()).collect();
900 assert_eq!(keys, ["b.md", "n/b.md", "a.md"]);
901 assert_eq!(diffs[0].1, d("x", "y"));
902 assert_eq!(
903 serde_json::to_string(&diffs_json(&diffs)).unwrap(),
904 r#"{"b.md":{"before":"x","after":"y"},"n/b.md":{"before":"","after":"x"},"a.md":{"before":"[b](b.md)","after":"[b](n/b.md)"}}"#
905 );
906 }
907
908 #[test]
909 fn set_meta_patch_merges_sets_and_drops_unsets() {
910 let set: Map<String, Value> = serde_json::from_str(r#"{"status":"done","n":2}"#).unwrap();
911 let out = patch_frontmatter(
912 "---\ntitle: T\nstatus: open\nold: 1\n---\n\n# Body\n",
913 Some(&set),
914 &["old".to_owned()],
915 );
916 assert_eq!(out, "---\ntitle: T\nstatus: done\nn: 2\n---\n\n# Body\n");
917 assert_eq!(patch_frontmatter("# Plain\n", None, &[]), "# Plain\n");
918 }
919}