Skip to main content

omgbase_store/
docs_ops.rs

1//! Document operations (`spec/mutate/README.md` §6): create, move, delete,
2//! set-meta — `api` commits with generated reasons, each following the
3//! file-first protocol against a [`DocStore`]. Each has a dry run
4//! (`spec/surface` §4, 1.3) behind [`Store::dry_run`]: every check the real
5//! operation makes runs, nothing is written or committed, and the result
6//! carries the per-file [`Diff`]s the operation would produce.
7
8use 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/// What a document operation runs with.
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct DocOpContext {
33    pub repo_id: String,
34    /// `commits.actor` (`NULL` when absent).
35    pub actor: Option<String>,
36    /// The commit timestamp (RFC 3339 UTC).
37    pub ts: String,
38}
39
40/// The file changes a dry run would make, keyed by repo-relative path in the
41/// order the reference builds them (`spec/surface` §4, 1.3): `before` the
42/// current bytes (`""` for a file that would be created), `after` the bytes
43/// the operation would leave (`""` for a file that would be removed).
44pub type Diffs = Vec<(String, Diff)>;
45
46/// `Diffs` on the wire: `{ "<path>": { "before", "after" } }`.
47#[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
59/// The reference's `Object.assign(diffs, more)`: a path already present keeps
60/// its position and takes the new diff; a new path is appended.
61fn 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/// `{ doc, path, committed, diffs? }`.
72#[derive(Clone, Debug, PartialEq, Eq)]
73pub struct DocOpResult {
74    pub doc_id: String,
75    pub path: String,
76    pub committed: bool,
77    /// With a dry run, the changes the operation would make (a create: `""`
78    /// → the composed file; a delete: the file → `""`; a set-meta: before →
79    /// after). `None` on a committed result.
80    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/// With `retarget_inbound`: the blocks rewritten and the documents they live in.
98#[derive(Clone, Debug, PartialEq, Eq)]
99pub struct Retargeted {
100    pub blocks: Vec<String>,
101    pub docs: Vec<String>,
102}
103
104/// The result of `docs_move`.
105#[derive(Clone, Debug, PartialEq, Eq)]
106pub struct DocMoveResult {
107    pub doc_id: String,
108    pub path: String,
109    pub committed: bool,
110    /// With a dry run: the old path emptied, the new path filled, then any
111    /// source the `retarget_inbound` rewrite would touch. `None` when committed.
112    pub diffs: Option<Diffs>,
113    /// Inbound links still naming the old path after the call (with the
114    /// retarget: the frontmatter relations, which are never rewritten).
115    pub dangling: Vec<InboundLink>,
116    /// `None` when the retarget was opted out or nothing linked in; empty
117    /// lists when it ran but only frontmatter relations linked in.
118    pub retargeted: Option<Retargeted>,
119}
120
121impl DocMoveResult {
122    /// `{ doc, path, committed, diffs?, dangling, retargeted }`.
123    #[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
148/// The `retarget_inbound` plan of a move: one coalesced, CAS-pinned `update`
149/// per deepest hit block, the blocks and docs it touches, and the inbound
150/// links it leaves dangling. Pure over the store's current blocks — the move
151/// itself changes no block — so the real move and its dry run plan identically.
152struct RetargetPlan {
153    ops: Vec<Op>,
154    blocks: Vec<String>,
155    docs: Vec<String>,
156    dangling: Vec<InboundLink>,
157}
158
159/// §6: strip leading `/`s, `\` → `/`.
160#[must_use]
161pub fn canonical(path: &str) -> String {
162    path.trim_start_matches('/').replace('\\', "/")
163}
164
165/// §6: the file bytes from a body and optional frontmatter (the body's
166/// trailing `\n` ensured; a non-empty mapping serialized by the YAML emitter
167/// between fences, a blank line before the body unless it starts with one).
168#[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/// §6 `docs_set_meta`: `(frontmatter mapping, body)`; missing or malformed
192/// frontmatter → `({}, content)`.
193#[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
210/// §6 `docs_set_meta`'s rewrite: `set` merged over the file's frontmatter,
211/// `unset` removed, recomposed over the same body.
212fn 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
236/// The dry-run view of the document operations (`spec/surface` §4, 1.3; the
237/// reference's `DocOpContext.dryRun`): the same four calls as on [`Store`],
238/// each validating exactly as the real run does (`doc_missing`,
239/// `path_taken`, …), writing and committing nothing, and returning
240/// `committed: false` with the per-file `diffs`. A create still mints its
241/// `d_` id (`apply`'s rule: a dry run consumes the id the real run would
242/// take next). Obtained from [`Store::dry_run`].
243pub 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    /// `docs_create` without writing: `{ "<path>": { before: "", after: bytes } }`.
253    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    /// `docs_move` without renaming: the old path → `""`, the new path ←
266    /// bytes; with `retarget_inbound`, `dangling`/`retargeted` as the real
267    /// run would report them and the rewritten sources' diffs.
268    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    /// `docs_delete` without deleting: `{ "<path>": { before: bytes, after: "" } }`.
281    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    /// `docs_set_meta` without writing: the file before → after the patch.
291    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    /// The document operations as previews: see [`DryRun`].
306    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    /// §6 `docs_create`: a new document from complete bytes (frontmatter as
323    /// a mapping); `path_taken` when a live doc or a file exists; ingested
324    /// with the reconciling resolver (`reason: "create <path>"`).
325    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    /// §6 `docs_move`: rename a document (identity kept); an `api` commit
390    /// with no revision; open edges from other documents re-pointed to
391    /// `phantom:<old path>` (a self edge only when its link names the path);
392    /// phantoms at the new path adopted. With `retarget_inbound` — the
393    /// surface's default since mutate 1.3 — the dangling links are rewritten
394    /// as one follow-up changeset; `false` leaves them as written.
395    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        // Rewrite the dangling links block by block (one coalesced update per
553        // block, CAS on the block's current hash) and apply as one changeset;
554        // the re-ingest re-extracts each source doc, whose links now resolve
555        // to the moved doc.
556        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    /// The `retarget_inbound` plan of a move `from_path → to_rel` over the
572    /// store's current blocks: deepest hit block per inbound link (a
573    /// container whose raw includes a hit child's raw is covered by the
574    /// child), each rewritten against the source's *current* directory.
575    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            // Match against the directory the link was RESOLVED in (the
636            // source's path at extraction time); write relative forms against
637            // the source's CURRENT directory — they differ only for links
638            // inside the moved doc itself.
639            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        // Containers whose rewritten child covered them count as rewritten too.
663        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    /// §6 `docs_delete`: one transaction — an `api` commit, FTS rows dropped,
682    /// live blocks and the doc tombstoned, nothing pooled — then the file
683    /// removed.
684    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    /// §6 `docs_set_meta`: read the file, split the frontmatter, merge `set`,
755    /// delete `unset`, compose, write, ingest with the reconciling resolver
756    /// (`reason: "set_meta <path>"`).
757    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
817/// The follow-up changeset of a `retarget_inbound` move: the context's repo,
818/// actor (`api` when absent) and a generated reason.
819fn 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![
895                ("a.md".to_owned(), d("[b](b.md)", "[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}