1use std::path::Path;
16
17use omgbase_format::BlockKind;
18use omgbase_format::text::{normalize_text, normalize_visible_text};
19use omgbase_reconcile::Config;
20use omgbase_search::EmbeddingProvider;
21use omgbase_store::docs_ops::diffs_json;
22use omgbase_store::mutate_kernel::{At, Op, Parent, To};
23use omgbase_store::{
24 ApplyOrigin, ApplyRequest, ApplyResult, DocOpContext, DocStore, Expect, FsDocStore, Opset,
25 QueryVector, Store,
26};
27use omgbase_sync::{RealFileSystem, RepoRow};
28use rusqlite::{OptionalExtension, params};
29use serde_json::{Map, Value as Json, json};
30
31use crate::error::{Result, SurfaceError};
32use crate::graph::{GraphArgs, graph_neighborhood};
33use crate::history;
34use crate::links;
35use crate::paths::{reference_keyed, reference_path, root_field, storage_path};
36use crate::query::{QueryOptions, query};
37use crate::read::{self, Resolution, ResolvedRef};
38use crate::reference::QUERY_SYNTAX;
39use crate::version::{BuildInfo, version_info};
40
41pub const ACTOR: &str = "agent:mcp";
43
44#[derive(Clone, Debug, PartialEq)]
46pub struct ToolSpec {
47 pub name: &'static str,
48 pub description: &'static str,
49 pub input_schema: Json,
51}
52
53#[derive(Clone, Debug, PartialEq)]
56pub struct ToolOutcome {
57 pub body: Json,
58 pub is_error: bool,
59}
60
61enum WriteTarget {
63 Derived,
66 Fixed(Box<dyn DocStore>),
69}
70
71pub struct Surface {
73 store: Store,
74 default_repo: String,
75 provider: Option<Box<dyn EmbeddingProvider>>,
76 on_mutation: Option<Box<dyn FnMut()>>,
77 clock: Box<dyn FnMut() -> String>,
78 writes: WriteTarget,
79 config: Config,
80 actor: String,
82 build_info: BuildInfo,
84}
85
86fn bad_args(msg: impl Into<String>) -> SurfaceError {
89 SurfaceError::filter_invalid(msg, "arguments")
90}
91
92fn arg_str<'a>(args: &'a Json, key: &str) -> Option<&'a str> {
93 args.get(key).and_then(Json::as_str)
94}
95
96fn arg_string(args: &Json, key: &str) -> Result<String> {
97 arg_str(args, key)
98 .map(str::to_owned)
99 .ok_or_else(|| bad_args(format!("`{key}` must be a string")))
100}
101
102fn arg_i64(args: &Json, key: &str) -> Result<Option<i64>> {
103 match args.get(key) {
104 None | Some(Json::Null) => Ok(None),
105 Some(v) => v
106 .as_f64()
107 .filter(|n| n.fract() == 0.0)
108 .map(|n| Some(n as i64))
109 .ok_or_else(|| bad_args(format!("`{key}` must be an integer"))),
110 }
111}
112
113fn arg_usize(args: &Json, key: &str) -> Result<Option<usize>> {
114 Ok(arg_i64(args, key)?.map(|n| usize::try_from(n).unwrap_or(0)))
115}
116
117fn arg_bool(args: &Json, key: &str) -> Result<Option<bool>> {
118 match args.get(key) {
119 None | Some(Json::Null) => Ok(None),
120 Some(Json::Bool(b)) => Ok(Some(*b)),
121 Some(_) => Err(bad_args(format!("`{key}` must be a boolean"))),
122 }
123}
124
125fn arg_strings(args: &Json, key: &str) -> Result<Vec<String>> {
126 let Some(arr) = args.get(key).and_then(Json::as_array) else {
127 return Err(bad_args(format!("`{key}` must be an array of strings")));
128 };
129 arr.iter()
130 .map(|v| {
131 v.as_str()
132 .map(str::to_owned)
133 .ok_or_else(|| bad_args(format!("`{key}` must be an array of strings")))
134 })
135 .collect()
136}
137
138fn arg_object<'a>(args: &'a Json, key: &str) -> Result<Option<&'a Map<String, Json>>> {
139 match args.get(key) {
140 None | Some(Json::Null) => Ok(None),
141 Some(Json::Object(o)) => Ok(Some(o)),
142 Some(_) => Err(bad_args(format!("`{key}` must be an object"))),
143 }
144}
145
146fn hex_field(e: &Map<String, Json>, k: &str) -> Option<String> {
147 e.get(k).and_then(Json::as_str).map(str::to_owned)
148}
149
150fn arg_expect(args: &Json) -> Result<Option<Expect>> {
153 Ok(arg_object(args, "expect")?.map(|e| Expect {
154 content_hash: hex_field(e, "content_hash"),
155 parent_children_hash: hex_field(e, "parent_children_hash"),
156 }))
157}
158
159fn arg_parent_expect(args: &Json) -> Result<Option<Expect>> {
164 Ok(arg_object(args, "expect")?.map(|e| Expect {
165 content_hash: None,
166 parent_children_hash: hex_field(e, "parent_children_hash"),
167 }))
168}
169
170fn resolution_arg(args: &Json, default: Resolution) -> Result<Resolution> {
171 match arg_str(args, "resolution") {
172 None => Ok(default),
173 Some(s) => Resolution::parse(s).ok_or_else(|| {
174 bad_args("`resolution` must be one of skeleton, outline, text, raw, full")
175 }),
176 }
177}
178
179fn merge(mut a: Map<String, Json>, b: Json) -> Json {
181 if let Json::Object(o) = b {
182 for (k, v) in o {
183 a.insert(k, v);
184 }
185 }
186 Json::Object(a)
187}
188
189fn schema(props: &[(&str, Json)], required: &[&str], repo: bool) -> Json {
190 let mut p = Map::new();
191 for (k, v) in props {
192 p.insert((*k).to_owned(), v.clone());
193 }
194 if repo {
195 p.insert("repo".to_owned(), json!({ "type": "string" }));
196 }
197 json!({ "type": "object", "properties": p, "required": required })
198}
199
200fn s() -> Json {
201 json!({ "type": "string" })
202}
203fn i() -> Json {
204 json!({ "type": "integer" })
205}
206fn b() -> Json {
207 json!({ "type": "boolean" })
208}
209fn strings() -> Json {
210 json!({ "type": "array", "items": { "type": "string" } })
211}
212fn obj() -> Json {
213 json!({ "type": "object" })
214}
215fn nullable_string() -> Json {
216 json!({ "type": ["string", "null"] })
217}
218fn resolution_schema() -> Json {
219 json!({ "type": "string", "enum": ["skeleton", "outline", "text", "raw", "full"] })
220}
221fn at_schema() -> Json {
222 json!({ "oneOf": [
223 { "const": "start" }, { "const": "end" },
224 { "type": "object", "properties": { "before": { "type": "string" } }, "required": ["before"] },
225 { "type": "object", "properties": { "after": { "type": "string" } }, "required": ["after"] }
226 ] })
227}
228fn expect_schema() -> Json {
229 json!({ "type": "object", "properties": { "content_hash": { "type": "string" }, "parent_children_hash": { "type": "string" } } })
230}
231fn parent_expect_schema() -> Json {
236 json!({ "type": "object", "properties": { "parent_children_hash": { "type": "string" } } })
237}
238
239#[must_use]
241pub fn tools() -> Vec<ToolSpec> {
242 let dp = |extra: &[(&str, Json)], required: &[&str]| {
243 let mut props = vec![("doc", s()), ("path", s())];
244 props.extend(extra.iter().cloned());
245 schema(&props, required, true)
246 };
247 vec![
248 ToolSpec {
249 name: "docs_outline",
250 description: "A document's compact indented outline (id, type, label per line; § marks headings). Args take a doc id or path.",
251 input_schema: dp(
252 &[
253 (
254 "resolution",
255 json!({ "type": "string", "enum": ["skeleton", "outline"] }),
256 ),
257 ("depth", i()),
258 ("budget_tokens", i()),
259 ],
260 &[],
261 ),
262 },
263 ToolSpec {
264 name: "docs_read",
265 description: "Read a whole document: verbatim `content`, `properties` grouped by source, `path`/`docId`/`rev`; include_ids adds `ids`, `hashes` (CAS tokens) and `parents`.",
266 input_schema: dp(&[("include_ids", b())], &[]),
267 },
268 ToolSpec {
269 name: "docs_get_many",
270 description: "Batch docs_read over `docs` (ids or paths): `{ items, errors, truncated }`, capped at 100 refs, optional token budget.",
271 input_schema: schema(
272 &[
273 ("docs", strings()),
274 ("include_ids", b()),
275 ("budget_tokens", i()),
276 ],
277 &["docs"],
278 true,
279 ),
280 },
281 ToolSpec {
282 name: "nodes_get",
283 description: "Hydrate one block subtree at a resolution (skeleton|outline|text|raw|full); the owning doc is inferred from `id` when `doc`/`path` are omitted.",
284 input_schema: dp(&[("id", s()), ("resolution", resolution_schema())], &["id"]),
285 },
286 ToolSpec {
287 name: "nodes_get_many",
288 description: "Fetch up to 100 blocks by id in request order with budget truncation; `doc`/`path` is an optional scope. Returns `nodes`, `truncated`, `unresolved`.",
289 input_schema: dp(
290 &[
291 ("ids", strings()),
292 ("resolution", resolution_schema()),
293 ("budget_tokens", i()),
294 ],
295 &["ids"],
296 ),
297 },
298 ToolSpec {
299 name: "read_ref",
300 description: "Read any ref — a document (id or path) or a block (`b_` id, or an `n_` node id) — classified as `{ kind: \"document\", … }` or `{ kind: \"block\", … }` (block `resolution` defaults to raw).",
301 input_schema: schema(
302 &[("ref", s()), ("resolution", resolution_schema())],
303 &["ref"],
304 true,
305 ),
306 },
307 ToolSpec {
308 name: "docs_tree",
309 description: "The directory-aware shape of a repo: live docs under `path` collapsed at `depth` segments into dir/doc entries with totals, ordered by path and paged.",
310 input_schema: schema(
311 &[
312 ("path", s()),
313 ("depth", i()),
314 ("limit", i()),
315 ("cursor", nullable_string()),
316 ("budget_tokens", i()),
317 ],
318 &[],
319 true,
320 ),
321 },
322 ToolSpec {
323 name: "docs_list",
324 description: "Enumerate live documents as a page `{ items: [{ path, blocks, ts }], truncated, cursor }`, ordered by path; `path_glob` is a LIKE match (`*` matches across `/`).",
325 input_schema: schema(
326 &[
327 ("path_glob", s()),
328 ("limit", i()),
329 ("cursor", nullable_string()),
330 ("budget_tokens", i()),
331 ],
332 &[],
333 true,
334 ),
335 },
336 ToolSpec {
337 name: "query_syntax",
338 description: "The OQX syntax reference for the `query` tool.",
339 input_schema: schema(&[], &[], false),
340 },
341 ToolSpec {
342 name: "query",
343 description: "Run one OQX query (`select … from docs|blocks|nodes|edges where … follow … order by … limit N`). Returns lean hits `{ id, path, …projections }` with `truncated` + `cursor`, or a `count`/`exists`/`none` scalar, or `values`. See query_syntax.",
344 input_schema: schema(
345 &[
346 ("query", s()),
347 ("limit", i()),
348 ("cursor", nullable_string()),
349 ],
350 &["query"],
351 true,
352 ),
353 },
354 ToolSpec {
355 name: "graph",
356 description: "The bounded neighborhood around root documents in one call — `{ documents, edges, frontier }` — compiled to an OQX `follow doc.out`/`doc.in` walk.",
357 input_schema: schema(
358 &[
359 ("roots", strings()),
360 ("degrees", i()),
361 (
362 "direction",
363 json!({ "type": "string", "enum": ["in", "out", "both"] }),
364 ),
365 ("predicate", s()),
366 ("select", strings()),
367 ("max_documents", i()),
368 ],
369 &["roots"],
370 true,
371 ),
372 },
373 ToolSpec {
374 name: "text_search",
375 description: "Full-text (FTS5, bm25-ranked) search over block text.",
376 input_schema: schema(&[("q", s()), ("limit", i())], &["q"], true),
377 },
378 ToolSpec {
379 name: "resolve",
380 description: "Resolve a name/title/phrase to the blocks it refers to: ranked `{ id, locator, preview, evidence }` (FTS, fused with the vector ranking when a provider exists).",
381 input_schema: schema(&[("query", s()), ("limit", i())], &["query"], true),
382 },
383 ToolSpec {
384 name: "apply",
385 description: "Apply a changeset of kernel ops (insert/update/move/remove/split/merge) atomically; `dry_run` previews diffs.",
386 input_schema: schema(
387 &[
388 (
389 "ops",
390 json!({ "type": "array", "items": { "type": "object" } }),
391 ),
392 ("reason", s()),
393 ("dry_run", b()),
394 ],
395 &["ops"],
396 true,
397 ),
398 },
399 ToolSpec {
400 name: "blocks_insert",
401 description: "Insert blocks parsed from `markdown` under `to` (a block ref, or a document ref for its top level) at `at` (end|start|{before|after}). Optional `expect.parent_children_hash` is the destination-parent CAS: the parent's CURRENT direct child ids (the document's top-level ids for a document `to`) joined by `,` and sha256-hexed — compute it from docs_read include_ids (`ids` filtered by `parents`) — and the insert fails `stale_expectation` (with `data.current.parent_children_hash`) if the siblings changed under you; `content_hash` has no meaning here (there is no target block) and is not accepted.",
402 input_schema: schema(
403 &[
404 ("to", s()),
405 ("markdown", s()),
406 ("at", at_schema()),
407 ("expect", parent_expect_schema()),
408 ("dry_run", b()),
409 ],
410 &["to", "markdown"],
411 true,
412 ),
413 },
414 ToolSpec {
415 name: "blocks_update",
416 description: "Replace a block's markdown and/or set attrs (`checked` folds into attrs) with CAS pinned server-side when `expect` is omitted. Returns `id`, `ids` and the apply result.",
417 input_schema: schema(
418 &[
419 ("block", s()),
420 ("markdown", s()),
421 ("checked", b()),
422 ("attrs", obj()),
423 ("expect", expect_schema()),
424 ("dry_run", b()),
425 ],
426 &["block"],
427 true,
428 ),
429 },
430 ToolSpec {
431 name: "blocks_move",
432 description: "Move blocks under a new parent at a position; `to` is a block ref or the blocks' own document. Optional `expect.parent_children_hash` is the destination-parent CAS (as blocks_insert: the destination parent's CURRENT direct child ids joined by `,`, sha256 hex), checked once for the whole run before anything moves — `stale_expectation` with `data.current.parent_children_hash` if the destination's children changed; `content_hash` is meaningless here and not accepted.",
433 input_schema: schema(
434 &[
435 ("blocks", strings()),
436 ("to", s()),
437 ("at", at_schema()),
438 ("expect", parent_expect_schema()),
439 ("dry_run", b()),
440 ],
441 &["blocks", "to"],
442 true,
443 ),
444 },
445 ToolSpec {
446 name: "blocks_remove",
447 description: "Remove blocks (and their subtrees).",
448 input_schema: schema(
449 &[("blocks", strings()), ("dry_run", b())],
450 &["blocks"],
451 true,
452 ),
453 },
454 ToolSpec {
455 name: "blocks_split",
456 description: "Split a block at UTF-8 byte offsets; CAS pinned server-side.",
457 input_schema: schema(
458 &[
459 ("block", s()),
460 (
461 "at",
462 json!({ "type": "array", "items": { "type": "integer" } }),
463 ),
464 ("dry_run", b()),
465 ],
466 &["block", "at"],
467 true,
468 ),
469 },
470 ToolSpec {
471 name: "blocks_merge",
472 description: "Merge adjacent blocks into the first, joined by `separator`.",
473 input_schema: schema(
474 &[("blocks", strings()), ("separator", s()), ("dry_run", b())],
475 &["blocks"],
476 true,
477 ),
478 },
479 ToolSpec {
480 name: "tasks_complete",
481 description: "Check (or uncheck with checked:false) task blocks.",
482 input_schema: schema(
483 &[("blocks", strings()), ("checked", b()), ("dry_run", b())],
484 &["blocks"],
485 true,
486 ),
487 },
488 ToolSpec {
489 name: "node_set",
490 description: "Set one editable property of a projected node (a link's name/value, a task's checked).",
491 input_schema: schema(
492 &[
493 ("node", s()),
494 ("prop", s()),
495 ("value", s()),
496 ("dry_run", b()),
497 ],
498 &["node", "prop", "value"],
499 true,
500 ),
501 },
502 ToolSpec {
503 name: "sections_append",
504 description: "Append markdown at the end of a heading's section; `heading` is a heading block id or its text (scoped by `doc`/`path`).",
505 input_schema: schema(
506 &[
507 ("heading", s()),
508 ("markdown", s()),
509 ("doc", s()),
510 ("path", s()),
511 ("dry_run", b()),
512 ],
513 &["heading", "markdown"],
514 true,
515 ),
516 },
517 ToolSpec {
518 name: "docs_append",
519 description: "Append markdown at the end of a document as new top-level blocks (existing ids preserved).",
520 input_schema: schema(
521 &[("doc", s()), ("path", s()), ("text", s())],
522 &["text"],
523 true,
524 ),
525 },
526 ToolSpec {
527 name: "links_retarget",
528 description: "Rewrite one link destination everywhere it is linked (dry run by default).",
529 input_schema: schema(
530 &[
531 ("from_target", s()),
532 ("to_target", s()),
533 ("path_glob", s()),
534 ("dry_run", b()),
535 ],
536 &["from_target", "to_target"],
537 true,
538 ),
539 },
540 ToolSpec {
541 name: "links_stale",
542 description: "Dangling internal links (open edges to a `phantom:` target) plus external and total counts; `summary:true` returns counts only.",
543 input_schema: schema(
544 &[("path_glob", s()), ("limit", i()), ("summary", b())],
545 &[],
546 true,
547 ),
548 },
549 ToolSpec {
550 name: "links_repair",
551 description: "Bulk link repair: `repairs` ([{from,to}]) or one `from_target`/`to_target` pair, in one changeset (dry run by default).",
552 input_schema: schema(
553 &[
554 (
555 "repairs",
556 json!({ "type": "array", "items": { "type": "object", "properties": { "from": { "type": "string" }, "to": { "type": "string" } }, "required": ["from", "to"] } }),
557 ),
558 ("from_target", s()),
559 ("to_target", s()),
560 ("path_glob", s()),
561 ("dry_run", b()),
562 ],
563 &[],
564 true,
565 ),
566 },
567 ToolSpec {
568 name: "docs_create",
569 description: "Create a document at `path` from `markdown` with optional `frontmatter`; `dry_run` returns the would-be file under `diffs` without writing.",
570 input_schema: schema(
571 &[
572 ("path", s()),
573 ("markdown", s()),
574 ("frontmatter", obj()),
575 ("dry_run", b()),
576 ],
577 &["path", "markdown"],
578 true,
579 ),
580 },
581 ToolSpec {
582 name: "docs_move",
583 description: "Rename a document to `to_path`, identity preserved; the inbound links are rewritten to the new path in the same call (`retarget_inbound` defaults to `true`; `false` leaves them as written, listed in `dangling`); `dry_run` returns the plan (`dangling`, `retargeted`, per-file `diffs`) without renaming.",
584 input_schema: schema(
585 &[
586 ("doc", s()),
587 ("to_path", s()),
588 ("retarget_inbound", b()),
589 ("dry_run", b()),
590 ],
591 &["doc", "to_path"],
592 true,
593 ),
594 },
595 ToolSpec {
596 name: "docs_delete",
597 description: "Delete a document: tombstone it and remove the file; `dry_run` returns the removal under `diffs` without deleting.",
598 input_schema: schema(&[("doc", s()), ("dry_run", b())], &["doc"], true),
599 },
600 ToolSpec {
601 name: "docs_set_meta",
602 description: "Set and/or unset frontmatter keys, re-ingesting the document; `dry_run` returns the rewritten file under `diffs` without writing.",
603 input_schema: schema(
604 &[
605 ("doc", s()),
606 ("set", obj()),
607 ("unset", strings()),
608 ("dry_run", b()),
609 ],
610 &["doc"],
611 true,
612 ),
613 },
614 ToolSpec {
615 name: "docs_plan_update",
616 description: "Plan a whole-document update without applying: the opset and a one-line-per-op plan.",
617 input_schema: schema(&[("doc", s()), ("content", s())], &["doc", "content"], true),
618 },
619 ToolSpec {
620 name: "docs_update",
621 description: "Whole-document update with identity preservation: plan then apply (`dry_run` returns the plan only).",
622 input_schema: schema(
623 &[
624 ("doc", s()),
625 ("content", s()),
626 ("reason", s()),
627 ("dry_run", b()),
628 ],
629 &["doc", "content"],
630 true,
631 ),
632 },
633 ToolSpec {
634 name: "observe",
635 description: "Record `content` as the authoritative bytes at `path` (an observed-origin commit; an echo when unchanged).",
636 input_schema: schema(
637 &[("path", s()), ("content", s())],
638 &["path", "content"],
639 true,
640 ),
641 },
642 ToolSpec {
643 name: "observe_many",
644 description: "Observe several files under one timestamp and one pool sweep.",
645 input_schema: schema(
646 &[(
647 "files",
648 json!({ "type": "array", "items": { "type": "object", "properties": { "path": { "type": "string" }, "content": { "type": "string" } }, "required": ["path", "content"] } }),
649 )],
650 &["files"],
651 true,
652 ),
653 },
654 ToolSpec {
655 name: "observe_delete",
656 description: "Record that `path` left the source: an observed, pooled tombstone.",
657 input_schema: schema(&[("path", s())], &["path"], true),
658 },
659 ToolSpec {
660 name: "history_node",
661 description: "A block's biography: the commits that touched it, newest first.",
662 input_schema: schema(&[("id", s()), ("limit", i())], &["id"], false),
663 },
664 ToolSpec {
665 name: "diff",
666 description: "Block-grain diff between two revisions of a document.",
667 input_schema: schema(
668 &[("doc", s()), ("from_rev", s()), ("to_rev", s())],
669 &["doc", "from_rev", "to_rev"],
670 true,
671 ),
672 },
673 ToolSpec {
674 name: "diff_unified",
675 description: "Unified diff (Myers, 3 lines of context) between two revisions (default: the previous and current).",
676 input_schema: schema(
677 &[("doc", s()), ("from_rev", s()), ("to_rev", s())],
678 &["doc"],
679 true,
680 ),
681 },
682 ToolSpec {
683 name: "docs_read_at",
684 description: "The whole document as of a past revision.",
685 input_schema: dp(&[("rev", s())], &["rev"]),
686 },
687 ToolSpec {
688 name: "docs_history",
689 description: "Version history of the docs matching `path_glob` or `doc`, grouped by document.",
690 input_schema: schema(
691 &[
692 ("path_glob", s()),
693 ("doc", s()),
694 ("include_deleted", b()),
695 ("limit", i()),
696 ],
697 &[],
698 true,
699 ),
700 },
701 ToolSpec {
702 name: "changes_since",
703 description: "The change feed: commit digests after `cursor` (a repo commit seq).",
704 input_schema: schema(
705 &[
706 ("cursor", i()),
707 (
708 "origin",
709 json!({ "type": "string", "enum": ["api", "observed", "import"] }),
710 ),
711 ("limit", i()),
712 ],
713 &[],
714 true,
715 ),
716 },
717 ToolSpec {
718 name: "repos_status",
719 description: "Repo counts, unconverged docs and on-disk drift.",
720 input_schema: schema(&[], &[], true),
721 },
722 ToolSpec {
723 name: "sync_status",
724 description: "Sync state: last commit seq, last checkpoint, convergence.",
725 input_schema: schema(&[], &[], true),
726 },
727 ToolSpec {
728 name: "repos",
729 description: "The repos in this workspace: `{ repos: [{ slug, hasSource }] }`.",
730 input_schema: schema(&[], &[], false),
731 },
732 ToolSpec {
733 name: "version",
734 description: "Which engine and which versions you are talking to: `{ engine: \"typescript\"|\"rust\", version, components: { <package>: <version> }, specs: { oqx, format, reconcile, store, properties, graph, search, mutate, sync, surface, cli }, schema, mcp: { protocol, sdk? }, runtime, commit, built }`. `version` is the serving binary's own; `components` every omgbase package it is built from; `specs` the spec versions it implements (compile-time constants); `schema` the database's `PRAGMA user_version`; `commit`/`built` the build's git revision and RFC 3339 time (null when unknown). No arguments, no repo scope.",
735 input_schema: schema(&[], &[], false),
736 },
737 ]
738}
739
740impl Surface {
741 #[must_use]
744 pub fn new(
745 store: Store,
746 default_repo: &str,
747 provider: Option<Box<dyn EmbeddingProvider>>,
748 ) -> Self {
749 let _ = store
760 .conn()
761 .execute_batch("PRAGMA cache_size = -16000; PRAGMA temp_store = MEMORY;");
762 Self {
763 store,
764 default_repo: default_repo.to_owned(),
765 provider,
766 on_mutation: None,
767 clock: Box::new(omgbase_sync::now_ts),
768 writes: WriteTarget::Derived,
769 config: Config::default(),
770 actor: ACTOR.to_owned(),
771 build_info: BuildInfo::default(),
772 }
773 }
774
775 #[must_use]
778 pub fn with_actor(mut self, actor: &str) -> Self {
779 self.set_actor(actor);
780 self
781 }
782
783 pub fn set_actor(&mut self, actor: &str) {
785 self.actor = actor.to_owned();
786 }
787
788 #[must_use]
791 pub fn with_doc_store(mut self, doc_store: Box<dyn DocStore>) -> Self {
792 self.writes = WriteTarget::Fixed(doc_store);
793 self
794 }
795
796 #[must_use]
798 pub fn with_clock(mut self, clock: impl FnMut() -> String + 'static) -> Self {
799 self.clock = Box::new(clock);
800 self
801 }
802
803 #[must_use]
805 pub fn with_mutation_hook(mut self, hook: impl FnMut() + 'static) -> Self {
806 self.on_mutation = Some(Box::new(hook));
807 self
808 }
809
810 #[must_use]
812 pub fn with_config(mut self, config: Config) -> Self {
813 self.config = config;
814 self
815 }
816
817 #[must_use]
821 pub fn with_build_info(mut self, build_info: BuildInfo) -> Self {
822 self.build_info = build_info;
823 self
824 }
825
826 #[must_use]
827 pub fn store(&self) -> &Store {
828 &self.store
829 }
830
831 pub fn store_mut(&mut self) -> &mut Store {
832 &mut self.store
833 }
834
835 #[must_use]
836 pub fn default_repo(&self) -> &str {
837 &self.default_repo
838 }
839
840 #[must_use]
842 pub fn tools(&self) -> Vec<ToolSpec> {
843 tools()
844 }
845
846 #[must_use]
850 pub fn is_write_tool(name: &str) -> bool {
851 matches!(
852 name,
853 "apply"
854 | "blocks_insert"
855 | "blocks_update"
856 | "blocks_move"
857 | "blocks_remove"
858 | "blocks_split"
859 | "blocks_merge"
860 | "tasks_complete"
861 | "node_set"
862 | "sections_append"
863 | "docs_append"
864 | "links_retarget"
865 | "links_repair"
866 | "docs_create"
867 | "docs_move"
868 | "docs_delete"
869 | "docs_set_meta"
870 | "docs_update"
871 | "observe"
872 | "observe_many"
873 | "observe_delete"
874 )
875 }
876
877 pub fn call(&mut self, name: &str, args: Json) -> ToolOutcome {
879 match self.call_result(name, &args) {
880 Ok(body) => ToolOutcome {
881 body,
882 is_error: false,
883 },
884 Err(e) => ToolOutcome {
885 body: e.to_json(),
886 is_error: true,
887 },
888 }
889 }
890
891 fn now(&mut self) -> String {
892 (self.clock)()
893 }
894
895 fn notify(&mut self) {
896 if let Some(hook) = &mut self.on_mutation {
897 hook();
898 }
899 }
900
901 fn repo_rows(&self) -> Result<Vec<RepoRow>> {
904 Ok(omgbase_sync::workspace::list_repos(&self.store)?)
905 }
906
907 fn scope(&self, args: &Json) -> Result<(String, Option<String>)> {
909 let rows = self.repo_rows()?;
910 match arg_str(args, "repo") {
911 None => {
912 let root = rows
913 .iter()
914 .find(|r| r.repo_id == self.default_repo)
915 .and_then(|r| r.root_path.clone());
916 Ok((self.default_repo.clone(), root))
917 }
918 Some(slug) => rows
919 .iter()
920 .find(|r| r.slug == slug)
921 .map(|r| (r.repo_id.clone(), r.root_path.clone()))
922 .ok_or_else(|| {
923 SurfaceError::with_data(
924 "repo_not_found",
925 format!("no repo '{slug}' in this workspace"),
926 json!({ "repo": slug }),
927 )
928 }),
929 }
930 }
931
932 fn require_root(&self, root: Option<&str>) -> Result<()> {
933 if matches!(self.writes, WriteTarget::Fixed(_)) || root.is_some() {
934 Ok(())
935 } else {
936 Err(SurfaceError::new(
937 "repo_not_found",
938 "repo has no filesystem source; mutation disabled",
939 ))
940 }
941 }
942
943 fn with_writes<T>(
945 &mut self,
946 root: Option<&str>,
947 f: impl FnOnce(&mut Store, &mut dyn DocStore) -> Result<T>,
948 ) -> Result<T> {
949 self.require_root(root)?;
950 match &mut self.writes {
951 WriteTarget::Fixed(ds) => f(&mut self.store, ds.as_mut()),
952 WriteTarget::Derived => {
953 let mut fs = FsDocStore::new(root.expect("checked by require_root"));
954 f(&mut self.store, &mut fs)
955 }
956 }
957 }
958
959 fn resolve_doc_id(
963 &self,
964 repo_id: &str,
965 doc: Option<&str>,
966 path: Option<&str>,
967 block: Option<&str>,
968 ) -> Result<String> {
969 let conn = self.store.conn();
970 let found = if let Some(d) = doc.filter(|d| !d.is_empty()) {
971 read::find_doc_by_ref(conn, repo_id, d)?.map(|i| i.doc_id)
972 } else if let Some(p) = path.filter(|p| !p.is_empty()) {
973 read::find_doc_by_path(conn, repo_id, p)?.map(|i| i.doc_id)
974 } else if let Some(b) = block.filter(|b| !b.is_empty()) {
975 conn.query_row(
976 "SELECT doc_id FROM blocks WHERE block_id = ?1",
977 params![b],
978 |r| r.get::<_, String>(0),
979 )
980 .optional()?
981 } else {
982 None
983 };
984 found.ok_or_else(|| {
985 let mut m = Map::new();
986 if let Some(d) = doc {
987 m.insert("doc".to_owned(), json!(d));
988 }
989 if let Some(p) = path {
990 m.insert("path".to_owned(), json!(p));
991 }
992 if let Some(b) = block {
993 m.insert("block".to_owned(), json!(b));
994 }
995 SurfaceError::with_data(
996 "doc_missing",
997 format!("no document for {}", Json::Object(m.clone())),
998 Json::Object(m),
999 )
1000 })
1001 }
1002
1003 fn resolve_doc_from_args(&self, repo_id: &str, args: &Json) -> Result<String> {
1004 self.resolve_doc_id(repo_id, arg_str(args, "doc"), arg_str(args, "path"), None)
1005 }
1006
1007 fn resolve_heading_id(
1009 &self,
1010 repo_id: &str,
1011 heading: &str,
1012 doc: Option<&str>,
1013 path: Option<&str>,
1014 ) -> Result<String> {
1015 let conn = self.store.conn();
1016 let as_block: Option<String> = conn
1017 .query_row(
1018 "SELECT block_id FROM blocks WHERE block_id = ?1 AND type = 'heading' AND deleted_commit IS NULL",
1019 params![heading],
1020 |r| r.get(0),
1021 )
1022 .optional()?;
1023 if let Some(b) = as_block {
1024 return Ok(b);
1025 }
1026 let want_doc = if doc.is_some_and(|d| !d.is_empty()) || path.is_some_and(|p| !p.is_empty())
1027 {
1028 Some(self.resolve_doc_id(repo_id, doc, path, None)?)
1029 } else {
1030 None
1031 };
1032 let needle = if heading.trim_start_matches([' ', '\t']).starts_with('#') {
1033 normalize_visible_text(heading, BlockKind::Heading, 0)
1034 } else {
1035 normalize_text(heading)
1036 };
1037 let rows: Vec<(String, String, String)> = match &want_doc {
1038 Some(d) => {
1039 let mut stmt = conn.prepare(
1040 "SELECT block_id, doc_id, text FROM blocks WHERE repo_id = ?1 AND type = 'heading' AND doc_id = ?2 AND deleted_commit IS NULL",
1041 )?;
1042 let it = stmt.query_map(params![repo_id, d], |r| {
1043 Ok((r.get(0)?, r.get(1)?, r.get(2)?))
1044 })?;
1045 it.collect::<std::result::Result<_, _>>()?
1046 }
1047 None => {
1048 let mut stmt = conn.prepare(
1049 "SELECT block_id, doc_id, text FROM blocks WHERE repo_id = ?1 AND type = 'heading' AND deleted_commit IS NULL",
1050 )?;
1051 let it =
1052 stmt.query_map(params![repo_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
1053 it.collect::<std::result::Result<_, _>>()?
1054 }
1055 };
1056 let matches: Vec<&(String, String, String)> = rows
1057 .iter()
1058 .filter(|(_, _, text)| normalize_text(text) == needle)
1059 .collect();
1060 match matches.len() {
1061 0 => {
1062 let mut data = Map::new();
1063 data.insert("heading".to_owned(), json!(heading));
1064 if let Some(d) = want_doc {
1065 data.insert("doc".to_owned(), json!(d));
1066 }
1067 Err(SurfaceError::with_data(
1068 "parent_missing",
1069 format!("no heading matching {}", Json::String(heading.to_owned())),
1070 Json::Object(data),
1071 ))
1072 }
1073 1 => Ok(matches[0].0.clone()),
1074 n => Err(SurfaceError::with_data(
1075 "ambiguous_heading",
1076 format!(
1077 "heading {} matches {n} headings; pass its block id or a doc/path scope",
1078 Json::String(heading.to_owned())
1079 ),
1080 json!({
1081 "heading": heading,
1082 "candidates": matches.iter().map(|(b, d, _)| json!({ "block": b, "doc": d })).collect::<Vec<_>>(),
1083 }),
1084 )),
1085 }
1086 }
1087
1088 fn resolve_block_ref(&self, repo_id: &str, r: &str) -> Result<String> {
1090 match read::resolve_ref(self.store.conn(), repo_id, r)? {
1091 Some(ResolvedRef::Block { block_id, .. }) => Ok(block_id),
1092 _ => Err(SurfaceError::with_data(
1093 "block_missing",
1094 format!("not a block: {r}"),
1095 json!({ "ref": r }),
1096 )),
1097 }
1098 }
1099
1100 fn resolve_parent_ref(&self, repo_id: &str, r: &str) -> Result<(Parent, String)> {
1102 match read::resolve_ref(self.store.conn(), repo_id, r)? {
1103 Some(ResolvedRef::Block { doc_id, block_id }) => Ok((Parent::Block(block_id), doc_id)),
1104 Some(ResolvedRef::Document { doc_id }) => Ok((Parent::Doc, doc_id)),
1105 None => Err(SurfaceError::with_data(
1106 "block_missing",
1107 format!("not a block or document: {r}"),
1108 json!({ "ref": r }),
1109 )),
1110 }
1111 }
1112
1113 fn doc_id_of_block(&self, block_id: &str) -> Result<Option<String>> {
1114 Ok(self
1115 .store
1116 .conn()
1117 .query_row(
1118 "SELECT doc_id FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
1119 params![block_id],
1120 |r| r.get(0),
1121 )
1122 .optional()?)
1123 }
1124
1125 fn pin_hash(&self, block_id: &str) -> Result<Option<String>> {
1127 let h: Option<Vec<u8>> = self
1128 .store
1129 .conn()
1130 .query_row(
1131 "SELECT raw_hash FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
1132 params![block_id],
1133 |r| r.get(0),
1134 )
1135 .optional()?;
1136 Ok(h.map(|h| omgbase_format::hash::hex(&h)))
1137 }
1138
1139 fn resolve_at(&self, repo_id: &str, at: Option<&Json>) -> Result<At> {
1141 match at {
1142 None | Some(Json::Null) => Ok(At::End),
1143 Some(Json::String(s)) if s == "start" => Ok(At::Start),
1144 Some(Json::String(s)) if s == "end" => Ok(At::End),
1145 Some(Json::Object(o)) => {
1146 if let Some(b) = o.get("before").and_then(Json::as_str) {
1147 return Ok(At::Before(self.resolve_block_ref(repo_id, b)?));
1148 }
1149 if let Some(a) = o.get("after").and_then(Json::as_str) {
1150 return Ok(At::After(self.resolve_block_ref(repo_id, a)?));
1151 }
1152 Err(bad_args(
1153 "`at` must be \"start\", \"end\", {before} or {after}",
1154 ))
1155 }
1156 Some(_) => Err(bad_args(
1157 "`at` must be \"start\", \"end\", {before} or {after}",
1158 )),
1159 }
1160 }
1161
1162 fn apply_ops(
1165 &mut self,
1166 repo_id: &str,
1167 root: Option<&str>,
1168 ops: Vec<Op>,
1169 reason: &str,
1170 dry_run: bool,
1171 ) -> Result<ApplyResult> {
1172 let ts = self.now();
1173 let req = ApplyRequest {
1174 repo_id: repo_id.to_owned(),
1175 ops,
1176 origin: ApplyOrigin::new(&self.actor, Some(reason)),
1177 dry_run,
1178 set_frontmatter: Vec::new(),
1179 };
1180 let res = self.with_writes(root, |store, ds| Ok(store.apply(&req, ds, &ts)?))?;
1181 if !dry_run {
1182 self.notify();
1183 }
1184 Ok(res)
1185 }
1186
1187 fn doc_ctx(&mut self, repo_id: &str) -> DocOpContext {
1188 DocOpContext {
1189 repo_id: repo_id.to_owned(),
1190 actor: Some(self.actor.clone()),
1191 ts: self.now(),
1192 }
1193 }
1194
1195 #[allow(clippy::too_many_lines)]
1199 pub fn call_result(&mut self, name: &str, args: &Json) -> Result<Json> {
1200 match name {
1201 "docs_outline" => {
1202 let (repo, _) = self.scope(args)?;
1203 let doc_id = self.resolve_doc_from_args(&repo, args)?;
1204 let skeleton = match arg_str(args, "resolution") {
1205 None | Some("outline") => false,
1206 Some("skeleton") => true,
1207 Some(_) => return Err(bad_args("`resolution` must be skeleton or outline")),
1208 };
1209 read::docs_outline(
1210 &self.store,
1211 &doc_id,
1212 skeleton,
1213 arg_i64(args, "depth")?,
1214 arg_usize(args, "budget_tokens")?,
1215 )
1216 }
1217 "docs_read" => {
1218 let (repo, _) = self.scope(args)?;
1219 let doc_id = self.resolve_doc_from_args(&repo, args)?;
1220 read::docs_read(
1221 &self.store,
1222 &doc_id,
1223 arg_bool(args, "include_ids")?.unwrap_or(false),
1224 )?
1225 .ok_or_else(|| SurfaceError::new("doc_missing", format!("no document for {args}")))
1226 }
1227 "docs_get_many" => {
1228 let (repo, _) = self.scope(args)?;
1229 let refs = arg_strings(args, "docs")?;
1230 read::docs_read_many(
1231 &self.store,
1232 &repo,
1233 &refs,
1234 arg_bool(args, "include_ids")?.unwrap_or(false),
1235 arg_usize(args, "budget_tokens")?,
1236 )
1237 }
1238 "nodes_get" => {
1239 let (repo, _) = self.scope(args)?;
1240 let id = arg_string(args, "id")?;
1241 let doc_id = self.resolve_doc_id(
1242 &repo,
1243 arg_str(args, "doc"),
1244 arg_str(args, "path"),
1245 Some(&id),
1246 )?;
1247 read::nodes_get(
1248 &self.store,
1249 &doc_id,
1250 &id,
1251 resolution_arg(args, Resolution::Full)?,
1252 )?
1253 .ok_or_else(|| SurfaceError::new("block_missing", format!("no block {id}")))
1254 }
1255 "nodes_get_many" => {
1256 let (repo, _) = self.scope(args)?;
1257 let ids = arg_strings(args, "ids")?;
1258 let scoped = arg_str(args, "doc").is_some_and(|d| !d.is_empty())
1259 || arg_str(args, "path").is_some_and(|p| !p.is_empty());
1260 let doc_id = if scoped {
1261 Some(self.resolve_doc_from_args(&repo, args)?)
1262 } else {
1263 None
1264 };
1265 read::nodes_get_many(
1266 &self.store,
1267 doc_id.as_deref(),
1268 &ids,
1269 resolution_arg(args, Resolution::Text)?,
1270 arg_usize(args, "budget_tokens")?,
1271 )
1272 }
1273 "read_ref" => {
1274 let (repo, _) = self.scope(args)?;
1275 let r = arg_string(args, "ref")?;
1276 let resolved =
1277 read::resolve_ref(self.store.conn(), &repo, &r)?.ok_or_else(|| {
1278 SurfaceError::new(
1279 "doc_missing",
1280 format!("no document or block for {}", Json::String(r.clone())),
1281 )
1282 })?;
1283 match resolved {
1284 ResolvedRef::Document { doc_id } => {
1285 let res =
1286 read::docs_read(&self.store, &doc_id, false)?.ok_or_else(|| {
1287 SurfaceError::new(
1288 "doc_missing",
1289 format!("no document for {}", Json::String(r.clone())),
1290 )
1291 })?;
1292 let mut m = Map::new();
1293 m.insert("kind".to_owned(), json!("document"));
1294 Ok(merge(m, res))
1295 }
1296 ResolvedRef::Block { doc_id, block_id } => {
1297 let node = read::nodes_get(
1298 &self.store,
1299 &doc_id,
1300 &block_id,
1301 resolution_arg(args, Resolution::Raw)?,
1302 )?
1303 .ok_or_else(|| {
1304 SurfaceError::new("block_missing", format!("no block {block_id}"))
1305 })?;
1306 let mut m = Map::new();
1307 m.insert("kind".to_owned(), json!("block"));
1308 Ok(merge(m, node))
1309 }
1310 }
1311 }
1312 "docs_tree" => {
1313 let (repo, _) = self.scope(args)?;
1314 read::docs_tree(
1315 &self.store,
1316 &repo,
1317 arg_str(args, "path"),
1318 arg_i64(args, "depth")?,
1319 arg_i64(args, "limit")?,
1320 arg_str(args, "cursor"),
1321 arg_usize(args, "budget_tokens")?,
1322 )
1323 }
1324 "docs_list" => {
1325 let (repo, _) = self.scope(args)?;
1326 read::docs_list(
1327 &self.store,
1328 &repo,
1329 arg_str(args, "path_glob"),
1330 arg_i64(args, "limit")?,
1331 arg_str(args, "cursor"),
1332 arg_usize(args, "budget_tokens")?,
1333 )
1334 }
1335 "query_syntax" => Ok(json!({ "syntax": QUERY_SYNTAX })),
1336 "query" => {
1337 let (repo, _) = self.scope(args)?;
1338 let source = arg_string(args, "query")?;
1339 if self.provider.is_none()
1342 && !crate::query::collect_semantic_phrases(&source).is_empty()
1343 {
1344 return Err(SurfaceError::new(
1345 "semantic_unavailable",
1346 "no embedding provider configured for this server",
1347 ));
1348 }
1349 let opts = QueryOptions {
1350 limit: arg_usize(args, "limit")?,
1351 cursor: arg_str(args, "cursor"),
1352 provider: self.provider.as_deref(),
1353 in_memory: false,
1354 };
1355 Ok(query(&self.store, &repo, &source, opts)?.to_json())
1356 }
1357 "graph" => {
1358 let (repo, _) = self.scope(args)?;
1359 let g = GraphArgs {
1360 roots: arg_strings(args, "roots")?,
1361 degrees: arg_i64(args, "degrees")?,
1362 direction: arg_str(args, "direction").map(str::to_owned),
1363 predicate: arg_str(args, "predicate").map(str::to_owned),
1364 select: if args.get("select").is_some() {
1365 arg_strings(args, "select")?
1366 } else {
1367 Vec::new()
1368 },
1369 max_documents: arg_i64(args, "max_documents")?,
1370 };
1371 if self.provider.is_none()
1372 && g.select.iter().any(|s| {
1373 !crate::query::collect_semantic_phrases(&format!("from docs select x: {s}"))
1374 .is_empty()
1375 })
1376 {
1377 return Err(SurfaceError::new(
1378 "semantic_unavailable",
1379 "no embedding provider configured for this server",
1380 ));
1381 }
1382 graph_neighborhood(&self.store, &repo, &g, self.provider.as_deref())
1383 }
1384 "text_search" => {
1385 let (repo, _) = self.scope(args)?;
1386 let q = arg_string(args, "q")?;
1387 let res =
1388 self.store
1389 .text_search(&repo, &q, arg_usize(args, "limit")?.unwrap_or(50))?;
1390 Ok(json!({
1391 "hits": res.hits.iter().map(|h| json!({
1392 "blockId": h.block_id, "docId": h.doc_id, "path": reference_path(&h.path), "type": h.block_type, "text": h.text, "score": h.score,
1393 })).collect::<Vec<_>>(),
1394 "truncated": res.truncated,
1395 }))
1396 }
1397 "resolve" => {
1398 let (repo, _) = self.scope(args)?;
1399 let q = arg_string(args, "query")?;
1400 let vector = match &self.provider {
1401 Some(p) => Some(QueryVector {
1402 model: p.model().to_owned(),
1403 vec: p
1404 .embed_query(&q)
1405 .map_err(|e| SurfaceError::new(e.code(), e.to_string()))?,
1406 }),
1407 None => None,
1408 };
1409 let hits = self
1410 .store
1411 .resolve(&repo, &q, vector, arg_usize(args, "limit")?)?;
1412 let locator = |l: &str| match l.find('#') {
1415 Some(i) => format!("{}{}", reference_path(&l[..i]), &l[i..]),
1416 None => l.to_owned(),
1417 };
1418 Ok(Json::Array(hits.iter().map(|h| json!({
1419 "id": h.id, "locator": locator(&h.locator), "preview": h.preview, "evidence": evidence_json(&h.evidence),
1420 })).collect()))
1421 }
1422 "apply" => {
1423 let (repo, root) = self.scope(args)?;
1424 self.require_root(root.as_deref())?;
1425 let ops = args
1426 .get("ops")
1427 .and_then(Json::as_array)
1428 .ok_or_else(|| bad_args("`ops` must be an array"))?;
1429 let ops: Vec<Op> = ops
1430 .iter()
1431 .map(|o| Op::from_json(o).map_err(bad_args))
1432 .collect::<Result<_>>()?;
1433 let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1434 let reason = arg_str(args, "reason").map(str::to_owned);
1435 let ts = self.now();
1436 let req = ApplyRequest {
1437 repo_id: repo.clone(),
1438 ops,
1439 origin: ApplyOrigin::new(&self.actor, reason.as_deref()),
1440 dry_run: dry,
1441 set_frontmatter: Vec::new(),
1442 };
1443 let res =
1444 self.with_writes(root.as_deref(), |store, ds| Ok(store.apply(&req, ds, &ts)?))?;
1445 if !dry {
1446 self.notify();
1447 }
1448 Ok(apply_json(&res))
1449 }
1450 "blocks_insert" => {
1451 let (repo, root) = self.scope(args)?;
1452 self.require_root(root.as_deref())?;
1453 let (parent, doc_id) = self.resolve_parent_ref(&repo, &arg_string(args, "to")?)?;
1454 let at = self.resolve_at(&repo, args.get("at"))?;
1455 let doc = if parent == Parent::Doc {
1456 Some(doc_id)
1457 } else {
1458 None
1459 };
1460 let ops = vec![Op::Insert {
1461 doc,
1462 to: To { parent, at },
1463 markdown: arg_string(args, "markdown")?,
1464 expect: arg_parent_expect(args)?,
1465 }];
1466 Ok(apply_json(&self.apply_ops(
1467 &repo,
1468 root.as_deref(),
1469 ops,
1470 "blocks_insert",
1471 arg_bool(args, "dry_run")?.unwrap_or(false),
1472 )?))
1473 }
1474 "blocks_update" => {
1475 let (repo, root) = self.scope(args)?;
1476 self.require_root(root.as_deref())?;
1477 let block = self.resolve_block_ref(&repo, &arg_string(args, "block")?)?;
1478 let expect = match arg_expect(args)? {
1479 Some(e) => Some(e),
1480 None => self.pin_hash(&block)?.map(Expect::content),
1481 };
1482 let checked = arg_bool(args, "checked")?;
1483 let extra = arg_object(args, "attrs")?;
1484 let attrs = if checked.is_some() || extra.is_some() {
1485 let mut a = Map::new();
1486 if let Some(c) = checked {
1487 a.insert("checked".to_owned(), json!(c));
1488 }
1489 if let Some(x) = extra {
1490 for (k, v) in x {
1491 a.insert(k.clone(), v.clone());
1492 }
1493 }
1494 Some(a)
1495 } else {
1496 None
1497 };
1498 let ops = vec![Op::Update {
1499 block: block.clone(),
1500 markdown: arg_str(args, "markdown").map(str::to_owned),
1501 attrs,
1502 expect,
1503 trivia: None,
1504 child_ids: None,
1505 }];
1506 let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1507 let res = self.apply_ops(&repo, root.as_deref(), ops, "blocks_update", dry)?;
1508 let ids: Vec<String> = res
1509 .results
1510 .first()
1511 .map_or_else(|| vec![block.clone()], |r| r.ids.clone());
1512 let mut m = Map::new();
1513 m.insert(
1514 "id".to_owned(),
1515 json!(ids.first().cloned().unwrap_or(block)),
1516 );
1517 m.insert("ids".to_owned(), json!(ids));
1518 Ok(merge(m, apply_json(&res)))
1519 }
1520 "blocks_move" => {
1521 let (repo, root) = self.scope(args)?;
1522 self.require_root(root.as_deref())?;
1523 let blocks: Vec<String> = arg_strings(args, "blocks")?
1524 .iter()
1525 .map(|b| self.resolve_block_ref(&repo, b))
1526 .collect::<Result<_>>()?;
1527 let to_ref = arg_string(args, "to")?;
1528 let (parent, doc_id) = self.resolve_parent_ref(&repo, &to_ref)?;
1529 if parent == Parent::Doc {
1530 if let Some(first) = blocks.first() {
1531 if self.doc_id_of_block(first)? != Some(doc_id) {
1532 return Err(SurfaceError::with_data(
1533 "target_missing",
1534 format!(
1535 "blocks_move cannot target another document's root ({to_ref}); anchor on a block in that document with at.before/at.after"
1536 ),
1537 json!({ "to": to_ref }),
1538 ));
1539 }
1540 }
1541 }
1542 let at = self.resolve_at(&repo, args.get("at"))?;
1543 let ops = vec![Op::Move {
1544 blocks,
1545 to: To { parent, at },
1546 expect: arg_parent_expect(args)?,
1547 }];
1548 Ok(apply_json(&self.apply_ops(
1549 &repo,
1550 root.as_deref(),
1551 ops,
1552 "blocks_move",
1553 arg_bool(args, "dry_run")?.unwrap_or(false),
1554 )?))
1555 }
1556 "blocks_remove" => {
1557 let (repo, root) = self.scope(args)?;
1558 self.require_root(root.as_deref())?;
1559 let blocks: Vec<String> = arg_strings(args, "blocks")?
1560 .iter()
1561 .map(|b| self.resolve_block_ref(&repo, b))
1562 .collect::<Result<_>>()?;
1563 let ops = vec![Op::Remove {
1564 blocks,
1565 expect: None,
1566 }];
1567 Ok(apply_json(&self.apply_ops(
1568 &repo,
1569 root.as_deref(),
1570 ops,
1571 "blocks_remove",
1572 arg_bool(args, "dry_run")?.unwrap_or(false),
1573 )?))
1574 }
1575 "blocks_split" => {
1576 let (repo, root) = self.scope(args)?;
1577 self.require_root(root.as_deref())?;
1578 let block = self.resolve_block_ref(&repo, &arg_string(args, "block")?)?;
1579 let at: Vec<usize> = args
1580 .get("at")
1581 .and_then(Json::as_array)
1582 .ok_or_else(|| bad_args("`at` must be an array of byte offsets"))?
1583 .iter()
1584 .map(|v| {
1585 v.as_u64()
1586 .map(|n| usize::try_from(n).unwrap_or(usize::MAX))
1587 .ok_or_else(|| bad_args("`at` must be an array of byte offsets"))
1588 })
1589 .collect::<Result<_>>()?;
1590 let expect = Some(Expect::content(self.pin_hash(&block)?.unwrap_or_default()));
1592 let ops = vec![Op::Split { block, at, expect }];
1593 Ok(apply_json(&self.apply_ops(
1594 &repo,
1595 root.as_deref(),
1596 ops,
1597 "blocks_split",
1598 arg_bool(args, "dry_run")?.unwrap_or(false),
1599 )?))
1600 }
1601 "blocks_merge" => {
1602 let (repo, root) = self.scope(args)?;
1603 self.require_root(root.as_deref())?;
1604 let blocks: Vec<String> = arg_strings(args, "blocks")?
1605 .iter()
1606 .map(|b| self.resolve_block_ref(&repo, b))
1607 .collect::<Result<_>>()?;
1608 let ops = vec![Op::Merge {
1609 blocks,
1610 separator: arg_str(args, "separator").map(str::to_owned),
1611 expect: None,
1612 }];
1613 Ok(apply_json(&self.apply_ops(
1614 &repo,
1615 root.as_deref(),
1616 ops,
1617 "blocks_merge",
1618 arg_bool(args, "dry_run")?.unwrap_or(false),
1619 )?))
1620 }
1621 "tasks_complete" => {
1622 let (repo, root) = self.scope(args)?;
1623 self.require_root(root.as_deref())?;
1624 let blocks: Vec<String> = arg_strings(args, "blocks")?
1625 .iter()
1626 .map(|b| self.resolve_block_ref(&repo, b))
1627 .collect::<Result<_>>()?;
1628 let ops = if arg_bool(args, "checked")?.unwrap_or(true) {
1629 self.store.tasks_complete(&blocks)?
1630 } else {
1631 let mut ops = Vec::with_capacity(blocks.len());
1632 for b in &blocks {
1633 let mut a = Map::new();
1634 a.insert("checked".to_owned(), json!(false));
1635 ops.push(Op::Update {
1636 block: b.clone(),
1637 markdown: None,
1638 attrs: Some(a),
1639 expect: self.pin_hash(b)?.map(Expect::content),
1640 trivia: None,
1641 child_ids: None,
1642 });
1643 }
1644 ops
1645 };
1646 Ok(apply_json(&self.apply_ops(
1647 &repo,
1648 root.as_deref(),
1649 ops,
1650 "tasks_complete",
1651 arg_bool(args, "dry_run")?.unwrap_or(false),
1652 )?))
1653 }
1654 "node_set" => {
1655 let (repo, root) = self.scope(args)?;
1656 self.require_root(root.as_deref())?;
1657 let ops = self.store.node_set(
1658 &arg_string(args, "node")?,
1659 &arg_string(args, "prop")?,
1660 &arg_string(args, "value")?,
1661 )?;
1662 Ok(apply_json(&self.apply_ops(
1663 &repo,
1664 root.as_deref(),
1665 ops,
1666 "node_set",
1667 arg_bool(args, "dry_run")?.unwrap_or(false),
1668 )?))
1669 }
1670 "sections_append" => {
1671 let (repo, root) = self.scope(args)?;
1672 self.require_root(root.as_deref())?;
1673 let heading = self.resolve_heading_id(
1674 &repo,
1675 &arg_string(args, "heading")?,
1676 arg_str(args, "doc"),
1677 arg_str(args, "path"),
1678 )?;
1679 let ops = Store::sections_append(&heading, &arg_string(args, "markdown")?);
1680 Ok(apply_json(&self.apply_ops(
1681 &repo,
1682 root.as_deref(),
1683 ops,
1684 "sections_append",
1685 arg_bool(args, "dry_run")?.unwrap_or(false),
1686 )?))
1687 }
1688 "docs_append" => {
1689 let (repo, root) = self.scope(args)?;
1690 self.require_root(root.as_deref())?;
1691 let doc_id = self.resolve_doc_from_args(&repo, args)?;
1692 let ops = Store::docs_append(&doc_id, &arg_string(args, "text")?);
1693 Ok(apply_json(&self.apply_ops(
1694 &repo,
1695 root.as_deref(),
1696 ops,
1697 "docs_append",
1698 false,
1699 )?))
1700 }
1701 "links_retarget" | "links_repair" => {
1702 let (repo, root) = self.scope(args)?;
1703 self.require_root(root.as_deref())?;
1704 let repairs: Vec<omgbase_store::LinkRepair> = if name == "links_retarget" {
1705 vec![omgbase_store::LinkRepair {
1706 from: arg_string(args, "from_target")?,
1707 to: arg_string(args, "to_target")?,
1708 }]
1709 } else if let Some(list) = args.get("repairs").and_then(Json::as_array) {
1710 list.iter()
1711 .map(|r| {
1712 Ok(omgbase_store::LinkRepair {
1713 from: arg_string(r, "from")?,
1714 to: arg_string(r, "to")?,
1715 })
1716 })
1717 .collect::<Result<_>>()?
1718 } else if let (Some(f), Some(t)) =
1719 (arg_str(args, "from_target"), arg_str(args, "to_target"))
1720 {
1721 vec![omgbase_store::LinkRepair {
1722 from: f.to_owned(),
1723 to: t.to_owned(),
1724 }]
1725 } else {
1726 Vec::new()
1727 };
1728 if repairs.is_empty() {
1729 return Err(SurfaceError::new(
1730 "target_missing",
1731 "links_repair requires `repairs` (array of {from,to}) or a `from_target`+`to_target` pair",
1732 ));
1733 }
1734 let plan = self.store.links_repair(
1735 &repo,
1736 &repairs,
1737 arg_str(args, "path_glob").filter(|g| !g.is_empty()),
1738 )?;
1739 let dry = arg_bool(args, "dry_run")?.unwrap_or(true);
1740 let res = self.apply_ops(&repo, root.as_deref(), plan.ops.clone(), name, dry)?;
1741 let mut m = Map::new();
1742 m.insert(
1743 "hits".to_owned(),
1744 Json::Array(plan.hits.iter().map(|h| json!({ "block": h.block, "path": reference_path(&h.path), "oldRaw": h.old_raw, "newRaw": h.new_raw })).collect()),
1745 );
1746 m.insert(
1747 "pairs".to_owned(),
1748 Json::Array(
1749 plan.pairs
1750 .iter()
1751 .map(|p| json!({ "from": p.from, "to": p.to, "hits": p.hits }))
1752 .collect(),
1753 ),
1754 );
1755 m.insert("applied".to_owned(), json!(!dry));
1756 Ok(merge(m, apply_json(&res)))
1757 }
1758 "links_stale" => {
1759 let (repo, _) = self.scope(args)?;
1760 let glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
1761 if arg_bool(args, "summary")?.unwrap_or(false) {
1762 return links::links_stale_summary(&self.store, &repo, glob);
1763 }
1764 links::links_stale(&self.store, &repo, glob, arg_i64(args, "limit")?)
1765 }
1766 "docs_create" => {
1767 let (repo, root) = self.scope(args)?;
1768 let ctx = self.doc_ctx(&repo);
1769 let path = arg_string(args, "path")?;
1770 let markdown = arg_string(args, "markdown")?;
1771 let fm = arg_object(args, "frontmatter")?.cloned();
1772 let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1773 let res = self.with_writes(root.as_deref(), |store, ds| {
1774 Ok(if dry {
1775 store
1776 .dry_run()
1777 .docs_create(&ctx, ds, &path, &markdown, fm.as_ref())?
1778 } else {
1779 store.docs_create(&ctx, ds, &path, &markdown, fm.as_ref())?
1780 })
1781 })?;
1782 if !dry {
1783 self.notify();
1784 }
1785 Ok(doc_op_json(&res))
1786 }
1787 "docs_move" => {
1788 let (repo, root) = self.scope(args)?;
1789 let ctx = self.doc_ctx(&repo);
1790 let doc = arg_string(args, "doc")?;
1791 let to = arg_string(args, "to_path")?;
1792 let retarget = arg_bool(args, "retarget_inbound")?.unwrap_or(true);
1794 let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1795 let res = self.with_writes(root.as_deref(), |store, ds| {
1796 Ok(if dry {
1797 store.dry_run().docs_move(&ctx, ds, &doc, &to, retarget)?
1798 } else {
1799 store.docs_move(&ctx, ds, &doc, &to, retarget)?
1800 })
1801 })?;
1802 if !dry {
1803 self.notify();
1804 }
1805 let mut m = Map::new();
1808 m.insert("docId".to_owned(), json!(res.doc_id));
1809 m.insert("path".to_owned(), json!(reference_path(&res.path)));
1810 m.insert("committed".to_owned(), json!(res.committed));
1811 if let Some(diffs) = &res.diffs {
1812 m.insert("diffs".to_owned(), reference_keyed(&diffs_json(diffs)));
1813 }
1814 m.insert(
1817 "dangling".to_owned(),
1818 Json::Array(
1819 res.dangling
1820 .iter()
1821 .map(|l| {
1822 let mut j = l.to_json();
1823 root_field(&mut j, "path");
1824 j
1825 })
1826 .collect(),
1827 ),
1828 );
1829 m.insert(
1830 "retargeted".to_owned(),
1831 json!(
1832 res.retargeted
1833 .as_ref()
1834 .map(|r| json!({ "blocks": r.blocks, "docs": r.docs }))
1835 ),
1836 );
1837 Ok(Json::Object(m))
1838 }
1839 "docs_delete" => {
1840 let (repo, root) = self.scope(args)?;
1841 let ctx = self.doc_ctx(&repo);
1842 let doc = arg_string(args, "doc")?;
1843 let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1844 let res = self.with_writes(root.as_deref(), |store, ds| {
1845 Ok(if dry {
1846 store.dry_run().docs_delete(&ctx, ds, &doc)?
1847 } else {
1848 store.docs_delete(&ctx, ds, &doc)?
1849 })
1850 })?;
1851 if !dry {
1852 self.notify();
1853 }
1854 Ok(doc_op_json(&res))
1855 }
1856 "docs_set_meta" => {
1857 let (repo, root) = self.scope(args)?;
1858 let ctx = self.doc_ctx(&repo);
1859 let doc = arg_string(args, "doc")?;
1860 let set = arg_object(args, "set")?.cloned();
1861 let unset = if args.get("unset").is_some() {
1862 arg_strings(args, "unset")?
1863 } else {
1864 Vec::new()
1865 };
1866 let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1867 let res = self.with_writes(root.as_deref(), |store, ds| {
1868 Ok(if dry {
1869 store
1870 .dry_run()
1871 .docs_set_meta(&ctx, ds, &doc, set.as_ref(), &unset)?
1872 } else {
1873 store.docs_set_meta(&ctx, ds, &doc, set.as_ref(), &unset)?
1874 })
1875 })?;
1876 if !dry {
1877 self.notify();
1878 }
1879 Ok(doc_op_json(&res))
1880 }
1881 "docs_plan_update" => {
1882 let (repo, root) = self.scope(args)?;
1883 self.require_root(root.as_deref())?;
1884 let doc = arg_string(args, "doc")?;
1885 let content = arg_string(args, "content")?;
1886 let opset = self
1887 .store
1888 .plan_update(&repo, &doc, &content, &self.config.clone())?;
1889 Ok(json!({ "opset": opset_json(&opset), "plan": render_opset_plan(&opset) }))
1890 }
1891 "docs_update" => {
1892 let (repo, root) = self.scope(args)?;
1893 let doc = arg_string(args, "doc")?;
1894 let content = arg_string(args, "content")?;
1895 let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1896 let reason = arg_str(args, "reason").map(str::to_owned);
1897 let origin = ApplyOrigin::new(&self.actor, reason.as_deref());
1898 let config = self.config.clone();
1899 let ts = self.now();
1900 let (opset, result) = self.with_writes(root.as_deref(), |store, ds| {
1901 Ok(store.docs_update(&repo, &doc, &content, &config, &origin, dry, ds, &ts)?)
1902 })?;
1903 if !dry {
1904 self.notify();
1905 }
1906 Ok(json!({
1907 "opset": opset_json(&opset),
1908 "plan": render_opset_plan(&opset),
1909 "result": result.map(|r| apply_json(&r)),
1910 }))
1911 }
1912 "observe" => {
1913 let (repo, _) = self.scope(args)?;
1914 let path = storage_path(&arg_string(args, "path")?).to_owned();
1915 let content = arg_string(args, "content")?;
1916 let ts = self.now();
1917 let config = self.config.clone();
1918 let out = self
1919 .store
1920 .observe_one(&repo, &path, &content, &ts, &config)?;
1921 self.store.sweep_pool(&ts)?;
1922 self.notify();
1923 Ok(observe_json(&out))
1924 }
1925 "observe_many" => {
1926 let (repo, _) = self.scope(args)?;
1927 let files = args
1928 .get("files")
1929 .and_then(Json::as_array)
1930 .ok_or_else(|| bad_args("`files` must be an array of {path, content}"))?;
1931 let items: Vec<omgbase_store::BatchItem> = files
1932 .iter()
1933 .map(|f| {
1934 Ok(omgbase_store::BatchItem::observed(
1935 storage_path(&arg_string(f, "path")?),
1936 &arg_string(f, "content")?,
1937 ))
1938 })
1939 .collect::<Result<_>>()?;
1940 let ts = self.now();
1941 let config = self.config.clone();
1942 let outcomes = self.store.observe_batch(&repo, &items, &ts, &config)?;
1943 self.store.sweep_pool(&ts)?;
1944 self.notify();
1945 let mut out = Vec::with_capacity(outcomes.len());
1946 for o in &outcomes {
1947 match o.as_observed() {
1948 Some(obs) => out.push(observe_json(obs)),
1949 None => {
1950 return Err(SurfaceError::other(format!(
1951 "observe_many: unexpected outcome for {}",
1952 o.path()
1953 )));
1954 }
1955 }
1956 }
1957 Ok(Json::Array(out))
1958 }
1959 "observe_delete" => {
1960 let (repo, _) = self.scope(args)?;
1961 let path = storage_path(&arg_string(args, "path")?).to_owned();
1962 let ts = self.now();
1963 let out = self.store.observe_delete(&repo, &path, &ts)?;
1964 self.notify();
1965 Ok(
1966 json!({ "docId": out.doc_id, "path": reference_path(&out.path), "deleted": out.deleted() }),
1967 )
1968 }
1969 "history_node" => history::history_node(
1970 &self.store,
1971 &arg_string(args, "id")?,
1972 arg_i64(args, "limit")?,
1973 ),
1974 "diff" => {
1975 let (repo, _) = self.scope(args)?;
1976 let doc_id = self.resolve_doc_id(&repo, arg_str(args, "doc"), None, None)?;
1977 history::diff_blocks(
1978 &self.store,
1979 &doc_id,
1980 &arg_string(args, "from_rev")?,
1981 &arg_string(args, "to_rev")?,
1982 )
1983 }
1984 "diff_unified" => {
1985 let (repo, _) = self.scope(args)?;
1986 let doc_ref = arg_string(args, "doc")?;
1987 let doc_id = self.resolve_doc_id(&repo, Some(&doc_ref), None, None)?;
1988 let revs = history::recent_revs(self.store.conn(), &doc_id)?;
1989 let to_rev = arg_str(args, "to_rev")
1990 .map(str::to_owned)
1991 .or_else(|| revs.first().cloned());
1992 let from_rev = arg_str(args, "from_rev")
1993 .map(str::to_owned)
1994 .or_else(|| revs.get(1).cloned())
1995 .or_else(|| revs.first().cloned());
1996 let (Some(from), Some(to)) = (from_rev, to_rev) else {
1997 return Err(SurfaceError::new(
1998 "target_missing",
1999 format!("no revisions to diff for {}", Json::String(doc_ref)),
2000 ));
2001 };
2002 let path: Option<String> = self
2003 .store
2004 .conn()
2005 .query_row(
2006 "SELECT path FROM docs WHERE doc_id = ?1",
2007 params![doc_id],
2008 |r| r.get(0),
2009 )
2010 .optional()?;
2011 let diff = history::diff_unified_text(&self.store, &doc_id, &from, &to)?;
2012 Ok(
2013 json!({ "doc": doc_id, "path": path.as_deref().map_or_else(String::new, reference_path), "from": from, "to": to, "diff": diff }),
2014 )
2015 }
2016 "docs_read_at" => {
2017 let (repo, _) = self.scope(args)?;
2018 let doc_id = self.resolve_doc_from_args(&repo, args)?;
2019 let rev = arg_string(args, "rev")?;
2020 read::docs_read_at(&self.store, &doc_id, &rev)?.ok_or_else(|| {
2021 SurfaceError::with_data(
2022 "target_missing",
2023 format!(
2024 "no revision {} for document {doc_id}",
2025 Json::String(rev.clone())
2026 ),
2027 json!({ "doc": doc_id, "rev": rev }),
2028 )
2029 })
2030 }
2031 "docs_history" => {
2032 let (repo, _) = self.scope(args)?;
2033 let glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
2034 let doc = arg_str(args, "doc").filter(|d| !d.is_empty());
2035 if glob.is_none() && doc.is_none() {
2036 return Err(SurfaceError::new(
2037 "target_missing",
2038 "docs_history requires one of path_glob or doc",
2039 ));
2040 }
2041 let include_deleted = arg_bool(args, "include_deleted")?.unwrap_or(false);
2042 if let Some(d) = doc {
2043 if history::resolve_doc_row(self.store.conn(), &repo, d, include_deleted)?
2044 .is_none()
2045 {
2046 return Err(SurfaceError::new(
2047 "doc_missing",
2048 format!("no document for {}", Json::String(d.to_owned())),
2049 ));
2050 }
2051 }
2052 history::docs_history(
2053 &self.store,
2054 &repo,
2055 glob,
2056 doc,
2057 include_deleted,
2058 arg_i64(args, "limit")?,
2059 )
2060 }
2061 "changes_since" => {
2062 let (repo, _) = self.scope(args)?;
2063 let page = self.store.changes_since(
2064 &repo,
2065 arg_i64(args, "cursor")?.unwrap_or(0),
2066 arg_usize(args, "limit")?.unwrap_or(50),
2067 arg_str(args, "origin").filter(|o| !o.is_empty()),
2068 )?;
2069 Ok(root_changes_page(page.to_json()))
2070 }
2071 "repos_status" => {
2072 let (repo, root) = self.scope(args)?;
2073 let fs = RealFileSystem;
2074 let disk = root
2075 .as_deref()
2076 .map(|r| (&fs as &dyn omgbase_sync::FileSystem, Path::new(r)));
2077 Ok(omgbase_sync::repos_status(&self.store, &repo, disk)?.to_json())
2078 }
2079 "sync_status" => {
2080 let (repo, root) = self.scope(args)?;
2081 let fs = RealFileSystem;
2082 let disk = root
2083 .as_deref()
2084 .map(|r| (&fs as &dyn omgbase_sync::FileSystem, Path::new(r)));
2085 Ok(omgbase_sync::sync_status(&self.store, &repo, disk)?.to_json())
2086 }
2087 "repos" => {
2088 let rows = self.repo_rows()?;
2089 Ok(json!({
2090 "repos": rows.iter().map(|r| json!({ "slug": r.slug, "hasSource": r.root_path.is_some() })).collect::<Vec<_>>(),
2091 }))
2092 }
2093 "version" => version_info(Some(&self.store), &self.build_info),
2094 other => Err(SurfaceError::other(format!("unknown tool {other}"))),
2095 }
2096 }
2097}
2098
2099#[must_use]
2103pub fn apply_json(res: &ApplyResult) -> Json {
2104 let mut m = Map::new();
2105 m.insert(
2106 "results".to_owned(),
2107 Json::Array(
2108 res.results
2109 .iter()
2110 .map(|r| {
2111 let mut o = Map::new();
2112 o.insert("ids".to_owned(), json!(r.ids));
2113 if let Some(rm) = &r.removed {
2114 o.insert("removed".to_owned(), json!(rm));
2115 }
2116 if let Some(mi) = &r.merged_into {
2117 o.insert("mergedInto".to_owned(), json!(mi));
2118 }
2119 Json::Object(o)
2120 })
2121 .collect(),
2122 ),
2123 );
2124 m.insert(
2126 "revisions".to_owned(),
2127 Json::Array(
2128 res.revisions
2129 .iter()
2130 .map(|r| json!({ "doc": r.doc, "path": reference_path(&r.path) }))
2131 .collect(),
2132 ),
2133 );
2134 if let Some(diffs) = &res.diffs {
2135 let mut d = Map::new();
2136 for (path, diff) in diffs {
2137 d.insert(
2138 reference_path(path),
2139 json!({ "before": diff.before, "after": diff.after }),
2140 );
2141 }
2142 m.insert("diffs".to_owned(), Json::Object(d));
2143 }
2144 m.insert("committed".to_owned(), json!(res.committed));
2145 Json::Object(m)
2146}
2147
2148fn doc_op_json(res: &omgbase_store::DocOpResult) -> Json {
2151 let mut m = Map::new();
2152 m.insert("docId".to_owned(), json!(res.doc_id));
2153 m.insert("path".to_owned(), json!(reference_path(&res.path)));
2154 m.insert("committed".to_owned(), json!(res.committed));
2155 if let Some(diffs) = &res.diffs {
2156 m.insert("diffs".to_owned(), reference_keyed(&diffs_json(diffs)));
2157 }
2158 Json::Object(m)
2159}
2160
2161fn root_changes_page(mut page: Json) -> Json {
2169 let Some(digests) = page.get_mut("digests").and_then(Json::as_array_mut) else {
2170 return page;
2171 };
2172 for d in digests.iter_mut() {
2173 if let Some(revs) = d.get_mut("revisions").and_then(Json::as_array_mut) {
2174 for r in revs.iter_mut() {
2175 root_field(r, "path");
2176 }
2177 }
2178 if let Some(summary) = d.get("summary").and_then(Json::as_str) {
2179 let rooted = root_summary(summary);
2180 d["summary"] = Json::String(rooted);
2181 }
2182 }
2183 page
2184}
2185
2186fn root_summary(summary: &str) -> String {
2188 let Some(colon) = summary.find(": ") else {
2189 return summary.to_owned();
2190 };
2191 let (head, rest) = summary.split_at(colon + 2);
2192 let (subject, detail) = match rest.find(" — ") {
2193 Some(i) => rest.split_at(i),
2194 None => (rest, ""),
2195 };
2196 let root_list = |s: &str| -> String {
2197 if s.is_empty() {
2198 return String::new();
2199 }
2200 s.split(", ")
2201 .map(reference_path)
2202 .collect::<Vec<_>>()
2203 .join(", ")
2204 };
2205 let subject = if let Some(paths) = subject.strip_prefix("deleted ") {
2206 format!("deleted {}", root_list(paths))
2207 } else if let Some(mv) = subject.strip_prefix("moved ") {
2208 match mv.split_once(" → ") {
2209 Some((from, to)) => format!("moved {} → {}", reference_path(from), reference_path(to)),
2210 None => format!("moved {mv}"),
2211 }
2212 } else {
2213 root_list(subject)
2214 };
2215 format!("{head}{subject}{detail}")
2216}
2217
2218#[must_use]
2221pub fn opset_json(opset: &Opset) -> Json {
2222 let mut m = Map::new();
2223 m.insert("version".to_owned(), json!(1));
2224 m.insert("kind".to_owned(), json!("doc_update"));
2225 m.insert(
2227 "target".to_owned(),
2228 json!({ "doc": opset.target_doc, "path": reference_path(&opset.target_path) }),
2229 );
2230 m.insert(
2231 "precondition".to_owned(),
2232 json!({
2233 "doc": opset.precondition.doc,
2234 "path": reference_path(&opset.precondition.path),
2235 "baseRevision": opset.precondition.base_revision,
2236 "baseContentHash": opset.precondition.base_content_hash,
2237 }),
2238 );
2239 m.insert("matcherV".to_owned(), json!(opset.matcher_v));
2240 m.insert(
2241 "ops".to_owned(),
2242 Json::Array(
2243 opset
2244 .ops
2245 .iter()
2246 .map(|p| {
2247 let mut o = Map::new();
2248 let mut op = p.op.to_json();
2253 if let Some(m) = op.as_object_mut()
2254 && let Some(c) = m.remove("child_ids")
2255 {
2256 m.insert("childIds".to_owned(), c);
2257 }
2258 o.insert("op".to_owned(), op);
2259 o.insert("disposition".to_owned(), json!(p.disposition.as_str()));
2260 o.insert("blocks".to_owned(), json!(p.blocks));
2261 o.insert("confidence".to_owned(), json!(p.confidence));
2262 o.insert("reason".to_owned(), json!(p.reason));
2263 if let Some(d) = &p.detail {
2264 o.insert("detail".to_owned(), d.clone());
2265 }
2266 Json::Object(o)
2267 })
2268 .collect(),
2269 ),
2270 );
2271 if let Some(fm) = &opset.frontmatter {
2272 m.insert("frontmatter".to_owned(), json!({ "raw": fm }));
2273 }
2274 m.insert("summary".to_owned(), opset.summary.to_json());
2275 m.insert("converges".to_owned(), json!(opset.converges));
2276 m.insert("diagnostics".to_owned(), json!(opset.diagnostics));
2277 Json::Object(m)
2278}
2279
2280fn evidence_json(e: &omgbase_store::Evidence) -> Json {
2283 let mut m = Map::new();
2284 m.insert("rrf".to_owned(), json!(e.rrf));
2285 m.insert("boosts".to_owned(), e.boosts.to_json());
2286 if let Some(r) = e.fts_rank {
2287 m.insert("ftsRank".to_owned(), json!(r));
2288 }
2289 if let Some(r) = e.vector_rank {
2290 m.insert("vectorRank".to_owned(), json!(r));
2291 if let Some(c) = e.cosine {
2292 m.insert("cosine".to_owned(), json!(c));
2293 }
2294 }
2295 Json::Object(m)
2296}
2297
2298fn observe_json(o: &omgbase_store::ObserveOutcome) -> Json {
2301 json!({
2302 "docId": o.doc_id,
2303 "path": reference_path(&o.path),
2304 "rev": o.rev,
2305 "commitId": o.commit_id,
2306 "converged": o.converged,
2307 "echo": o.echo,
2308 "conflicted": o.conflicted,
2309 "dispositions": o.dispositions.iter().map(|(k, n)| json!({ "kind": k, "count": n })).collect::<Vec<_>>(),
2310 })
2311}
2312
2313fn verb(d: omgbase_store::mutate_kernel::PlanDisposition) -> &'static str {
2314 use omgbase_store::mutate_kernel::PlanDisposition as D;
2315 match d {
2316 D::Same => "KEEP ",
2317 D::Edited | D::EditedMoved => "UPDATE",
2318 D::Moved => "MOVE ",
2319 D::Inserted => "INSERT",
2320 D::Deleted => "REMOVE",
2321 D::SplitFrom => "SPLIT ",
2322 D::MergedInto => "MERGE ",
2323 D::CopiedFrom => "COPY ",
2324 D::Resurrected => "RESURR",
2325 D::BulkRewrite => "REWRITE",
2326 D::Retiled => "RETILE",
2327 }
2328}
2329
2330#[must_use]
2332pub fn render_opset_plan(opset: &Opset) -> String {
2333 let mut lines = Vec::new();
2334 for p in &opset.ops {
2335 let subject = p.blocks.first().map_or("(new)", String::as_str);
2336 let conf = p.confidence.map_or(String::new(), |c| format!(" ~{c:.2}"));
2337 let why = p
2338 .reason
2339 .as_ref()
2340 .map_or(String::new(), |r| format!(" [{r}]"));
2341 lines.push(format!(
2342 "{} {:<9} {}{conf}{why}",
2343 verb(p.disposition),
2344 subject,
2345 p.disposition.as_str()
2346 ));
2347 }
2348 let s = &opset.summary;
2349 lines.push(String::new());
2350 lines.push(format!(
2351 "preserved: {} updated: {} moved: {} created: {} removed: {} split: {} merged: {} ambiguous: {}",
2352 s.preserved, s.updated, s.moved, s.created, s.removed, s.split, s.merged, s.ambiguous
2353 ));
2354 if !opset.converges {
2355 lines.push(
2356 "WARNING: plan does not reproduce the proposed content exactly — will not apply."
2357 .to_owned(),
2358 );
2359 }
2360 lines.join("\n")
2361}
2362
2363#[cfg(test)]
2364mod tests {
2365 use super::*;
2366 use omgbase_store::{MemDocStore, SequentialMinter};
2367
2368 fn surface() -> Surface {
2369 let mut store =
2370 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
2371 let repo = store.create_repo("fixture").unwrap();
2372 let mut n = 0;
2373 Surface::new(store, &repo, None)
2374 .with_doc_store(Box::new(MemDocStore::new()))
2375 .with_clock(move || {
2376 n += 1;
2377 format!("2026-09-26T10:{n:02}:00.000Z")
2378 })
2379 }
2380
2381 #[test]
2382 fn catalog_lists_every_tool_of_the_table() {
2383 let names: Vec<&str> = tools().iter().map(|t| t.name).collect();
2384 for want in [
2385 "docs_outline",
2386 "docs_read",
2387 "docs_get_many",
2388 "nodes_get",
2389 "nodes_get_many",
2390 "read_ref",
2391 "docs_tree",
2392 "docs_list",
2393 "query_syntax",
2394 "query",
2395 "graph",
2396 "text_search",
2397 "resolve",
2398 "apply",
2399 "blocks_insert",
2400 "blocks_update",
2401 "blocks_move",
2402 "blocks_remove",
2403 "blocks_split",
2404 "blocks_merge",
2405 "tasks_complete",
2406 "node_set",
2407 "sections_append",
2408 "docs_append",
2409 "links_retarget",
2410 "links_stale",
2411 "links_repair",
2412 "docs_create",
2413 "docs_move",
2414 "docs_delete",
2415 "docs_set_meta",
2416 "docs_plan_update",
2417 "docs_update",
2418 "observe",
2419 "observe_many",
2420 "observe_delete",
2421 "history_node",
2422 "diff",
2423 "diff_unified",
2424 "docs_read_at",
2425 "docs_history",
2426 "changes_since",
2427 "repos_status",
2428 "sync_status",
2429 "repos",
2430 "version",
2431 ] {
2432 assert!(names.contains(&want), "missing {want}");
2433 }
2434 assert_eq!(names.len(), 46);
2435 for t in tools() {
2436 assert_eq!(t.input_schema["type"], "object");
2437 }
2438 }
2439
2440 #[test]
2441 fn observe_read_and_query_round_trip() {
2442 let mut s = surface();
2443 let out = s.call("observe", json!({ "path": "a.md", "content": "---\nlayer: canon\n---\n# Title\n\nHello world.\n\n- [ ] task one\n" }));
2444 assert!(!out.is_error, "{}", out.body);
2445 assert_eq!(out.body["docId"], "d_0");
2446 assert_eq!(out.body["echo"], false);
2447 let read = s.call("docs_read", json!({ "doc": "a.md", "include_ids": true }));
2448 assert!(!read.is_error);
2449 assert_eq!(read.body["path"], "/a.md");
2450 assert_eq!(
2452 s.call("docs_read", json!({ "doc": "/a.md" })).body["path"],
2453 "/a.md"
2454 );
2455 assert_eq!(
2456 s.call("docs_read", json!({ "path": "//a.md" })).body["docId"],
2457 "d_0"
2458 );
2459 assert_eq!(read.body["properties"]["frontmatter"]["layer"], "canon");
2460 assert!(read.body["ids"].as_array().unwrap().len() >= 3);
2461 let q = s.call("query", json!({ "query": "select $title, layer, t: nodes collect { value where kind == \"md:task\" } from docs where layer == \"canon\" && nodes count { where kind == \"md:task\" } == 1" }));
2462 assert!(!q.is_error, "{}", q.body);
2463 assert_eq!(q.body["hits"][0]["$title"], "Title");
2464 assert_eq!(q.body["hits"][0]["layer"], "canon");
2465 assert_eq!(q.body["hits"][0]["t"][0]["value"], "task one");
2466 assert_eq!(q.body["hits"][0]["id"], "d_0");
2467 assert_eq!(q.body["consumer"], "collect");
2468 let c = s.call(
2469 "query",
2470 json!({ "query": "blocks count { where text(\"hello\") }" }),
2471 );
2472 assert_eq!(c.body["count"], 1);
2473 let bad = s.call(
2474 "query",
2475 json!({ "query": "from docs where path == \"a.md\"" }),
2476 );
2477 assert!(bad.is_error);
2478 assert_eq!(bad.body["error"], "filter_invalid");
2479 assert!(
2480 bad.body["message"]
2481 .as_str()
2482 .unwrap()
2483 .contains("did you mean the intrinsic $path")
2484 );
2485 let sem = s.call(
2486 "query",
2487 json!({ "query": "from docs where semantic(\"x\") > 0.5" }),
2488 );
2489 assert_eq!(sem.body["error"], "semantic_unavailable");
2490 let outline = s.call("docs_outline", json!({ "path": "a.md" }));
2491 let text = outline.body["text"].as_str().unwrap();
2492 assert!(text.starts_with("b_0 h1 Title §"), "{text}");
2493 assert!(text.contains("☐ task one"));
2494 let missing = s.call("docs_read", json!({ "doc": "nope.md" }));
2495 assert_eq!(missing.body["error"], "doc_missing");
2496 let repos = s.call("repos", json!({}));
2497 assert_eq!(repos.body["repos"][0]["slug"], "fixture");
2498 let unknown = s.call("docs_list", json!({ "repo": "zzz" }));
2499 assert_eq!(unknown.body["error"], "repo_not_found");
2500 }
2501
2502 #[test]
2503 fn insert_and_move_carry_the_destination_parent_cas_and_many_reads_keep_one_item() {
2504 let mut s = surface();
2505 s.call(
2506 "observe",
2507 json!({ "path": "a.md", "content": "# T\n\nOne.\n\nTwo.\n" }),
2508 );
2509 s.call("observe", json!({ "path": "b.md", "content": "# U\n" }));
2510 let stale = s.call(
2514 "blocks_insert",
2515 json!({ "to": "a.md", "markdown": "Three.", "expect": { "parent_children_hash": "00" } }),
2516 );
2517 assert!(stale.is_error, "{}", stale.body);
2518 assert_eq!(stale.body["error"], "stale_expectation");
2519 assert_eq!(stale.body["retriable"], true);
2520 let current = stale.body["data"]["current"]["parent_children_hash"]
2521 .as_str()
2522 .expect("current hash")
2523 .to_owned();
2524 assert_eq!(current.len(), 64, "{current}");
2525 let ok = s.call(
2526 "blocks_insert",
2527 json!({ "to": "a.md", "markdown": "Three.", "expect": { "parent_children_hash": current, "content_hash": "dropped, not checked" } }),
2528 );
2529 assert!(!ok.is_error, "{}", ok.body);
2530 assert_eq!(ok.body["committed"], true);
2531 let read = s.call("docs_read", json!({ "doc": "a.md" }));
2532 assert_eq!(read.body["content"], "# T\n\nOne.\n\nTwo.\n\nThree.\n");
2533 let stale = s.call(
2535 "blocks_move",
2536 json!({ "blocks": ["b_1", "b_2"], "to": "a.md", "at": "end", "expect": { "parent_children_hash": "00" } }),
2537 );
2538 assert_eq!(stale.body["error"], "stale_expectation", "{}", stale.body);
2539 let current = stale.body["data"]["current"]["parent_children_hash"]
2540 .as_str()
2541 .expect("current hash")
2542 .to_owned();
2543 let ok = s.call(
2544 "blocks_move",
2545 json!({ "blocks": ["b_1", "b_2"], "to": "a.md", "at": "end", "expect": { "parent_children_hash": current } }),
2546 );
2547 assert!(!ok.is_error, "{}", ok.body);
2548 let read = s.call("docs_read", json!({ "doc": "a.md" }));
2549 assert_eq!(read.body["content"], "# T\n\nThree.\n\nOne.\n\nTwo.\n\n");
2550 let dry = s.call(
2552 "blocks_move",
2553 json!({ "blocks": ["b_1"], "to": "a.md", "at": "start", "dry_run": true, "expect": { "parent_children_hash": "00" } }),
2554 );
2555 assert_eq!(dry.body["error"], "stale_expectation", "{}", dry.body);
2556 let many = s.call(
2559 "docs_get_many",
2560 json!({ "docs": ["a.md", "b.md"], "budget_tokens": 1 }),
2561 );
2562 assert_eq!(
2563 many.body["items"].as_array().unwrap().len(),
2564 1,
2565 "{}",
2566 many.body
2567 );
2568 assert_eq!(many.body["items"][0]["path"], "/a.md");
2569 assert_eq!(many.body["truncated"], true);
2570 let one = s.call(
2571 "docs_get_many",
2572 json!({ "docs": ["b.md"], "budget_tokens": 1 }),
2573 );
2574 assert_eq!(one.body["items"].as_array().unwrap().len(), 1);
2575 assert_eq!(one.body["truncated"], false);
2576 let nodes = s.call(
2577 "nodes_get_many",
2578 json!({ "ids": ["b_zzz", "b_1", "b_2"], "resolution": "full", "budget_tokens": 1 }),
2579 );
2580 assert_eq!(
2581 nodes.body["nodes"].as_array().unwrap().len(),
2582 1,
2583 "{}",
2584 nodes.body
2585 );
2586 assert_eq!(nodes.body["nodes"][0]["id"], "b_1");
2587 assert_eq!(nodes.body["truncated"], true);
2588 assert_eq!(nodes.body["unresolved"], json!(["b_zzz"]));
2589 }
2590
2591 #[test]
2592 fn writes_go_through_the_fixed_doc_store_and_fire_the_hook() {
2593 use std::cell::Cell;
2594 use std::rc::Rc;
2595 let fired = Rc::new(Cell::new(0));
2596 let f2 = Rc::clone(&fired);
2597 let mut s = surface().with_mutation_hook(move || f2.set(f2.get() + 1));
2598 s.call(
2599 "observe",
2600 json!({ "path": "a.md", "content": "# T\n\nOne.\n" }),
2601 );
2602 assert_eq!(fired.get(), 1);
2603 let dry = s.call(
2604 "blocks_insert",
2605 json!({ "to": "a.md", "markdown": "Two.", "dry_run": true }),
2606 );
2607 assert!(!dry.is_error, "{}", dry.body);
2608 assert_eq!(dry.body["committed"], false);
2609 assert_eq!(fired.get(), 1, "a dry run never fires the hook");
2610 let wet = s.call("blocks_insert", json!({ "to": "a.md", "markdown": "Two." }));
2611 assert!(!wet.is_error, "{}", wet.body);
2612 assert_eq!(fired.get(), 2);
2613 let read = s.call("docs_read", json!({ "doc": "d_0" }));
2614 assert_eq!(read.body["content"], "# T\n\nOne.\n\nTwo.\n");
2615 let upd = s.call(
2616 "blocks_update",
2617 json!({ "block": "b_1", "markdown": "One, edited." }),
2618 );
2619 assert!(!upd.is_error, "{}", upd.body);
2620 assert_eq!(upd.body["id"], "b_1");
2621 let app = s.call(
2622 "sections_append",
2623 json!({ "heading": "T", "markdown": "Three." }),
2624 );
2625 assert!(!app.is_error, "{}", app.body);
2626 let amb = s.call(
2627 "sections_append",
2628 json!({ "heading": "Nope", "markdown": "x" }),
2629 );
2630 assert_eq!(amb.body["error"], "parent_missing");
2631 let hist = s.call("history_node", json!({ "id": "b_1" }));
2632 let entries = hist.body.as_array().unwrap();
2633 assert!(entries.len() >= 2, "{}", hist.body);
2634 assert_eq!(entries[0]["origin"], "api", "newest first");
2635 let du = s.call("diff_unified", json!({ "doc": "a.md" }));
2636 assert!(!du.is_error, "{}", du.body);
2637 assert!(du.body["diff"].as_str().unwrap().contains("+Three."));
2638 }
2639
2640 #[test]
2641 fn doc_tools_dry_run_preview_diffs_and_commit_nothing() {
2642 use std::cell::Cell;
2643 use std::rc::Rc;
2644 let fired = Rc::new(Cell::new(0));
2645 let f2 = Rc::clone(&fired);
2646 let mut s = surface().with_mutation_hook(move || f2.set(f2.get() + 1));
2647 let b_src = "# B\n\nTarget.\n";
2648 let a_src = "# A\n\nSee [b](b.md).\n";
2649 let b = s.call("docs_create", json!({ "path": "b.md", "markdown": b_src }));
2650 assert_eq!(b.body["docId"], "d_0", "{}", b.body);
2651 let a = s.call("docs_create", json!({ "path": "a.md", "markdown": a_src }));
2652 assert_eq!(a.body["docId"], "d_1", "{}", a.body);
2653 assert_eq!(fired.get(), 2);
2654 let count = |s: &Surface, table: &str| -> i64 {
2655 s.store
2656 .conn()
2657 .query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
2658 .unwrap()
2659 };
2660 let snapshot = |s: &Surface| {
2661 (
2662 count(s, "docs"),
2663 count(s, "commits"),
2664 count(s, "revisions"),
2665 count(s, "blocks"),
2666 )
2667 };
2668 let before = snapshot(&s);
2669 let keys = |v: &Json| -> Vec<String> { v.as_object().unwrap().keys().cloned().collect() };
2670
2671 let c = s.call(
2673 "docs_create",
2674 json!({ "path": "c.md", "markdown": "# C", "frontmatter": { "title": "C" }, "dry_run": true }),
2675 );
2676 assert!(!c.is_error, "{}", c.body);
2677 assert_eq!(
2678 serde_json::to_string(&c.body).unwrap(),
2679 r#"{"docId":"d_2","path":"/c.md","committed":false,"diffs":{"/c.md":{"before":"","after":"---\ntitle: C\n---\n\n# C\n"}}}"#,
2680 "the reference's key order, byte for byte"
2681 );
2682
2683 let m = s.call(
2686 "docs_move",
2687 json!({ "doc": "b.md", "to_path": "notes/b.md", "retarget_inbound": true, "dry_run": true }),
2688 );
2689 assert!(!m.is_error, "{}", m.body);
2690 assert_eq!(
2694 serde_json::to_string(&m.body).unwrap(),
2695 r##"{"docId":"d_0","path":"/notes/b.md","committed":false,"diffs":{"/b.md":{"before":"# B\n\nTarget.\n","after":""},"/notes/b.md":{"before":"","after":"# B\n\nTarget.\n"},"/a.md":{"before":"# A\n\nSee [b](b.md).\n","after":"# A\n\nSee [b](notes/b.md).\n"}},"dangling":[],"retargeted":{"blocks":["b_3"],"docs":["d_1"]}}"##
2696 );
2697 assert_eq!(
2698 keys(&m.body),
2699 [
2700 "docId",
2701 "path",
2702 "committed",
2703 "diffs",
2704 "dangling",
2705 "retargeted"
2706 ]
2707 );
2708 assert_eq!(keys(&m.body["diffs"]), ["/b.md", "/notes/b.md", "/a.md"]);
2709 assert_eq!(
2710 m.body["diffs"]["/a.md"],
2711 json!({ "before": a_src, "after": "# A\n\nSee [b](notes/b.md).\n" })
2712 );
2713 let plain = s.call(
2716 "docs_move",
2717 json!({ "doc": "b.md", "to_path": "notes/b.md", "dry_run": true, "retarget_inbound": false }),
2718 );
2719 assert_eq!(
2720 serde_json::to_string(&plain.body).unwrap(),
2721 r##"{"docId":"d_0","path":"/notes/b.md","committed":false,"diffs":{"/b.md":{"before":"# B\n\nTarget.\n","after":""},"/notes/b.md":{"before":"","after":"# B\n\nTarget.\n"}},"dangling":[{"doc":"d_1","path":"/a.md","block":"b_3","target":"b.md","anchor":null}],"retargeted":null}"##
2722 );
2723
2724 let d = s.call("docs_delete", json!({ "doc": "a.md", "dry_run": true }));
2726 assert!(!d.is_error, "{}", d.body);
2727 assert_eq!(
2728 d.body,
2729 json!({ "docId": "d_1", "path": "/a.md", "committed": false, "diffs": { "/a.md": { "before": a_src, "after": "" } } })
2730 );
2731 assert_eq!(keys(&d.body), ["docId", "path", "committed", "diffs"]);
2732
2733 let sm = s.call(
2735 "docs_set_meta",
2736 json!({ "doc": "a.md", "set": { "status": "open" }, "dry_run": true }),
2737 );
2738 assert!(!sm.is_error, "{}", sm.body);
2739 assert_eq!(
2740 sm.body,
2741 json!({ "docId": "d_1", "path": "/a.md", "committed": false, "diffs": { "/a.md": { "before": a_src, "after": "---\nstatus: open\n---\n\n# A\n\nSee [b](b.md).\n" } } })
2742 );
2743
2744 assert_eq!(fired.get(), 2, "dry runs never fire the mutation hook");
2746 assert_eq!(snapshot(&s), before);
2747 assert_eq!(
2748 s.call("docs_read", json!({ "doc": "d_0" })).body["path"],
2749 "/b.md"
2750 );
2751 assert_eq!(
2752 s.call("docs_read", json!({ "doc": "d_1" })).body["content"],
2753 a_src
2754 );
2755 let taken = s.call(
2756 "docs_create",
2757 json!({ "path": "a.md", "markdown": "x", "dry_run": true }),
2758 );
2759 assert_eq!(taken.body["error"], "path_taken");
2760 let missing = s.call("docs_delete", json!({ "doc": "nope.md", "dry_run": true }));
2761 assert_eq!(missing.body["error"], "doc_missing");
2762 let real = s.call(
2764 "docs_create",
2765 json!({ "path": "c.md", "markdown": "# C\n" }),
2766 );
2767 assert_eq!(
2768 real.body,
2769 json!({ "docId": "d_3", "path": "/c.md", "committed": true })
2770 );
2771 assert_eq!(fired.get(), 3);
2772 }
2773
2774 #[test]
2775 fn change_summaries_are_rooted_like_the_reference_renders_them() {
2776 assert_eq!(
2777 root_summary("observed: a.md, b/c.md — 3 inserted, 1 edited"),
2778 "observed: /a.md, /b/c.md — 3 inserted, 1 edited"
2779 );
2780 assert_eq!(
2781 root_summary("observed: deleted a.md"),
2782 "observed: deleted /a.md"
2783 );
2784 assert_eq!(
2785 root_summary("api(human:spec): moved a.md → b/a.md"),
2786 "api(human:spec): moved /a.md → /b/a.md"
2787 );
2788 assert_eq!(root_summary("api(?): x.md"), "api(?): /x.md");
2789 assert_eq!(root_summary("observed: "), "observed: ");
2790 assert_eq!(root_summary("nonsense"), "nonsense");
2791 let page = root_changes_page(json!({
2792 "digests": [{ "summary": "observed: a.md", "revisions": [{ "doc": "d_0", "path": "a.md", "contentHash": "ff" }] }],
2793 "cursor": 1, "truncated": false, "head": 1
2794 }));
2795 assert_eq!(page["digests"][0]["revisions"][0]["path"], "/a.md");
2796 assert_eq!(page["digests"][0]["summary"], "observed: /a.md");
2797 }
2798}