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::arguments::{argument_error, validate_arguments};
32use crate::error::{Result, SurfaceError};
33use crate::graph::{GraphArgs, graph_neighborhood};
34use crate::history;
35use crate::links;
36use crate::paths::{reference_keyed, reference_path, root_field, storage_path};
37use crate::query::{QueryOptions, query};
38use crate::read::{self, Resolution, ResolvedRef};
39use crate::reference::QUERY_SYNTAX;
40use crate::version::{BuildInfo, version_info};
41
42pub const ACTOR: &str = "agent:mcp";
44
45#[derive(Clone, Debug, PartialEq)]
47pub struct ToolSpec {
48 pub name: &'static str,
49 pub description: &'static str,
50 pub input_schema: Json,
52}
53
54#[derive(Clone, Debug, PartialEq)]
57pub struct ToolOutcome {
58 pub body: Json,
59 pub is_error: bool,
60}
61
62enum WriteTarget {
64 Derived,
67 Fixed(Box<dyn DocStore>),
70}
71
72pub struct Surface {
74 store: Store,
75 default_repo: String,
76 provider: Option<Box<dyn EmbeddingProvider>>,
77 provider_name: Option<String>,
80 on_mutation: Option<Box<dyn FnMut()>>,
81 clock: Box<dyn FnMut() -> String>,
82 writes: WriteTarget,
83 config: Config,
84 actor: String,
86 build_info: BuildInfo,
88}
89
90fn bad_args(msg: impl Into<String>) -> SurfaceError {
93 SurfaceError::filter_invalid(msg, "arguments")
94}
95
96fn arg_str<'a>(args: &'a Json, key: &str) -> Option<&'a str> {
97 args.get(key).and_then(Json::as_str)
98}
99
100fn arg_string(args: &Json, key: &str) -> Result<String> {
101 arg_str(args, key)
102 .map(str::to_owned)
103 .ok_or_else(|| bad_args(format!("`{key}` must be a string")))
104}
105
106fn arg_i64(args: &Json, key: &str) -> Result<Option<i64>> {
107 match args.get(key) {
108 None | Some(Json::Null) => Ok(None),
109 Some(v) => v
110 .as_f64()
111 .filter(|n| n.fract() == 0.0)
112 .map(|n| Some(n as i64))
113 .ok_or_else(|| bad_args(format!("`{key}` must be an integer"))),
114 }
115}
116
117fn arg_usize(args: &Json, key: &str) -> Result<Option<usize>> {
118 Ok(arg_i64(args, key)?.map(|n| usize::try_from(n).unwrap_or(0)))
119}
120
121fn arg_bool(args: &Json, key: &str) -> Result<Option<bool>> {
122 match args.get(key) {
123 None | Some(Json::Null) => Ok(None),
124 Some(Json::Bool(b)) => Ok(Some(*b)),
125 Some(_) => Err(bad_args(format!("`{key}` must be a boolean"))),
126 }
127}
128
129fn arg_strings(args: &Json, key: &str) -> Result<Vec<String>> {
130 let Some(arr) = args.get(key).and_then(Json::as_array) else {
131 return Err(bad_args(format!("`{key}` must be an array of strings")));
132 };
133 arr.iter()
134 .map(|v| {
135 v.as_str()
136 .map(str::to_owned)
137 .ok_or_else(|| bad_args(format!("`{key}` must be an array of strings")))
138 })
139 .collect()
140}
141
142fn arg_object<'a>(args: &'a Json, key: &str) -> Result<Option<&'a Map<String, Json>>> {
143 match args.get(key) {
144 None | Some(Json::Null) => Ok(None),
145 Some(Json::Object(o)) => Ok(Some(o)),
146 Some(_) => Err(bad_args(format!("`{key}` must be an object"))),
147 }
148}
149
150fn hex_field(e: &Map<String, Json>, k: &str) -> Option<String> {
151 e.get(k).and_then(Json::as_str).map(str::to_owned)
152}
153
154fn arg_expect(args: &Json) -> Result<Option<Expect>> {
157 Ok(arg_object(args, "expect")?.map(|e| Expect {
158 content_hash: hex_field(e, "content_hash"),
159 parent_children_hash: hex_field(e, "parent_children_hash"),
160 }))
161}
162
163fn arg_parent_expect(args: &Json) -> Result<Option<Expect>> {
168 Ok(arg_object(args, "expect")?.map(|e| Expect {
169 content_hash: None,
170 parent_children_hash: hex_field(e, "parent_children_hash"),
171 }))
172}
173
174fn resolution_arg(args: &Json, default: Resolution) -> Result<Resolution> {
175 match arg_str(args, "resolution") {
176 None => Ok(default),
177 Some(s) => Resolution::parse(s).ok_or_else(|| {
178 bad_args("`resolution` must be one of skeleton, outline, text, raw, full")
179 }),
180 }
181}
182
183fn merge(mut a: Map<String, Json>, b: Json) -> Json {
185 if let Json::Object(o) = b {
186 for (k, v) in o {
187 a.insert(k, v);
188 }
189 }
190 Json::Object(a)
191}
192
193fn schema(props: &[(&str, Json)], required: &[&str], repo: bool) -> Json {
194 let mut p = Map::new();
195 for (k, v) in props {
196 p.insert((*k).to_owned(), v.clone());
197 }
198 if repo {
199 p.insert("repo".to_owned(), json!({ "type": "string" }));
200 }
201 json!({ "type": "object", "properties": p, "required": required })
202}
203
204fn s() -> Json {
205 json!({ "type": "string" })
206}
207fn i() -> Json {
208 json!({ "type": "integer" })
209}
210fn b() -> Json {
211 json!({ "type": "boolean" })
212}
213fn strings() -> Json {
214 json!({ "type": "array", "items": { "type": "string" } })
215}
216fn obj() -> Json {
217 json!({ "type": "object" })
218}
219fn nullable_string() -> Json {
220 json!({ "type": ["string", "null"] })
221}
222fn resolution_schema() -> Json {
223 json!({ "type": "string", "enum": ["skeleton", "outline", "text", "raw", "full"] })
224}
225fn at_schema() -> Json {
226 json!({ "oneOf": [
227 { "const": "start" }, { "const": "end" },
228 { "type": "object", "properties": { "before": { "type": "string" } }, "required": ["before"] },
229 { "type": "object", "properties": { "after": { "type": "string" } }, "required": ["after"] }
230 ] })
231}
232fn expect_schema() -> Json {
233 json!({ "type": "object", "properties": { "content_hash": { "type": "string" }, "parent_children_hash": { "type": "string" } } })
234}
235fn parent_expect_schema() -> Json {
240 json!({ "type": "object", "properties": { "parent_children_hash": { "type": "string" } } })
241}
242
243#[must_use]
248pub fn tools() -> Vec<ToolSpec> {
249 catalog().to_vec()
250}
251
252pub(crate) fn catalog() -> &'static [ToolSpec] {
254 static CATALOG: std::sync::OnceLock<Vec<ToolSpec>> = std::sync::OnceLock::new();
255 CATALOG.get_or_init(build_catalog)
256}
257
258pub(crate) fn tool_spec(name: &str) -> Option<&'static ToolSpec> {
260 catalog().iter().find(|t| t.name == name)
261}
262
263#[allow(clippy::too_many_lines)]
264fn build_catalog() -> Vec<ToolSpec> {
265 use crate::descriptions as d;
266 let doc = |extra: &[(&str, Json)], required: &[&str]| {
269 let mut props = vec![("id", s()), ("path", s())];
270 props.extend(extra.iter().cloned());
271 schema(&props, required, true)
272 };
273 vec![
274 ToolSpec {
275 name: "docs_outline",
276 description: d::DOCS_OUTLINE,
277 input_schema: doc(
278 &[
279 (
280 "resolution",
281 json!({ "type": "string", "enum": ["skeleton", "outline"] }),
282 ),
283 ("depth", i()),
284 ("budget_tokens", i()),
285 ],
286 &[],
287 ),
288 },
289 ToolSpec {
290 name: "docs_read",
291 description: d::DOCS_READ,
292 input_schema: doc(&[("include_ids", b())], &[]),
293 },
294 ToolSpec {
295 name: "docs_read_many",
296 description: d::DOCS_READ_MANY,
297 input_schema: schema(
298 &[
299 ("docs", strings()),
300 ("include_ids", b()),
301 ("budget_tokens", i()),
302 ],
303 &["docs"],
304 true,
305 ),
306 },
307 ToolSpec {
308 name: "blocks_read",
309 description: d::BLOCKS_READ,
310 input_schema: schema(
311 &[("id", s()), ("resolution", resolution_schema())],
312 &["id"],
313 true,
314 ),
315 },
316 ToolSpec {
317 name: "nodes_block_read",
318 description: d::NODES_BLOCK_READ,
319 input_schema: schema(
320 &[("id", s()), ("resolution", resolution_schema())],
321 &["id"],
322 true,
323 ),
324 },
325 ToolSpec {
326 name: "blocks_read_many",
327 description: d::BLOCKS_READ_MANY,
328 input_schema: schema(
329 &[
330 ("ids", strings()),
331 ("resolution", resolution_schema()),
332 ("budget_tokens", i()),
333 ],
334 &["ids"],
335 true,
336 ),
337 },
338 ToolSpec {
339 name: "docs_tree",
340 description: d::DOCS_TREE,
341 input_schema: schema(
342 &[
343 ("path", s()),
344 ("depth", i()),
345 ("limit", i()),
346 ("cursor", nullable_string()),
347 ("budget_tokens", i()),
348 ],
349 &[],
350 true,
351 ),
352 },
353 ToolSpec {
354 name: "docs_list",
355 description: d::DOCS_LIST,
356 input_schema: schema(
357 &[
358 ("path_glob", s()),
359 ("limit", i()),
360 ("cursor", nullable_string()),
361 ("budget_tokens", i()),
362 ],
363 &[],
364 true,
365 ),
366 },
367 ToolSpec {
368 name: "query_syntax",
369 description: d::QUERY_SYNTAX,
370 input_schema: schema(&[], &[], false),
371 },
372 ToolSpec {
373 name: "query",
374 description: d::QUERY,
375 input_schema: schema(
376 &[
377 ("query", s()),
378 ("limit", i()),
379 ("cursor", nullable_string()),
380 ],
381 &["query"],
382 true,
383 ),
384 },
385 ToolSpec {
386 name: "graph",
387 description: d::GRAPH,
388 input_schema: schema(
389 &[
390 ("roots", strings()),
391 ("degrees", i()),
392 (
393 "direction",
394 json!({ "type": "string", "enum": ["in", "out", "both"] }),
395 ),
396 ("predicate", s()),
397 ("select", strings()),
398 ("max_documents", i()),
399 ],
400 &["roots"],
401 true,
402 ),
403 },
404 ToolSpec {
405 name: "blocks_search",
406 description: d::BLOCKS_SEARCH,
407 input_schema: schema(&[("q", s()), ("limit", i())], &["q"], true),
408 },
409 ToolSpec {
410 name: "blocks_resolve",
411 description: d::BLOCKS_RESOLVE,
412 input_schema: schema(&[("query", s()), ("limit", i())], &["query"], true),
413 },
414 ToolSpec {
415 name: "apply",
416 description: d::APPLY,
417 input_schema: schema(
418 &[
419 (
420 "ops",
421 json!({ "type": "array", "items": { "type": "object" } }),
422 ),
423 ("reason", s()),
424 ("dry_run", b()),
425 ],
426 &["ops"],
427 true,
428 ),
429 },
430 ToolSpec {
431 name: "blocks_insert",
432 description: d::BLOCKS_INSERT,
433 input_schema: schema(
434 &[
435 ("to", s()),
436 ("markdown", s()),
437 ("at", at_schema()),
438 ("expect", parent_expect_schema()),
439 ("dry_run", b()),
440 ],
441 &["to", "markdown"],
442 true,
443 ),
444 },
445 ToolSpec {
446 name: "blocks_update",
447 description: d::BLOCKS_UPDATE,
448 input_schema: schema(
449 &[
450 ("id", s()),
451 ("markdown", s()),
452 ("checked", b()),
453 ("attrs", obj()),
454 ("expect", expect_schema()),
455 ("dry_run", b()),
456 ],
457 &["id"],
458 true,
459 ),
460 },
461 ToolSpec {
462 name: "blocks_move",
463 description: d::BLOCKS_MOVE,
464 input_schema: schema(
465 &[
466 ("blocks", strings()),
467 ("to", s()),
468 ("at", at_schema()),
469 ("expect", parent_expect_schema()),
470 ("dry_run", b()),
471 ],
472 &["blocks", "to"],
473 true,
474 ),
475 },
476 ToolSpec {
477 name: "blocks_remove",
478 description: d::BLOCKS_REMOVE,
479 input_schema: schema(
480 &[("blocks", strings()), ("dry_run", b())],
481 &["blocks"],
482 true,
483 ),
484 },
485 ToolSpec {
486 name: "blocks_split",
487 description: d::BLOCKS_SPLIT,
488 input_schema: schema(
489 &[
490 ("id", s()),
491 (
492 "at",
493 json!({ "type": "array", "items": { "type": "integer" } }),
494 ),
495 ("dry_run", b()),
496 ],
497 &["id", "at"],
498 true,
499 ),
500 },
501 ToolSpec {
502 name: "blocks_merge",
503 description: d::BLOCKS_MERGE,
504 input_schema: schema(
505 &[("blocks", strings()), ("separator", s()), ("dry_run", b())],
506 &["blocks"],
507 true,
508 ),
509 },
510 ToolSpec {
511 name: "blocks_check",
512 description: d::BLOCKS_CHECK,
513 input_schema: schema(
514 &[("ids", strings()), ("checked", b()), ("dry_run", b())],
515 &["ids"],
516 true,
517 ),
518 },
519 ToolSpec {
520 name: "nodes_set",
521 description: d::NODES_SET,
522 input_schema: schema(
523 &[("id", s()), ("prop", s()), ("value", s()), ("dry_run", b())],
524 &["id", "prop", "value"],
525 true,
526 ),
527 },
528 ToolSpec {
529 name: "blocks_append",
530 description: d::BLOCKS_APPEND,
531 input_schema: schema(
532 &[("id", s()), ("text", s()), ("dry_run", b())],
533 &["id", "text"],
534 true,
535 ),
536 },
537 ToolSpec {
538 name: "docs_append",
539 description: d::DOCS_APPEND,
540 input_schema: doc(
541 &[("text", s()), ("section", s()), ("dry_run", b())],
542 &["text"],
543 ),
544 },
545 ToolSpec {
546 name: "links_retarget",
547 description: d::LINKS_RETARGET,
548 input_schema: schema(
549 &[
550 ("from_target", s()),
551 ("to_target", s()),
552 ("path_glob", s()),
553 ("dry_run", b()),
554 ],
555 &["from_target", "to_target"],
556 true,
557 ),
558 },
559 ToolSpec {
560 name: "links_stale",
561 description: d::LINKS_STALE,
562 input_schema: schema(
563 &[("path_glob", s()), ("limit", i()), ("summary", b())],
564 &[],
565 true,
566 ),
567 },
568 ToolSpec {
569 name: "links_repair",
570 description: d::LINKS_REPAIR,
571 input_schema: schema(
572 &[
573 (
574 "repairs",
575 json!({ "type": "array", "items": { "type": "object", "properties": { "from": { "type": "string" }, "to": { "type": "string" } }, "required": ["from", "to"] } }),
576 ),
577 ("from_target", s()),
578 ("to_target", s()),
579 ("path_glob", s()),
580 ("dry_run", b()),
581 ],
582 &[],
583 true,
584 ),
585 },
586 ToolSpec {
587 name: "docs_create",
588 description: d::DOCS_CREATE,
589 input_schema: schema(
590 &[
591 ("path", s()),
592 ("markdown", s()),
593 ("frontmatter", obj()),
594 ("dry_run", b()),
595 ],
596 &["path", "markdown"],
597 true,
598 ),
599 },
600 ToolSpec {
601 name: "docs_move",
602 description: d::DOCS_MOVE,
603 input_schema: doc(
604 &[
605 ("to_path", s()),
606 ("retarget_inbound", b()),
607 ("dry_run", b()),
608 ],
609 &["to_path"],
610 ),
611 },
612 ToolSpec {
613 name: "docs_delete",
614 description: d::DOCS_DELETE,
615 input_schema: doc(&[("dry_run", b())], &[]),
616 },
617 ToolSpec {
618 name: "docs_set_meta",
619 description: d::DOCS_SET_META,
620 input_schema: doc(
621 &[("set", obj()), ("unset", strings()), ("dry_run", b())],
622 &[],
623 ),
624 },
625 ToolSpec {
626 name: "docs_update",
627 description: d::DOCS_UPDATE,
628 input_schema: doc(
629 &[("content", s()), ("reason", s()), ("dry_run", b())],
630 &["content"],
631 ),
632 },
633 ToolSpec {
634 name: "observe",
635 description: d::OBSERVE,
636 input_schema: schema(
637 &[("path", s()), ("content", s())],
638 &["path", "content"],
639 true,
640 ),
641 },
642 ToolSpec {
643 name: "observe_many",
644 description: d::OBSERVE_MANY,
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: d::OBSERVE_DELETE,
657 input_schema: schema(&[("path", s())], &["path"], true),
658 },
659 ToolSpec {
660 name: "blocks_history",
661 description: d::BLOCKS_HISTORY,
662 input_schema: schema(&[("id", s()), ("limit", i())], &["id"], false),
663 },
664 ToolSpec {
665 name: "docs_diff",
666 description: d::DOCS_DIFF,
667 input_schema: doc(
668 &[("from_rev", s()), ("to_rev", s())],
669 &["from_rev", "to_rev"],
670 ),
671 },
672 ToolSpec {
673 name: "docs_diff_unified",
674 description: d::DOCS_DIFF_UNIFIED,
675 input_schema: doc(&[("from_rev", s()), ("to_rev", s())], &[]),
676 },
677 ToolSpec {
678 name: "docs_read_at",
679 description: d::DOCS_READ_AT,
680 input_schema: doc(&[("rev", s())], &["rev"]),
681 },
682 ToolSpec {
683 name: "docs_history",
684 description: d::DOCS_HISTORY,
685 input_schema: schema(
686 &[
687 ("path_glob", s()),
688 ("id", s()),
689 ("path", s()),
690 ("include_deleted", b()),
691 ("limit", i()),
692 ],
693 &[],
694 true,
695 ),
696 },
697 ToolSpec {
698 name: "changes_since",
699 description: d::CHANGES_SINCE,
700 input_schema: schema(
701 &[
702 ("cursor", i()),
703 (
704 "origin",
705 json!({ "type": "string", "enum": ["api", "observed", "import"] }),
706 ),
707 ("limit", i()),
708 ],
709 &[],
710 true,
711 ),
712 },
713 ToolSpec {
714 name: "repos_status",
715 description: d::REPOS_STATUS,
716 input_schema: schema(&[], &[], true),
717 },
718 ToolSpec {
719 name: "sync_status",
720 description: d::SYNC_STATUS,
721 input_schema: schema(&[], &[], true),
722 },
723 ToolSpec {
724 name: "repos",
725 description: d::REPOS,
726 input_schema: schema(&[], &[], false),
727 },
728 ToolSpec {
729 name: "version",
730 description: d::VERSION,
731 input_schema: schema(&[], &[], false),
732 },
733 ]
734}
735
736impl Surface {
737 #[must_use]
740 pub fn new(
741 store: Store,
742 default_repo: &str,
743 provider: Option<Box<dyn EmbeddingProvider>>,
744 ) -> Self {
745 let _ = store
756 .conn()
757 .execute_batch("PRAGMA cache_size = -16000; PRAGMA temp_store = MEMORY;");
758 Self {
759 store,
760 default_repo: default_repo.to_owned(),
761 provider,
762 provider_name: None,
763 on_mutation: None,
764 clock: Box::new(omgbase_sync::now_ts),
765 writes: WriteTarget::Derived,
766 config: Config::default(),
767 actor: ACTOR.to_owned(),
768 build_info: BuildInfo::default(),
769 }
770 }
771
772 #[must_use]
775 pub fn with_provider_name(mut self, name: &str) -> Self {
776 self.provider_name = Some(name.to_owned());
777 self
778 }
779
780 #[must_use]
783 pub fn with_actor(mut self, actor: &str) -> Self {
784 self.set_actor(actor);
785 self
786 }
787
788 pub fn set_actor(&mut self, actor: &str) {
790 self.actor = actor.to_owned();
791 }
792
793 #[must_use]
796 pub fn with_doc_store(mut self, doc_store: Box<dyn DocStore>) -> Self {
797 self.writes = WriteTarget::Fixed(doc_store);
798 self
799 }
800
801 #[must_use]
803 pub fn with_clock(mut self, clock: impl FnMut() -> String + 'static) -> Self {
804 self.clock = Box::new(clock);
805 self
806 }
807
808 #[must_use]
810 pub fn with_mutation_hook(mut self, hook: impl FnMut() + 'static) -> Self {
811 self.on_mutation = Some(Box::new(hook));
812 self
813 }
814
815 #[must_use]
817 pub fn with_config(mut self, config: Config) -> Self {
818 self.config = config;
819 self
820 }
821
822 #[must_use]
826 pub fn with_build_info(mut self, build_info: BuildInfo) -> Self {
827 self.build_info = build_info;
828 self
829 }
830
831 #[must_use]
832 pub fn store(&self) -> &Store {
833 &self.store
834 }
835
836 pub fn store_mut(&mut self) -> &mut Store {
837 &mut self.store
838 }
839
840 #[must_use]
841 pub fn default_repo(&self) -> &str {
842 &self.default_repo
843 }
844
845 #[must_use]
847 pub fn tools(&self) -> Vec<ToolSpec> {
848 tools()
849 }
850
851 #[must_use]
855 pub fn is_write_tool(name: &str) -> bool {
856 matches!(
857 name,
858 "apply"
859 | "blocks_insert"
860 | "blocks_update"
861 | "blocks_move"
862 | "blocks_remove"
863 | "blocks_split"
864 | "blocks_merge"
865 | "blocks_check"
866 | "nodes_set"
867 | "blocks_append"
868 | "docs_append"
869 | "links_retarget"
870 | "links_repair"
871 | "docs_create"
872 | "docs_move"
873 | "docs_delete"
874 | "docs_set_meta"
875 | "docs_update"
876 | "observe"
877 | "observe_many"
878 | "observe_delete"
879 )
880 }
881
882 pub fn call(&mut self, name: &str, args: Json) -> ToolOutcome {
884 match self.call_result(name, &args) {
885 Ok(body) => ToolOutcome {
886 body,
887 is_error: false,
888 },
889 Err(e) => ToolOutcome {
890 body: e.to_json(),
891 is_error: true,
892 },
893 }
894 }
895
896 fn now(&mut self) -> String {
897 (self.clock)()
898 }
899
900 fn notify(&mut self) {
901 if let Some(hook) = &mut self.on_mutation {
902 hook();
903 }
904 }
905
906 fn repo_rows(&self) -> Result<Vec<RepoRow>> {
909 Ok(omgbase_sync::workspace::list_repos(&self.store)?)
910 }
911
912 fn scope(&self, args: &Json) -> Result<(String, Option<String>)> {
914 let rows = self.repo_rows()?;
915 match arg_str(args, "repo") {
916 None => {
917 let root = rows
918 .iter()
919 .find(|r| r.repo_id == self.default_repo)
920 .and_then(|r| r.root_path.clone());
921 Ok((self.default_repo.clone(), root))
922 }
923 Some(slug) => rows
924 .iter()
925 .find(|r| r.slug == slug)
926 .map(|r| (r.repo_id.clone(), r.root_path.clone()))
927 .ok_or_else(|| {
928 SurfaceError::with_data(
929 "repo_not_found",
930 format!("no repo '{slug}' in this workspace"),
931 json!({ "repo": slug }),
932 )
933 }),
934 }
935 }
936
937 fn require_root(&self, root: Option<&str>) -> Result<()> {
938 if matches!(self.writes, WriteTarget::Fixed(_)) || root.is_some() {
939 Ok(())
940 } else {
941 Err(SurfaceError::new(
942 "repo_not_found",
943 "repo has no filesystem source; mutation disabled",
944 ))
945 }
946 }
947
948 fn with_writes<T>(
950 &mut self,
951 root: Option<&str>,
952 f: impl FnOnce(&mut Store, &mut dyn DocStore) -> Result<T>,
953 ) -> Result<T> {
954 self.require_root(root)?;
955 match &mut self.writes {
956 WriteTarget::Fixed(ds) => f(&mut self.store, ds.as_mut()),
957 WriteTarget::Derived => {
958 let mut fs = FsDocStore::new(root.expect("checked by require_root"));
959 f(&mut self.store, &mut fs)
960 }
961 }
962 }
963
964 fn locate_doc(&self, repo_id: &str, tool: &str, args: &Json) -> Result<String> {
976 let (id_key, path_key) = ("id", "path");
977 let schema = &tool_spec(tool).expect("a catalog tool").input_schema;
978 let id = arg_str(args, id_key);
979 let path = arg_str(args, path_key);
980 if id.is_none() && path.is_none() {
981 return Err(argument_error(
982 tool,
983 &format!("pass {id_key} or {path_key}"),
984 schema,
985 ));
986 }
987 let mut given = Map::new();
988 if let Some(v) = id {
989 given.insert(id_key.to_owned(), json!(v));
990 }
991 if let Some(v) = path {
992 given.insert(path_key.to_owned(), json!(v));
993 }
994 let missing = |value: &str| {
995 SurfaceError::with_data(
996 "doc_missing",
997 format!(
998 "no document with {id_key} or {path_key} {}",
999 Json::String(value.to_owned())
1000 ),
1001 Json::Object(given.clone()),
1002 )
1003 };
1004 let conn = self.store.conn();
1005 let by_id = match id {
1006 Some(v) => {
1007 let info = read::find_doc_by_id(conn, v)?.filter(|i| i.repo_id == repo_id);
1008 Some(info.ok_or_else(|| missing(v))?.doc_id)
1009 }
1010 None => None,
1011 };
1012 let by_path = match path {
1013 Some(v) => Some(
1014 read::find_doc_by_path(conn, repo_id, v)?
1015 .ok_or_else(|| missing(v))?
1016 .doc_id,
1017 ),
1018 None => None,
1019 };
1020 if let (Some(a), Some(b)) = (&by_id, &by_path)
1021 && a != b
1022 {
1023 return Err(argument_error(
1024 tool,
1025 &format!(
1026 "{id_key} {} and {path_key} {} name different documents",
1027 Json::String(id.unwrap_or_default().to_owned()),
1028 Json::String(path.unwrap_or_default().to_owned())
1029 ),
1030 schema,
1031 ));
1032 }
1033 Ok(by_id.or(by_path).expect("one locator given"))
1034 }
1035
1036 fn require_rankable(&self, repo_id: &str, semantic: bool) -> Result<()> {
1043 let Some(provider) = self.provider.as_deref().filter(|_| semantic) else {
1044 return Ok(());
1045 };
1046 let model = provider.model();
1047 let any: Option<i64> = self
1048 .store
1049 .conn()
1050 .query_row(
1051 "SELECT 1 WHERE EXISTS (SELECT 1 FROM doc_embeddings de JOIN docs d ON d.doc_id = de.doc_id WHERE d.repo_id = ?1 AND de.model = ?2) OR EXISTS (SELECT 1 FROM embeddings WHERE model = ?2)",
1052 params![repo_id, model],
1053 |r| r.get(0),
1054 )
1055 .optional()?;
1056 if any.is_some() {
1057 return Ok(());
1058 }
1059 let who = match &self.provider_name {
1060 Some(name) => format!("provider {} (model {model})", Json::String(name.clone())),
1061 None => format!("model {model}"),
1062 };
1063 Err(SurfaceError::with_data(
1064 "provider_unavailable",
1065 format!(
1066 "semantic(...) cannot rank: {who} is configured but nothing in this repository is embedded under it — run omg embed drain"
1067 ),
1068 json!({ "provider": self.provider_name, "model": model }),
1069 ))
1070 }
1071
1072 fn doc_of_block(&self, block_id: &str) -> Result<String> {
1074 self.doc_id_of_block(block_id)?
1075 .ok_or_else(|| block_missing(block_id))
1076 }
1077
1078 fn resolve_section_heading(
1086 &self,
1087 doc_id: &str,
1088 doc_path: &str,
1089 section: &str,
1090 ) -> Result<String> {
1091 let conn = self.store.conn();
1092 let needle = if section.trim_start_matches([' ', '\t']).starts_with('#') {
1093 normalize_visible_text(section, BlockKind::Heading, 0)
1094 } else {
1095 normalize_text(section)
1096 };
1097 let mut stmt = conn.prepare(
1098 "SELECT block_id, doc_id, text FROM blocks WHERE doc_id = ?1 AND type = 'heading' AND deleted_commit IS NULL",
1099 )?;
1100 let rows: Vec<(String, String, String)> = stmt
1101 .query_map(params![doc_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
1102 .collect::<std::result::Result<_, _>>()?;
1103 let matches: Vec<&(String, String, String)> = rows
1104 .iter()
1105 .filter(|(_, _, text)| normalize_text(text) == needle)
1106 .collect();
1107 let shown = reference_path(doc_path);
1108 match matches.len() {
1109 0 => Err(SurfaceError::with_data(
1110 "parent_missing",
1111 format!("no heading {} in {shown}", Json::String(section.to_owned())),
1112 json!({ "section": section, "path": shown }),
1113 )),
1114 1 => Ok(matches[0].0.clone()),
1115 n => Err(SurfaceError::with_data(
1116 "ambiguous_heading",
1117 format!(
1118 "heading {} matches {n} headings in {shown}; pass the heading's id to blocks_append",
1119 Json::String(section.to_owned())
1120 ),
1121 json!({
1122 "section": section,
1123 "path": shown,
1124 "candidates": matches.iter().map(|(b, d, _)| json!({ "id": b, "doc_id": d })).collect::<Vec<_>>(),
1125 }),
1126 )),
1127 }
1128 }
1129
1130 fn block_of_node(&self, node_id: &str) -> Result<(String, String)> {
1134 let node: Option<(String, Option<String>)> = self
1135 .store
1136 .conn()
1137 .query_row(
1138 "SELECT doc_id, block_id FROM nodes WHERE node_id = ?1",
1139 params![node_id],
1140 |r| Ok((r.get(0)?, r.get(1)?)),
1141 )
1142 .optional()?;
1143 let Some((doc_id, block_id)) = node else {
1144 return Err(SurfaceError::with_data(
1145 "block_missing",
1146 format!("no node with id {}", Json::String(node_id.to_owned())),
1147 json!({ "id": node_id }),
1148 ));
1149 };
1150 let live = match &block_id {
1151 Some(b) => self.doc_id_of_block(b)?.is_some(),
1152 None => false,
1153 };
1154 match block_id {
1155 Some(b) if live => Ok((doc_id, b)),
1156 _ => Err(node_has_no_block(node_id, &doc_id)),
1157 }
1158 }
1159
1160 fn resolve_block_ref(&self, repo_id: &str, r: &str) -> Result<String> {
1162 match read::resolve_ref(self.store.conn(), repo_id, r)? {
1163 Some(ResolvedRef::Block { block_id, .. }) => Ok(block_id),
1164 _ => Err(block_missing(r)),
1165 }
1166 }
1167
1168 fn resolve_parent_ref(&self, repo_id: &str, r: &str) -> Result<(Parent, String)> {
1170 match read::resolve_ref(self.store.conn(), repo_id, r)? {
1171 Some(ResolvedRef::Block { doc_id, block_id }) => Ok((Parent::Block(block_id), doc_id)),
1172 Some(ResolvedRef::Document { doc_id }) => Ok((Parent::Doc, doc_id)),
1173 None => Err(SurfaceError::with_data(
1174 "block_missing",
1175 format!(
1176 "no block or document with id or path {}",
1177 Json::String(r.to_owned())
1178 ),
1179 json!({ "ref": r }),
1180 )),
1181 }
1182 }
1183
1184 fn doc_id_of_block(&self, block_id: &str) -> Result<Option<String>> {
1185 Ok(self
1186 .store
1187 .conn()
1188 .query_row(
1189 "SELECT doc_id FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
1190 params![block_id],
1191 |r| r.get(0),
1192 )
1193 .optional()?)
1194 }
1195
1196 fn pin_hash(&self, block_id: &str) -> Result<Option<String>> {
1198 let h: Option<Vec<u8>> = self
1199 .store
1200 .conn()
1201 .query_row(
1202 "SELECT raw_hash FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
1203 params![block_id],
1204 |r| r.get(0),
1205 )
1206 .optional()?;
1207 Ok(h.map(|h| omgbase_format::hash::hex(&h)))
1208 }
1209
1210 fn resolve_at(&self, repo_id: &str, at: Option<&Json>) -> Result<At> {
1212 match at {
1213 None | Some(Json::Null) => Ok(At::End),
1214 Some(Json::String(s)) if s == "start" => Ok(At::Start),
1215 Some(Json::String(s)) if s == "end" => Ok(At::End),
1216 Some(Json::Object(o)) => {
1217 if let Some(b) = o.get("before").and_then(Json::as_str) {
1218 return Ok(At::Before(self.resolve_block_ref(repo_id, b)?));
1219 }
1220 if let Some(a) = o.get("after").and_then(Json::as_str) {
1221 return Ok(At::After(self.resolve_block_ref(repo_id, a)?));
1222 }
1223 Err(bad_args(
1224 "`at` must be \"start\", \"end\", {before} or {after}",
1225 ))
1226 }
1227 Some(_) => Err(bad_args(
1228 "`at` must be \"start\", \"end\", {before} or {after}",
1229 )),
1230 }
1231 }
1232
1233 fn apply_ops(
1236 &mut self,
1237 repo_id: &str,
1238 root: Option<&str>,
1239 ops: Vec<Op>,
1240 reason: &str,
1241 dry_run: bool,
1242 ) -> Result<ApplyResult> {
1243 let ts = self.now();
1244 let req = ApplyRequest {
1245 repo_id: repo_id.to_owned(),
1246 ops,
1247 origin: ApplyOrigin::new(&self.actor, Some(reason)),
1248 dry_run,
1249 set_frontmatter: Vec::new(),
1250 };
1251 let res = self.with_writes(root, |store, ds| Ok(store.apply(&req, ds, &ts)?))?;
1252 if !dry_run {
1253 self.notify();
1254 }
1255 Ok(res)
1256 }
1257
1258 fn doc_ctx(&mut self, repo_id: &str) -> DocOpContext {
1259 DocOpContext {
1260 repo_id: repo_id.to_owned(),
1261 actor: Some(self.actor.clone()),
1262 ts: self.now(),
1263 }
1264 }
1265
1266 #[allow(clippy::too_many_lines)]
1270 pub fn call_result(&mut self, name: &str, args: &Json) -> Result<Json> {
1271 let spec =
1274 tool_spec(name).ok_or_else(|| SurfaceError::other(format!("unknown tool {name}")))?;
1275 validate_arguments(name, &spec.input_schema, args)?;
1276 match name {
1277 "docs_outline" => {
1278 let (repo, _) = self.scope(args)?;
1279 let doc_id = self.locate_doc(&repo, name, args)?;
1280 let skeleton = match arg_str(args, "resolution") {
1281 None | Some("outline") => false,
1282 Some("skeleton") => true,
1283 Some(_) => return Err(bad_args("`resolution` must be skeleton or outline")),
1284 };
1285 read::docs_outline(
1286 &self.store,
1287 &doc_id,
1288 skeleton,
1289 arg_i64(args, "depth")?,
1290 arg_usize(args, "budget_tokens")?,
1291 )
1292 }
1293 "docs_read" => {
1294 let (repo, _) = self.scope(args)?;
1295 let doc_id = self.locate_doc(&repo, name, args)?;
1296 read::docs_read(
1297 &self.store,
1298 &doc_id,
1299 arg_bool(args, "include_ids")?.unwrap_or(false),
1300 )?
1301 .ok_or_else(|| {
1302 SurfaceError::with_data(
1303 "doc_missing",
1304 format!(
1305 "no document with id or path {}",
1306 Json::String(
1307 arg_str(args, "id")
1308 .or(arg_str(args, "path"))
1309 .unwrap_or_default()
1310 .to_owned()
1311 )
1312 ),
1313 json!({ "id": doc_id }),
1314 )
1315 })
1316 }
1317 "docs_read_many" => {
1318 let (repo, _) = self.scope(args)?;
1319 let refs = arg_strings(args, "docs")?;
1320 read::docs_read_many(
1321 &self.store,
1322 &repo,
1323 &refs,
1324 arg_bool(args, "include_ids")?.unwrap_or(false),
1325 arg_usize(args, "budget_tokens")?,
1326 )
1327 }
1328 "blocks_read" => {
1329 self.scope(args)?;
1330 let id = arg_string(args, "id")?;
1331 if id.starts_with("n_") {
1336 return Err(SurfaceError::with_data(
1337 "block_missing",
1338 format!(
1339 "no block with id {} (an n_ id names a node — nodes_block_read reads its block)",
1340 Json::String(id.clone())
1341 ),
1342 json!({ "id": id }),
1343 ));
1344 }
1345 let doc_id = self.doc_of_block(&id)?;
1346 read::blocks_read(
1347 &self.store,
1348 &doc_id,
1349 &id,
1350 resolution_arg(args, Resolution::Full)?,
1351 )?
1352 .ok_or_else(|| block_missing(&id))
1353 }
1354 "nodes_block_read" => {
1355 self.scope(args)?;
1356 let id = arg_string(args, "id")?;
1357 let (doc_id, block_id) = self.block_of_node(&id)?;
1362 read::blocks_read(
1363 &self.store,
1364 &doc_id,
1365 &block_id,
1366 resolution_arg(args, Resolution::Full)?,
1367 )?
1368 .ok_or_else(|| node_has_no_block(&id, &doc_id))
1369 }
1370 "blocks_read_many" => {
1371 self.scope(args)?;
1372 let ids = arg_strings(args, "ids")?;
1373 read::blocks_read_many(
1374 &self.store,
1375 None,
1376 &ids,
1377 resolution_arg(args, Resolution::Text)?,
1378 arg_usize(args, "budget_tokens")?,
1379 )
1380 }
1381 "docs_tree" => {
1382 let (repo, _) = self.scope(args)?;
1383 read::docs_tree(
1384 &self.store,
1385 &repo,
1386 arg_str(args, "path"),
1387 arg_i64(args, "depth")?,
1388 arg_i64(args, "limit")?,
1389 arg_str(args, "cursor"),
1390 arg_usize(args, "budget_tokens")?,
1391 )
1392 }
1393 "docs_list" => {
1394 let (repo, _) = self.scope(args)?;
1395 read::docs_list(
1396 &self.store,
1397 &repo,
1398 arg_str(args, "path_glob"),
1399 arg_i64(args, "limit")?,
1400 arg_str(args, "cursor"),
1401 arg_usize(args, "budget_tokens")?,
1402 )
1403 }
1404 "query_syntax" => Ok(json!({ "syntax": QUERY_SYNTAX })),
1405 "query" => {
1406 let (repo, _) = self.scope(args)?;
1407 let source = arg_string(args, "query")?;
1408 if self.provider.is_none()
1411 && !crate::query::collect_semantic_phrases(&source).is_empty()
1412 {
1413 return Err(provider_unavailable());
1414 }
1415 let opts = QueryOptions {
1416 limit: arg_usize(args, "limit")?,
1417 cursor: arg_str(args, "cursor"),
1418 provider: self.provider.as_deref(),
1419 in_memory: false,
1420 };
1421 self.require_rankable(
1422 &repo,
1423 !crate::query::collect_semantic_phrases(&source).is_empty(),
1424 )?;
1425 Ok(query(&self.store, &repo, &source, opts)?.to_json())
1426 }
1427 "graph" => {
1428 let (repo, _) = self.scope(args)?;
1429 let g = GraphArgs {
1430 roots: arg_strings(args, "roots")?,
1431 degrees: arg_i64(args, "degrees")?,
1432 direction: arg_str(args, "direction").map(str::to_owned),
1433 predicate: arg_str(args, "predicate").map(str::to_owned),
1434 select: if args.get("select").is_some() {
1435 arg_strings(args, "select")?
1436 } else {
1437 Vec::new()
1438 },
1439 max_documents: arg_i64(args, "max_documents")?,
1440 };
1441 if self.provider.is_none()
1442 && g.select.iter().any(|s| {
1443 !crate::query::collect_semantic_phrases(&format!("from docs select x: {s}"))
1444 .is_empty()
1445 })
1446 {
1447 return Err(provider_unavailable());
1448 }
1449 self.require_rankable(
1450 &repo,
1451 g.select.iter().any(|s| {
1452 !crate::query::collect_semantic_phrases(&format!("from docs select x: {s}"))
1453 .is_empty()
1454 }),
1455 )?;
1456 graph_neighborhood(&self.store, &repo, &g, self.provider.as_deref())
1457 }
1458 "blocks_search" => {
1459 let (repo, _) = self.scope(args)?;
1460 let q = arg_string(args, "q")?;
1461 let res =
1462 self.store
1463 .text_search(&repo, &q, arg_usize(args, "limit")?.unwrap_or(50))?;
1464 Ok(json!({
1465 "hits": res.hits.iter().map(|h| json!({
1466 "id": h.block_id, "doc_id": h.doc_id, "path": reference_path(&h.path), "type": h.block_type, "text": h.text, "score": h.score,
1467 })).collect::<Vec<_>>(),
1468 "truncated": res.truncated,
1469 }))
1470 }
1471 "blocks_resolve" => {
1472 let (repo, _) = self.scope(args)?;
1473 let q = arg_string(args, "query")?;
1474 let vector = match &self.provider {
1475 Some(p) => Some(QueryVector {
1476 model: p.model().to_owned(),
1477 vec: p
1478 .embed_query(&q)
1479 .map_err(|e| SurfaceError::new(e.code(), e.to_string()))?,
1480 }),
1481 None => None,
1482 };
1483 let hits = self
1484 .store
1485 .resolve(&repo, &q, vector, arg_usize(args, "limit")?)?;
1486 let locator = |l: &str| match l.find('#') {
1489 Some(i) => format!("{}{}", reference_path(&l[..i]), &l[i..]),
1490 None => l.to_owned(),
1491 };
1492 Ok(Json::Array(hits.iter().map(|h| json!({
1493 "id": h.id, "locator": locator(&h.locator), "preview": h.preview, "evidence": evidence_json(&h.evidence),
1494 })).collect()))
1495 }
1496 "apply" => {
1497 let (repo, root) = self.scope(args)?;
1498 self.require_root(root.as_deref())?;
1499 let ops = args
1500 .get("ops")
1501 .and_then(Json::as_array)
1502 .ok_or_else(|| bad_args("`ops` must be an array"))?;
1503 let ops: Vec<Op> = ops
1504 .iter()
1505 .map(|o| Op::from_json(o).map_err(bad_args))
1506 .collect::<Result<_>>()?;
1507 let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1508 let reason = arg_str(args, "reason").map(str::to_owned);
1509 let ts = self.now();
1510 let req = ApplyRequest {
1511 repo_id: repo.clone(),
1512 ops,
1513 origin: ApplyOrigin::new(&self.actor, reason.as_deref()),
1514 dry_run: dry,
1515 set_frontmatter: Vec::new(),
1516 };
1517 let res =
1518 self.with_writes(root.as_deref(), |store, ds| Ok(store.apply(&req, ds, &ts)?))?;
1519 if !dry {
1520 self.notify();
1521 }
1522 Ok(apply_json(&res))
1523 }
1524 "blocks_insert" => {
1525 let (repo, root) = self.scope(args)?;
1526 self.require_root(root.as_deref())?;
1527 let (parent, doc_id) = self.resolve_parent_ref(&repo, &arg_string(args, "to")?)?;
1528 let at = self.resolve_at(&repo, args.get("at"))?;
1529 let doc = if parent == Parent::Doc {
1530 Some(doc_id)
1531 } else {
1532 None
1533 };
1534 let ops = vec![Op::Insert {
1535 doc,
1536 to: To { parent, at },
1537 markdown: arg_string(args, "markdown")?,
1538 expect: arg_parent_expect(args)?,
1539 }];
1540 Ok(apply_json(&self.apply_ops(
1541 &repo,
1542 root.as_deref(),
1543 ops,
1544 "blocks_insert",
1545 arg_bool(args, "dry_run")?.unwrap_or(false),
1546 )?))
1547 }
1548 "blocks_update" => {
1549 let (repo, root) = self.scope(args)?;
1550 self.require_root(root.as_deref())?;
1551 let block = self.resolve_block_ref(&repo, &arg_string(args, "id")?)?;
1552 let expect = match arg_expect(args)? {
1553 Some(e) => Some(e),
1554 None => self.pin_hash(&block)?.map(Expect::content),
1555 };
1556 let checked = arg_bool(args, "checked")?;
1557 let extra = arg_object(args, "attrs")?;
1558 let attrs = if checked.is_some() || extra.is_some() {
1559 let mut a = Map::new();
1560 if let Some(c) = checked {
1561 a.insert("checked".to_owned(), json!(c));
1562 }
1563 if let Some(x) = extra {
1564 for (k, v) in x {
1565 a.insert(k.clone(), v.clone());
1566 }
1567 }
1568 Some(a)
1569 } else {
1570 None
1571 };
1572 let ops = vec![Op::Update {
1573 block: block.clone(),
1574 markdown: arg_str(args, "markdown").map(str::to_owned),
1575 attrs,
1576 expect,
1577 trivia: None,
1578 child_ids: None,
1579 }];
1580 let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1581 let res = self.apply_ops(&repo, root.as_deref(), ops, "blocks_update", dry)?;
1582 let ids: Vec<String> = res
1583 .results
1584 .first()
1585 .map_or_else(|| vec![block.clone()], |r| r.ids.clone());
1586 let mut m = Map::new();
1587 m.insert(
1588 "id".to_owned(),
1589 json!(ids.first().cloned().unwrap_or(block)),
1590 );
1591 m.insert("ids".to_owned(), json!(ids));
1592 Ok(merge(m, apply_json(&res)))
1593 }
1594 "blocks_move" => {
1595 let (repo, root) = self.scope(args)?;
1596 self.require_root(root.as_deref())?;
1597 let blocks: Vec<String> = arg_strings(args, "blocks")?
1598 .iter()
1599 .map(|b| self.resolve_block_ref(&repo, b))
1600 .collect::<Result<_>>()?;
1601 let to_ref = arg_string(args, "to")?;
1602 let (parent, doc_id) = self.resolve_parent_ref(&repo, &to_ref)?;
1603 if parent == Parent::Doc {
1604 if let Some(first) = blocks.first() {
1605 if self.doc_id_of_block(first)? != Some(doc_id) {
1606 return Err(SurfaceError::with_data(
1607 "target_missing",
1608 format!(
1609 "blocks_move cannot target another document's root ({to_ref}); anchor on a block in that document with at.before/at.after"
1610 ),
1611 json!({ "to": to_ref }),
1612 ));
1613 }
1614 }
1615 }
1616 let at = self.resolve_at(&repo, args.get("at"))?;
1617 let ops = vec![Op::Move {
1618 blocks,
1619 to: To { parent, at },
1620 expect: arg_parent_expect(args)?,
1621 }];
1622 Ok(apply_json(&self.apply_ops(
1623 &repo,
1624 root.as_deref(),
1625 ops,
1626 "blocks_move",
1627 arg_bool(args, "dry_run")?.unwrap_or(false),
1628 )?))
1629 }
1630 "blocks_remove" => {
1631 let (repo, root) = self.scope(args)?;
1632 self.require_root(root.as_deref())?;
1633 let blocks: Vec<String> = arg_strings(args, "blocks")?
1634 .iter()
1635 .map(|b| self.resolve_block_ref(&repo, b))
1636 .collect::<Result<_>>()?;
1637 let ops = vec![Op::Remove {
1638 blocks,
1639 expect: None,
1640 }];
1641 Ok(apply_json(&self.apply_ops(
1642 &repo,
1643 root.as_deref(),
1644 ops,
1645 "blocks_remove",
1646 arg_bool(args, "dry_run")?.unwrap_or(false),
1647 )?))
1648 }
1649 "blocks_split" => {
1650 let (repo, root) = self.scope(args)?;
1651 self.require_root(root.as_deref())?;
1652 let block = self.resolve_block_ref(&repo, &arg_string(args, "id")?)?;
1653 let at: Vec<usize> = args
1654 .get("at")
1655 .and_then(Json::as_array)
1656 .ok_or_else(|| bad_args("`at` must be an array of byte offsets"))?
1657 .iter()
1658 .map(|v| {
1659 v.as_u64()
1660 .map(|n| usize::try_from(n).unwrap_or(usize::MAX))
1661 .ok_or_else(|| bad_args("`at` must be an array of byte offsets"))
1662 })
1663 .collect::<Result<_>>()?;
1664 let expect = Some(Expect::content(self.pin_hash(&block)?.unwrap_or_default()));
1666 let ops = vec![Op::Split { block, at, expect }];
1667 Ok(apply_json(&self.apply_ops(
1668 &repo,
1669 root.as_deref(),
1670 ops,
1671 "blocks_split",
1672 arg_bool(args, "dry_run")?.unwrap_or(false),
1673 )?))
1674 }
1675 "blocks_merge" => {
1676 let (repo, root) = self.scope(args)?;
1677 self.require_root(root.as_deref())?;
1678 let blocks: Vec<String> = arg_strings(args, "blocks")?
1679 .iter()
1680 .map(|b| self.resolve_block_ref(&repo, b))
1681 .collect::<Result<_>>()?;
1682 let ops = vec![Op::Merge {
1683 blocks,
1684 separator: arg_str(args, "separator").map(str::to_owned),
1685 expect: None,
1686 }];
1687 Ok(apply_json(&self.apply_ops(
1688 &repo,
1689 root.as_deref(),
1690 ops,
1691 "blocks_merge",
1692 arg_bool(args, "dry_run")?.unwrap_or(false),
1693 )?))
1694 }
1695 "blocks_check" => {
1696 let (repo, root) = self.scope(args)?;
1697 self.require_root(root.as_deref())?;
1698 let blocks: Vec<String> = arg_strings(args, "ids")?
1699 .iter()
1700 .map(|b| self.resolve_block_ref(&repo, b))
1701 .collect::<Result<_>>()?;
1702 let ops = if arg_bool(args, "checked")?.unwrap_or(true) {
1703 self.store.tasks_complete(&blocks)?
1704 } else {
1705 let mut ops = Vec::with_capacity(blocks.len());
1706 for b in &blocks {
1707 let mut a = Map::new();
1708 a.insert("checked".to_owned(), json!(false));
1709 ops.push(Op::Update {
1710 block: b.clone(),
1711 markdown: None,
1712 attrs: Some(a),
1713 expect: self.pin_hash(b)?.map(Expect::content),
1714 trivia: None,
1715 child_ids: None,
1716 });
1717 }
1718 ops
1719 };
1720 Ok(apply_json(&self.apply_ops(
1721 &repo,
1722 root.as_deref(),
1723 ops,
1724 "blocks_check",
1725 arg_bool(args, "dry_run")?.unwrap_or(false),
1726 )?))
1727 }
1728 "nodes_set" => {
1729 let (repo, root) = self.scope(args)?;
1730 self.require_root(root.as_deref())?;
1731 let node_id = arg_string(args, "id")?;
1732 let known: Option<i64> = self
1733 .store
1734 .conn()
1735 .query_row(
1736 "SELECT 1 FROM nodes WHERE node_id = ?1 AND repo_id = ?2",
1737 params![node_id, repo],
1738 |r| r.get(0),
1739 )
1740 .optional()?;
1741 if known.is_none() {
1742 return Err(SurfaceError::with_data(
1743 "block_missing",
1744 format!("no node with id {}", Json::String(node_id.clone())),
1745 json!({ "id": node_id }),
1746 ));
1747 }
1748 let ops = self.store.node_set(
1749 &node_id,
1750 &arg_string(args, "prop")?,
1751 &arg_string(args, "value")?,
1752 )?;
1753 Ok(apply_json(&self.apply_ops(
1754 &repo,
1755 root.as_deref(),
1756 ops,
1757 "nodes_set",
1758 arg_bool(args, "dry_run")?.unwrap_or(false),
1759 )?))
1760 }
1761 "blocks_append" => {
1762 let (repo, root) = self.scope(args)?;
1763 self.require_root(root.as_deref())?;
1764 let given = arg_string(args, "id")?;
1765 let block = self.resolve_block_ref(&repo, &given)?;
1766 let text = arg_string(args, "text")?;
1767 let row: Option<(String, Option<String>, i64)> = self
1771 .store
1772 .conn()
1773 .query_row(
1774 "SELECT b.type, d.format, (SELECT count(*) FROM blocks c WHERE c.parent_block = b.block_id AND c.deleted_commit IS NULL) FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ?1 AND b.deleted_commit IS NULL",
1775 params![block],
1776 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1777 )
1778 .optional()?;
1779 let Some((kind, format, children)) = row else {
1780 return Err(block_missing(&given));
1781 };
1782 let container = if format.as_deref().unwrap_or("markdown") == "markdown" {
1783 kind.parse::<BlockKind>().is_ok_and(|k| k.is_container())
1784 } else {
1785 children > 0
1786 };
1787 let ops = if kind == "heading" {
1788 Store::sections_append(&block, &text)
1789 } else if container {
1790 vec![Op::Insert {
1791 doc: None,
1792 to: To {
1793 parent: Parent::Block(block.clone()),
1794 at: At::End,
1795 },
1796 markdown: text,
1797 expect: None,
1798 }]
1799 } else {
1800 return Err(SurfaceError::with_data(
1801 "block_not_container",
1802 format!(
1803 "block {block} is a {kind}, not a heading or a container; append means into — insert after it with blocks_insert {{ to: <its parent>, at: {{ after: {} }} }}",
1804 Json::String(block.clone())
1805 ),
1806 json!({ "id": block, "type": kind }),
1807 ));
1808 };
1809 Ok(apply_json(&self.apply_ops(
1810 &repo,
1811 root.as_deref(),
1812 ops,
1813 "blocks_append",
1814 arg_bool(args, "dry_run")?.unwrap_or(false),
1815 )?))
1816 }
1817 "docs_append" => {
1818 let (repo, root) = self.scope(args)?;
1819 self.require_root(root.as_deref())?;
1820 let doc_id = self.locate_doc(&repo, name, args)?;
1821 let text = arg_string(args, "text")?;
1822 let ops = match arg_str(args, "section") {
1823 Some(section) => {
1824 let info = read::find_doc_by_id(self.store.conn(), &doc_id)?
1825 .ok_or_else(|| block_missing(&doc_id))?;
1826 let heading = self.resolve_section_heading(&doc_id, &info.path, section)?;
1827 Store::sections_append(&heading, &text)
1828 }
1829 None => Store::docs_append(&doc_id, &text),
1830 };
1831 Ok(apply_json(&self.apply_ops(
1832 &repo,
1833 root.as_deref(),
1834 ops,
1835 "docs_append",
1836 arg_bool(args, "dry_run")?.unwrap_or(false),
1837 )?))
1838 }
1839 "links_retarget" | "links_repair" => {
1840 let (repo, root) = self.scope(args)?;
1841 self.require_root(root.as_deref())?;
1842 let repairs: Vec<omgbase_store::LinkRepair> = if name == "links_retarget" {
1843 vec![omgbase_store::LinkRepair {
1844 from: arg_string(args, "from_target")?,
1845 to: arg_string(args, "to_target")?,
1846 }]
1847 } else if let Some(list) = args.get("repairs").and_then(Json::as_array) {
1848 list.iter()
1849 .map(|r| {
1850 Ok(omgbase_store::LinkRepair {
1851 from: arg_string(r, "from")?,
1852 to: arg_string(r, "to")?,
1853 })
1854 })
1855 .collect::<Result<_>>()?
1856 } else if let (Some(f), Some(t)) =
1857 (arg_str(args, "from_target"), arg_str(args, "to_target"))
1858 {
1859 vec![omgbase_store::LinkRepair {
1860 from: f.to_owned(),
1861 to: t.to_owned(),
1862 }]
1863 } else {
1864 Vec::new()
1865 };
1866 if repairs.is_empty() {
1867 return Err(SurfaceError::new(
1868 "target_missing",
1869 "links_repair requires `repairs` (array of {from,to}) or a `from_target`+`to_target` pair",
1870 ));
1871 }
1872 let plan = self.store.links_repair(
1873 &repo,
1874 &repairs,
1875 arg_str(args, "path_glob").filter(|g| !g.is_empty()),
1876 )?;
1877 let dry = arg_bool(args, "dry_run")?.unwrap_or(true);
1878 let res = self.apply_ops(&repo, root.as_deref(), plan.ops.clone(), name, dry)?;
1879 let mut m = Map::new();
1880 m.insert(
1881 "hits".to_owned(),
1882 Json::Array(plan.hits.iter().map(|h| json!({ "id": h.block, "path": reference_path(&h.path), "oldRaw": h.old_raw, "newRaw": h.new_raw })).collect()),
1883 );
1884 m.insert(
1885 "pairs".to_owned(),
1886 Json::Array(
1887 plan.pairs
1888 .iter()
1889 .map(|p| json!({ "from": p.from, "to": p.to, "hits": p.hits }))
1890 .collect(),
1891 ),
1892 );
1893 m.insert("applied".to_owned(), json!(!dry));
1894 Ok(merge(m, apply_json(&res)))
1895 }
1896 "links_stale" => {
1897 let (repo, _) = self.scope(args)?;
1898 let glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
1899 if arg_bool(args, "summary")?.unwrap_or(false) {
1900 return links::links_stale_summary(&self.store, &repo, glob);
1901 }
1902 links::links_stale(&self.store, &repo, glob, arg_i64(args, "limit")?)
1903 }
1904 "docs_create" => {
1905 let (repo, root) = self.scope(args)?;
1906 let ctx = self.doc_ctx(&repo);
1907 let path = arg_string(args, "path")?;
1908 let markdown = arg_string(args, "markdown")?;
1909 let fm = arg_object(args, "frontmatter")?.cloned();
1910 let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1911 let res = self.with_writes(root.as_deref(), |store, ds| {
1912 Ok(if dry {
1913 store
1914 .dry_run()
1915 .docs_create(&ctx, ds, &path, &markdown, fm.as_ref())?
1916 } else {
1917 store.docs_create(&ctx, ds, &path, &markdown, fm.as_ref())?
1918 })
1919 })?;
1920 if !dry {
1921 self.notify();
1922 }
1923 Ok(doc_op_json(&res))
1924 }
1925 "docs_move" => {
1926 let (repo, root) = self.scope(args)?;
1927 let doc = self.locate_doc(&repo, name, args)?;
1928 let ctx = self.doc_ctx(&repo);
1929 let to = arg_string(args, "to_path")?;
1930 let retarget = arg_bool(args, "retarget_inbound")?.unwrap_or(true);
1932 let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1933 let res = self.with_writes(root.as_deref(), |store, ds| {
1934 Ok(if dry {
1935 store.dry_run().docs_move(&ctx, ds, &doc, &to, retarget)?
1936 } else {
1937 store.docs_move(&ctx, ds, &doc, &to, retarget)?
1938 })
1939 })?;
1940 if !dry {
1941 self.notify();
1942 }
1943 let mut m = Map::new();
1946 m.insert("id".to_owned(), json!(res.doc_id));
1947 m.insert("path".to_owned(), json!(reference_path(&res.path)));
1948 m.insert("committed".to_owned(), json!(res.committed));
1949 if let Some(diffs) = &res.diffs {
1950 m.insert("diffs".to_owned(), reference_keyed(&diffs_json(diffs)));
1951 }
1952 m.insert(
1956 "dangling".to_owned(),
1957 Json::Array(res.dangling.iter().map(dangling_json).collect()),
1958 );
1959 m.insert(
1960 "retargeted".to_owned(),
1961 json!(
1962 res.retargeted
1963 .as_ref()
1964 .map(|r| json!({ "blocks": r.blocks, "docs": r.docs }))
1965 ),
1966 );
1967 Ok(Json::Object(m))
1968 }
1969 "docs_delete" => {
1970 let (repo, root) = self.scope(args)?;
1971 let doc = self.locate_doc(&repo, name, args)?;
1972 let ctx = self.doc_ctx(&repo);
1973 let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1974 let res = self.with_writes(root.as_deref(), |store, ds| {
1975 Ok(if dry {
1976 store.dry_run().docs_delete(&ctx, ds, &doc)?
1977 } else {
1978 store.docs_delete(&ctx, ds, &doc)?
1979 })
1980 })?;
1981 if !dry {
1982 self.notify();
1983 }
1984 Ok(doc_op_json(&res))
1985 }
1986 "docs_set_meta" => {
1987 let (repo, root) = self.scope(args)?;
1988 let doc = self.locate_doc(&repo, name, args)?;
1989 let ctx = self.doc_ctx(&repo);
1990 let set = arg_object(args, "set")?.cloned();
1991 let unset = if args.get("unset").is_some() {
1992 arg_strings(args, "unset")?
1993 } else {
1994 Vec::new()
1995 };
1996 let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1997 let res = self.with_writes(root.as_deref(), |store, ds| {
1998 Ok(if dry {
1999 store
2000 .dry_run()
2001 .docs_set_meta(&ctx, ds, &doc, set.as_ref(), &unset)?
2002 } else {
2003 store.docs_set_meta(&ctx, ds, &doc, set.as_ref(), &unset)?
2004 })
2005 })?;
2006 if !dry {
2007 self.notify();
2008 }
2009 Ok(doc_op_json(&res))
2010 }
2011 "docs_update" => {
2012 let (repo, root) = self.scope(args)?;
2013 let doc = self.locate_doc(&repo, name, args)?;
2014 let content = arg_string(args, "content")?;
2015 let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
2016 let reason = arg_str(args, "reason").map(str::to_owned);
2017 let origin = ApplyOrigin::new(&self.actor, reason.as_deref());
2018 let config = self.config.clone();
2019 let ts = self.now();
2020 let (opset, result) = self.with_writes(root.as_deref(), |store, ds| {
2021 Ok(store.docs_update(&repo, &doc, &content, &config, &origin, dry, ds, &ts)?)
2022 })?;
2023 if !dry {
2024 self.notify();
2025 }
2026 Ok(json!({
2027 "opset": opset_json(&opset),
2028 "plan": render_opset_plan(&opset),
2029 "result": result.map(|r| apply_json(&r)),
2030 }))
2031 }
2032 "observe" => {
2033 let (repo, _) = self.scope(args)?;
2034 let path = storage_path(&arg_string(args, "path")?).to_owned();
2035 let content = arg_string(args, "content")?;
2036 let ts = self.now();
2037 let config = self.config.clone();
2038 let out = self
2039 .store
2040 .observe_one(&repo, &path, &content, &ts, &config)?;
2041 self.store.sweep_pool(&ts)?;
2042 self.notify();
2043 Ok(observe_json(&out))
2044 }
2045 "observe_many" => {
2046 let (repo, _) = self.scope(args)?;
2047 let files = args
2048 .get("files")
2049 .and_then(Json::as_array)
2050 .ok_or_else(|| bad_args("`files` must be an array of {path, content}"))?;
2051 let items: Vec<omgbase_store::BatchItem> = files
2052 .iter()
2053 .map(|f| {
2054 Ok(omgbase_store::BatchItem::observed(
2055 storage_path(&arg_string(f, "path")?),
2056 &arg_string(f, "content")?,
2057 ))
2058 })
2059 .collect::<Result<_>>()?;
2060 let ts = self.now();
2061 let config = self.config.clone();
2062 let outcomes = self.store.observe_batch(&repo, &items, &ts, &config)?;
2063 self.store.sweep_pool(&ts)?;
2064 self.notify();
2065 let mut out = Vec::with_capacity(outcomes.len());
2066 for o in &outcomes {
2067 match o.as_observed() {
2068 Some(obs) => out.push(observe_json(obs)),
2069 None => {
2070 return Err(SurfaceError::other(format!(
2071 "observe_many: unexpected outcome for {}",
2072 o.path()
2073 )));
2074 }
2075 }
2076 }
2077 Ok(Json::Array(out))
2078 }
2079 "observe_delete" => {
2080 let (repo, _) = self.scope(args)?;
2081 let path = storage_path(&arg_string(args, "path")?).to_owned();
2082 let ts = self.now();
2083 let out = self.store.observe_delete(&repo, &path, &ts)?;
2084 self.notify();
2085 Ok(
2086 json!({ "id": out.doc_id, "path": reference_path(&out.path), "deleted": out.deleted() }),
2087 )
2088 }
2089 "blocks_history" => history::blocks_history(
2090 &self.store,
2091 &arg_string(args, "id")?,
2092 arg_i64(args, "limit")?,
2093 ),
2094 "docs_diff" => {
2095 let (repo, _) = self.scope(args)?;
2096 let doc_id = self.locate_doc(&repo, name, args)?;
2097 history::diff_blocks(
2098 &self.store,
2099 &doc_id,
2100 &arg_string(args, "from_rev")?,
2101 &arg_string(args, "to_rev")?,
2102 )
2103 }
2104 "docs_diff_unified" => {
2105 let (repo, _) = self.scope(args)?;
2106 let doc_ref = arg_str(args, "id")
2107 .or(arg_str(args, "path"))
2108 .unwrap_or_default()
2109 .to_owned();
2110 let doc_id = self.locate_doc(&repo, name, args)?;
2111 let revs = history::recent_revs(self.store.conn(), &doc_id)?;
2112 let to_rev = arg_str(args, "to_rev")
2113 .map(str::to_owned)
2114 .or_else(|| revs.first().cloned());
2115 let from_rev = arg_str(args, "from_rev")
2116 .map(str::to_owned)
2117 .or_else(|| revs.get(1).cloned())
2118 .or_else(|| revs.first().cloned());
2119 let (Some(from), Some(to)) = (from_rev, to_rev) else {
2120 return Err(SurfaceError::new(
2121 "target_missing",
2122 format!("no revisions to diff for {}", Json::String(doc_ref)),
2123 ));
2124 };
2125 let path: Option<String> = self
2126 .store
2127 .conn()
2128 .query_row(
2129 "SELECT path FROM docs WHERE doc_id = ?1",
2130 params![doc_id],
2131 |r| r.get(0),
2132 )
2133 .optional()?;
2134 let diff = history::diff_unified_text(&self.store, &doc_id, &from, &to)?;
2135 Ok(
2136 json!({ "id": doc_id, "path": path.as_deref().map_or_else(String::new, reference_path), "from": from, "to": to, "diff": diff }),
2137 )
2138 }
2139 "docs_read_at" => {
2140 let (repo, _) = self.scope(args)?;
2141 let doc_id = self.locate_doc(&repo, name, args)?;
2142 let rev = arg_string(args, "rev")?;
2143 read::docs_read_at(&self.store, &doc_id, &rev)?.ok_or_else(|| {
2144 SurfaceError::with_data(
2145 "target_missing",
2146 format!(
2147 "no revision {} for document {doc_id}",
2148 Json::String(rev.clone())
2149 ),
2150 json!({ "doc": doc_id, "rev": rev }),
2151 )
2152 })
2153 }
2154 "docs_history" => {
2155 let (repo, _) = self.scope(args)?;
2156 let glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
2157 let id = arg_str(args, "id");
2158 let path = arg_str(args, "path");
2159 if glob.is_none() && id.is_none() && path.is_none() {
2160 return Err(SurfaceError::new(
2161 "target_missing",
2162 "docs_history requires one of path_glob, id or path",
2163 ));
2164 }
2165 let include_deleted = arg_bool(args, "include_deleted")?.unwrap_or(false);
2166 let doc: Option<String> = if id.is_some() || path.is_some() {
2169 let tomb: Option<String> = if include_deleted {
2170 match (id, path) {
2171 (Some(i), _) => self
2172 .store
2173 .conn()
2174 .query_row(
2175 "SELECT doc_id FROM docs WHERE doc_id = ?1 AND repo_id = ?2",
2176 params![i, repo],
2177 |r| r.get(0),
2178 )
2179 .optional()?,
2180 (None, Some(p)) => self
2181 .store
2182 .conn()
2183 .query_row(
2184 "SELECT doc_id FROM docs WHERE repo_id = ?1 AND path = ?2",
2185 params![repo, storage_path(p)],
2186 |r| r.get(0),
2187 )
2188 .optional()?,
2189 (None, None) => None,
2190 }
2191 } else {
2192 None
2193 };
2194 Some(match tomb {
2195 Some(d) => d,
2196 None => self.locate_doc(&repo, name, args)?,
2197 })
2198 } else {
2199 None
2200 };
2201 history::docs_history(
2202 &self.store,
2203 &repo,
2204 glob,
2205 doc.as_deref(),
2206 include_deleted,
2207 arg_i64(args, "limit")?,
2208 )
2209 }
2210 "changes_since" => {
2211 let (repo, _) = self.scope(args)?;
2212 let page = self.store.changes_since(
2213 &repo,
2214 arg_i64(args, "cursor")?.unwrap_or(0),
2215 arg_usize(args, "limit")?.unwrap_or(50),
2216 arg_str(args, "origin").filter(|o| !o.is_empty()),
2217 )?;
2218 Ok(root_changes_page(page.to_json()))
2219 }
2220 "repos_status" => {
2221 let (repo, root) = self.scope(args)?;
2222 let fs = RealFileSystem;
2223 let disk = root
2224 .as_deref()
2225 .map(|r| (&fs as &dyn omgbase_sync::FileSystem, Path::new(r)));
2226 Ok(omgbase_sync::repos_status(&self.store, &repo, disk)?.to_json())
2227 }
2228 "sync_status" => {
2229 let (repo, root) = self.scope(args)?;
2230 let fs = RealFileSystem;
2231 let disk = root
2232 .as_deref()
2233 .map(|r| (&fs as &dyn omgbase_sync::FileSystem, Path::new(r)));
2234 Ok(omgbase_sync::sync_status(&self.store, &repo, disk)?.to_json())
2235 }
2236 "repos" => {
2237 let rows = self.repo_rows()?;
2238 Ok(json!({
2239 "repos": rows.iter().map(|r| json!({ "slug": r.slug, "hasSource": r.root_path.is_some() })).collect::<Vec<_>>(),
2240 }))
2241 }
2242 "version" => version_info(Some(&self.store), &self.build_info),
2243 other => Err(SurfaceError::other(format!("unknown tool {other}"))),
2244 }
2245 }
2246}
2247
2248fn block_missing(value: &str) -> SurfaceError {
2251 SurfaceError::with_data(
2252 "block_missing",
2253 format!("no block with id {}", Json::String(value.to_owned())),
2254 json!({ "id": value }),
2255 )
2256}
2257
2258fn node_has_no_block(node_id: &str, doc_id: &str) -> SurfaceError {
2260 SurfaceError::with_data(
2261 "block_missing",
2262 format!("node {} has no block", Json::String(node_id.to_owned())),
2263 json!({ "id": node_id, "doc_id": doc_id }),
2264 )
2265}
2266
2267pub const PROVIDER_UNAVAILABLE_MESSAGE: &str = "semantic(...) needs an embedding provider; none is configured — set embedding.provider (omg config set embedding.provider <command|url>) and embed the repository (omg embed drain)";
2269
2270#[must_use]
2272pub fn provider_unavailable() -> SurfaceError {
2273 SurfaceError::with_data(
2274 "provider_unavailable",
2275 PROVIDER_UNAVAILABLE_MESSAGE,
2276 json!({ "provider": null }),
2277 )
2278}
2279
2280#[must_use]
2284pub fn apply_json(res: &ApplyResult) -> Json {
2285 let mut m = Map::new();
2286 m.insert(
2287 "results".to_owned(),
2288 Json::Array(
2289 res.results
2290 .iter()
2291 .map(|r| {
2292 let mut o = Map::new();
2293 o.insert("ids".to_owned(), json!(r.ids));
2294 if let Some(rm) = &r.removed {
2295 o.insert("removed".to_owned(), json!(rm));
2296 }
2297 if let Some(mi) = &r.merged_into {
2298 o.insert("mergedInto".to_owned(), json!(mi));
2299 }
2300 Json::Object(o)
2301 })
2302 .collect(),
2303 ),
2304 );
2305 m.insert(
2308 "revisions".to_owned(),
2309 Json::Array(
2310 res.revisions
2311 .iter()
2312 .map(|r| json!({ "id": r.doc, "path": reference_path(&r.path), "hash": r.hash }))
2313 .collect(),
2314 ),
2315 );
2316 if let Some(diffs) = &res.diffs {
2317 let mut d = Map::new();
2318 for (path, diff) in diffs {
2319 d.insert(
2320 reference_path(path),
2321 json!({ "before": diff.before, "after": diff.after }),
2322 );
2323 }
2324 m.insert("diffs".to_owned(), Json::Object(d));
2325 }
2326 m.insert("committed".to_owned(), json!(res.committed));
2327 Json::Object(m)
2328}
2329
2330fn dangling_json(l: &omgbase_store::InboundLink) -> Json {
2334 let mut m = Map::new();
2335 m.insert("doc_id".to_owned(), json!(l.doc));
2336 m.insert("path".to_owned(), json!(reference_path(&l.path)));
2337 m.insert("block_id".to_owned(), json!(l.block));
2338 m.insert("target".to_owned(), json!(l.target));
2339 m.insert("anchor".to_owned(), json!(l.anchor));
2340 if let Some(f) = &l.field {
2341 m.insert("field".to_owned(), json!(f));
2342 }
2343 Json::Object(m)
2344}
2345
2346fn doc_op_json(res: &omgbase_store::DocOpResult) -> Json {
2350 let mut m = Map::new();
2351 m.insert("id".to_owned(), json!(res.doc_id));
2352 m.insert("path".to_owned(), json!(reference_path(&res.path)));
2353 m.insert("committed".to_owned(), json!(res.committed));
2354 if let Some(diffs) = &res.diffs {
2355 m.insert("diffs".to_owned(), reference_keyed(&diffs_json(diffs)));
2356 }
2357 Json::Object(m)
2358}
2359
2360fn root_changes_page(mut page: Json) -> Json {
2368 let Some(digests) = page.get_mut("digests").and_then(Json::as_array_mut) else {
2369 return page;
2370 };
2371 for d in digests.iter_mut() {
2372 if let Some(revs) = d.get_mut("revisions").and_then(Json::as_array_mut) {
2373 for r in revs.iter_mut() {
2374 root_field(r, "path");
2375 }
2376 }
2377 if let Some(summary) = d.get("summary").and_then(Json::as_str) {
2378 let rooted = root_summary(summary);
2379 d["summary"] = Json::String(rooted);
2380 }
2381 }
2382 page
2383}
2384
2385fn root_summary(summary: &str) -> String {
2387 let Some(colon) = summary.find(": ") else {
2388 return summary.to_owned();
2389 };
2390 let (head, rest) = summary.split_at(colon + 2);
2391 let (subject, detail) = match rest.find(" — ") {
2392 Some(i) => rest.split_at(i),
2393 None => (rest, ""),
2394 };
2395 let root_list = |s: &str| -> String {
2396 if s.is_empty() {
2397 return String::new();
2398 }
2399 s.split(", ")
2400 .map(reference_path)
2401 .collect::<Vec<_>>()
2402 .join(", ")
2403 };
2404 let subject = if let Some(paths) = subject.strip_prefix("deleted ") {
2405 format!("deleted {}", root_list(paths))
2406 } else if let Some(mv) = subject.strip_prefix("moved ") {
2407 match mv.split_once(" → ") {
2408 Some((from, to)) => format!("moved {} → {}", reference_path(from), reference_path(to)),
2409 None => format!("moved {mv}"),
2410 }
2411 } else {
2412 root_list(subject)
2413 };
2414 format!("{head}{subject}{detail}")
2415}
2416
2417#[must_use]
2420pub fn opset_json(opset: &Opset) -> Json {
2421 let mut m = Map::new();
2422 m.insert("version".to_owned(), json!(1));
2423 m.insert("kind".to_owned(), json!("doc_update"));
2424 m.insert(
2428 "target".to_owned(),
2429 json!({ "id": opset.target_doc, "path": reference_path(&opset.target_path) }),
2430 );
2431 m.insert(
2432 "precondition".to_owned(),
2433 json!({
2434 "id": opset.precondition.doc,
2435 "path": reference_path(&opset.precondition.path),
2436 "baseRevision": opset.precondition.base_revision,
2437 "hash": opset.precondition.hash,
2438 }),
2439 );
2440 m.insert("matcherV".to_owned(), json!(opset.matcher_v));
2441 m.insert(
2442 "ops".to_owned(),
2443 Json::Array(
2444 opset
2445 .ops
2446 .iter()
2447 .map(|p| {
2448 let mut o = Map::new();
2449 let mut op = p.op.to_json();
2454 if let Some(m) = op.as_object_mut()
2455 && let Some(c) = m.remove("child_ids")
2456 {
2457 m.insert("childIds".to_owned(), c);
2458 }
2459 o.insert("op".to_owned(), op);
2460 o.insert("disposition".to_owned(), json!(p.disposition.as_str()));
2461 o.insert("blocks".to_owned(), json!(p.blocks));
2462 o.insert("confidence".to_owned(), json!(p.confidence));
2463 o.insert("reason".to_owned(), json!(p.reason));
2464 if let Some(d) = &p.detail {
2465 o.insert("detail".to_owned(), d.clone());
2466 }
2467 Json::Object(o)
2468 })
2469 .collect(),
2470 ),
2471 );
2472 if let Some(fm) = &opset.frontmatter {
2473 m.insert("frontmatter".to_owned(), json!({ "raw": fm }));
2474 }
2475 m.insert("summary".to_owned(), opset.summary.to_json());
2476 m.insert("converges".to_owned(), json!(opset.converges));
2477 m.insert("diagnostics".to_owned(), json!(opset.diagnostics));
2478 Json::Object(m)
2479}
2480
2481fn evidence_json(e: &omgbase_store::Evidence) -> Json {
2484 let mut m = Map::new();
2485 m.insert("rrf".to_owned(), json!(e.rrf));
2486 m.insert("boosts".to_owned(), e.boosts.to_json());
2487 if let Some(r) = e.fts_rank {
2488 m.insert("ftsRank".to_owned(), json!(r));
2489 }
2490 if let Some(r) = e.vector_rank {
2491 m.insert("vectorRank".to_owned(), json!(r));
2492 if let Some(c) = e.cosine {
2493 m.insert("cosine".to_owned(), json!(c));
2494 }
2495 }
2496 Json::Object(m)
2497}
2498
2499fn observe_json(o: &omgbase_store::ObserveOutcome) -> Json {
2504 json!({
2505 "id": o.doc_id,
2506 "path": reference_path(&o.path),
2507 "rev": o.rev,
2508 "commitId": o.commit_id,
2509 "converged": o.converged,
2510 "echo": o.echo,
2511 "conflicted": o.conflicted,
2512 "dispositions": o.dispositions.iter().map(|(k, n)| json!({ "kind": k, "count": n })).collect::<Vec<_>>(),
2513 })
2514}
2515
2516fn verb(d: omgbase_store::mutate_kernel::PlanDisposition) -> &'static str {
2517 use omgbase_store::mutate_kernel::PlanDisposition as D;
2518 match d {
2519 D::Same => "KEEP ",
2520 D::Edited | D::EditedMoved => "UPDATE",
2521 D::Moved => "MOVE ",
2522 D::Inserted => "INSERT",
2523 D::Deleted => "REMOVE",
2524 D::SplitFrom => "SPLIT ",
2525 D::MergedInto => "MERGE ",
2526 D::CopiedFrom => "COPY ",
2527 D::Resurrected => "RESURR",
2528 D::BulkRewrite => "REWRITE",
2529 D::Retiled => "RETILE",
2530 }
2531}
2532
2533#[must_use]
2535pub fn render_opset_plan(opset: &Opset) -> String {
2536 let mut lines = Vec::new();
2537 for p in &opset.ops {
2538 let subject = p.blocks.first().map_or("(new)", String::as_str);
2539 let conf = p.confidence.map_or(String::new(), |c| format!(" ~{c:.2}"));
2540 let why = p
2541 .reason
2542 .as_ref()
2543 .map_or(String::new(), |r| format!(" [{r}]"));
2544 lines.push(format!(
2545 "{} {:<9} {}{conf}{why}",
2546 verb(p.disposition),
2547 subject,
2548 p.disposition.as_str()
2549 ));
2550 }
2551 let s = &opset.summary;
2552 lines.push(String::new());
2553 lines.push(format!(
2554 "preserved: {} updated: {} moved: {} created: {} removed: {} split: {} merged: {} ambiguous: {}",
2555 s.preserved, s.updated, s.moved, s.created, s.removed, s.split, s.merged, s.ambiguous
2556 ));
2557 if !opset.converges {
2558 lines.push(
2559 "WARNING: plan does not reproduce the proposed content exactly — will not apply."
2560 .to_owned(),
2561 );
2562 }
2563 lines.join("\n")
2564}
2565
2566#[cfg(test)]
2567mod tests {
2568 use super::*;
2569 use omgbase_store::{MemDocStore, SequentialMinter};
2570
2571 fn surface() -> Surface {
2572 let mut store =
2573 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
2574 let repo = store.create_repo("fixture").unwrap();
2575 let mut n = 0;
2576 Surface::new(store, &repo, None)
2577 .with_doc_store(Box::new(MemDocStore::new()))
2578 .with_clock(move || {
2579 n += 1;
2580 format!("2026-09-26T10:{n:02}:00.000Z")
2581 })
2582 }
2583
2584 #[test]
2585 fn catalog_lists_every_tool_of_the_table() {
2586 let names: Vec<&str> = tools().iter().map(|t| t.name).collect();
2587 for want in [
2588 "docs_outline",
2589 "docs_read",
2590 "docs_read_many",
2591 "blocks_read",
2592 "nodes_block_read",
2593 "blocks_read_many",
2594 "docs_tree",
2595 "docs_list",
2596 "query_syntax",
2597 "query",
2598 "graph",
2599 "blocks_search",
2600 "blocks_resolve",
2601 "apply",
2602 "blocks_insert",
2603 "blocks_update",
2604 "blocks_move",
2605 "blocks_remove",
2606 "blocks_split",
2607 "blocks_merge",
2608 "blocks_check",
2609 "nodes_set",
2610 "blocks_append",
2611 "docs_append",
2612 "links_retarget",
2613 "links_stale",
2614 "links_repair",
2615 "docs_create",
2616 "docs_move",
2617 "docs_delete",
2618 "docs_set_meta",
2619 "docs_update",
2620 "observe",
2621 "observe_many",
2622 "observe_delete",
2623 "blocks_history",
2624 "docs_diff",
2625 "docs_diff_unified",
2626 "docs_read_at",
2627 "docs_history",
2628 "changes_since",
2629 "repos_status",
2630 "sync_status",
2631 "repos",
2632 "version",
2633 ] {
2634 assert!(names.contains(&want), "missing {want}");
2635 }
2636 assert_eq!(names.len(), 45);
2637 for t in tools() {
2638 assert_eq!(t.input_schema["type"], "object");
2639 }
2640 }
2641
2642 #[test]
2643 fn observe_read_and_query_round_trip() {
2644 let mut s = surface();
2645 let out = s.call("observe", json!({ "path": "a.md", "content": "---\nlayer: canon\n---\n# Title\n\nHello world.\n\n- [ ] task one\n" }));
2646 assert!(!out.is_error, "{}", out.body);
2647 assert_eq!(out.body["id"], "d_0");
2648 assert_eq!(out.body["echo"], false);
2649 let read = s.call("docs_read", json!({ "path": "a.md", "include_ids": true }));
2650 assert!(!read.is_error, "{}", read.body);
2651 assert_eq!(read.body["path"], "/a.md");
2652 assert_eq!(
2654 s.call("docs_read", json!({ "path": "/a.md" })).body["path"],
2655 "/a.md"
2656 );
2657 let retired = s.call("docs_read", json!({ "doc": "a.md" }));
2659 assert_eq!(retired.body["error"], "filter_invalid");
2660 assert_eq!(
2661 retired.body["message"],
2662 "`doc` is no longer an argument — pass `id` or `path` (docs_read takes id?, include_ids?, path?, repo?)"
2663 );
2664 let neither = s.call("docs_read", json!({}));
2665 assert_eq!(
2666 neither.body["message"],
2667 "pass id or path (docs_read takes id?, include_ids?, path?, repo?)"
2668 );
2669 assert_eq!(
2670 s.call("docs_read", json!({ "path": "//a.md" })).body["id"],
2671 "d_0"
2672 );
2673 assert_eq!(read.body["properties"]["frontmatter"]["layer"], "canon");
2674 assert!(read.body["ids"].as_array().unwrap().len() >= 3);
2675 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" }));
2676 assert!(!q.is_error, "{}", q.body);
2677 assert_eq!(q.body["hits"][0]["title"], "Title");
2678 assert_eq!(q.body["hits"][0]["layer"], "canon");
2679 assert_eq!(q.body["hits"][0]["t"][0]["value"], "task one");
2680 assert_eq!(q.body["hits"][0]["id"], "d_0");
2681 assert_eq!(q.body["hits"][0]["path"], "/a.md");
2682 assert_eq!(q.body["consumer"], "collect");
2683 let c = s.call(
2684 "query",
2685 json!({ "query": "blocks count { where text(\"hello\") }" }),
2686 );
2687 assert_eq!(c.body["count"], 1);
2688 let ok = s.call(
2691 "query",
2692 json!({ "query": "from docs where path == \"a.md\"" }),
2693 );
2694 assert!(!ok.is_error, "{}", ok.body);
2695 assert_eq!(ok.body["hits"][0]["id"], "d_0");
2696 let bad = s.call("query", json!({ "query": "select data.path from docs" }));
2697 assert!(bad.is_error);
2698 assert_eq!(bad.body["error"], "filter_invalid");
2699 assert_eq!(
2700 bad.body["message"],
2701 "projection `path` collides with the hit envelope — alias it (`p: data.path`)"
2702 );
2703 let sem = s.call(
2704 "query",
2705 json!({ "query": "from docs where semantic(\"x\") > 0.5" }),
2706 );
2707 assert_eq!(sem.body["error"], "provider_unavailable");
2708 assert_eq!(sem.body["data"], json!({ "provider": null }));
2709 let outline = s.call("docs_outline", json!({ "path": "a.md" }));
2710 let text = outline.body["text"].as_str().unwrap();
2711 assert!(text.starts_with("b_0 h1 Title §"), "{text}");
2712 assert!(text.contains("☐ task one"));
2713 let missing = s.call("docs_read", json!({ "path": "nope.md" }));
2714 assert_eq!(missing.body["error"], "doc_missing");
2715 assert_eq!(
2716 missing.body["message"],
2717 "no document with id or path \"nope.md\""
2718 );
2719 assert_eq!(missing.body["data"], json!({ "path": "nope.md" }));
2720 let repos = s.call("repos", json!({}));
2721 assert_eq!(repos.body["repos"][0]["slug"], "fixture");
2722 let unknown = s.call("docs_list", json!({ "repo": "zzz" }));
2723 assert_eq!(unknown.body["error"], "repo_not_found");
2724 }
2725
2726 #[test]
2727 fn insert_and_move_carry_the_destination_parent_cas_and_many_reads_keep_one_item() {
2728 let mut s = surface();
2729 s.call(
2730 "observe",
2731 json!({ "path": "a.md", "content": "# T\n\nOne.\n\nTwo.\n" }),
2732 );
2733 s.call("observe", json!({ "path": "b.md", "content": "# U\n" }));
2734 let stale = s.call(
2738 "blocks_insert",
2739 json!({ "to": "a.md", "markdown": "Three.", "expect": { "parent_children_hash": "00" } }),
2740 );
2741 assert!(stale.is_error, "{}", stale.body);
2742 assert_eq!(stale.body["error"], "stale_expectation");
2743 assert_eq!(stale.body["retriable"], true);
2744 let current = stale.body["data"]["current"]["parent_children_hash"]
2745 .as_str()
2746 .expect("current hash")
2747 .to_owned();
2748 assert_eq!(current.len(), 64, "{current}");
2749 let ok = s.call(
2750 "blocks_insert",
2751 json!({ "to": "a.md", "markdown": "Three.", "expect": { "parent_children_hash": current, "content_hash": "dropped, not checked" } }),
2752 );
2753 assert!(!ok.is_error, "{}", ok.body);
2754 assert_eq!(ok.body["committed"], true);
2755 let read = s.call("docs_read", json!({ "path": "a.md" }));
2756 assert_eq!(read.body["raw"], "# T\n\nOne.\n\nTwo.\n\nThree.\n");
2757 let stale = s.call(
2759 "blocks_move",
2760 json!({ "blocks": ["b_1", "b_2"], "to": "a.md", "at": "end", "expect": { "parent_children_hash": "00" } }),
2761 );
2762 assert_eq!(stale.body["error"], "stale_expectation", "{}", stale.body);
2763 let current = stale.body["data"]["current"]["parent_children_hash"]
2764 .as_str()
2765 .expect("current hash")
2766 .to_owned();
2767 let ok = s.call(
2768 "blocks_move",
2769 json!({ "blocks": ["b_1", "b_2"], "to": "a.md", "at": "end", "expect": { "parent_children_hash": current } }),
2770 );
2771 assert!(!ok.is_error, "{}", ok.body);
2772 let read = s.call("docs_read", json!({ "path": "a.md" }));
2773 assert_eq!(read.body["raw"], "# T\n\nThree.\n\nOne.\n\nTwo.\n\n");
2774 let dry = s.call(
2776 "blocks_move",
2777 json!({ "blocks": ["b_1"], "to": "a.md", "at": "start", "dry_run": true, "expect": { "parent_children_hash": "00" } }),
2778 );
2779 assert_eq!(dry.body["error"], "stale_expectation", "{}", dry.body);
2780 let many = s.call(
2783 "docs_read_many",
2784 json!({ "docs": ["a.md", "b.md"], "budget_tokens": 1 }),
2785 );
2786 assert_eq!(
2787 many.body["items"].as_array().unwrap().len(),
2788 1,
2789 "{}",
2790 many.body
2791 );
2792 assert_eq!(many.body["items"][0]["path"], "/a.md");
2793 assert_eq!(many.body["truncated"], true);
2794 let one = s.call(
2795 "docs_read_many",
2796 json!({ "docs": ["b.md"], "budget_tokens": 1 }),
2797 );
2798 assert_eq!(one.body["items"].as_array().unwrap().len(), 1);
2799 assert_eq!(one.body["truncated"], false);
2800 let nodes = s.call(
2801 "blocks_read_many",
2802 json!({ "ids": ["b_zzz", "b_1", "b_2"], "resolution": "full", "budget_tokens": 1 }),
2803 );
2804 assert_eq!(
2805 nodes.body["nodes"].as_array().unwrap().len(),
2806 1,
2807 "{}",
2808 nodes.body
2809 );
2810 assert_eq!(nodes.body["nodes"][0]["id"], "b_1");
2811 assert_eq!(nodes.body["truncated"], true);
2812 assert_eq!(nodes.body["unresolved"], json!(["b_zzz"]));
2813 }
2814
2815 #[test]
2816 fn writes_go_through_the_fixed_doc_store_and_fire_the_hook() {
2817 use std::cell::Cell;
2818 use std::rc::Rc;
2819 let fired = Rc::new(Cell::new(0));
2820 let f2 = Rc::clone(&fired);
2821 let mut s = surface().with_mutation_hook(move || f2.set(f2.get() + 1));
2822 s.call(
2823 "observe",
2824 json!({ "path": "a.md", "content": "# T\n\nOne.\n" }),
2825 );
2826 assert_eq!(fired.get(), 1);
2827 let dry = s.call(
2828 "blocks_insert",
2829 json!({ "to": "a.md", "markdown": "Two.", "dry_run": true }),
2830 );
2831 assert!(!dry.is_error, "{}", dry.body);
2832 assert_eq!(dry.body["committed"], false);
2833 assert_eq!(fired.get(), 1, "a dry run never fires the hook");
2834 let wet = s.call("blocks_insert", json!({ "to": "a.md", "markdown": "Two." }));
2835 assert!(!wet.is_error, "{}", wet.body);
2836 assert_eq!(fired.get(), 2);
2837 let read = s.call("docs_read", json!({ "id": "d_0" }));
2838 assert_eq!(read.body["raw"], "# T\n\nOne.\n\nTwo.\n");
2839 let upd = s.call(
2840 "blocks_update",
2841 json!({ "id": "b_1", "markdown": "One, edited." }),
2842 );
2843 assert!(!upd.is_error, "{}", upd.body);
2844 assert_eq!(upd.body["id"], "b_1");
2845 let app = s.call(
2846 "docs_append",
2847 json!({ "path": "a.md", "section": "T", "text": "Three." }),
2848 );
2849 assert!(!app.is_error, "{}", app.body);
2850 let amb = s.call(
2851 "docs_append",
2852 json!({ "path": "a.md", "section": "Nope", "text": "x" }),
2853 );
2854 assert_eq!(amb.body["error"], "parent_missing");
2855 assert_eq!(amb.body["message"], "no heading \"Nope\" in /a.md");
2856 let into = s.call("blocks_append", json!({ "id": "b_0", "text": "Four." }));
2858 assert!(!into.is_error, "{}", into.body);
2859 let leaf = s.call("blocks_append", json!({ "id": "b_1", "text": "x" }));
2860 assert_eq!(leaf.body["error"], "block_not_container", "{}", leaf.body);
2861 assert_eq!(
2862 leaf.body["data"],
2863 json!({ "id": "b_1", "type": "paragraph" })
2864 );
2865 let hist = s.call("blocks_history", json!({ "id": "b_1" }));
2866 let entries = hist.body.as_array().unwrap();
2867 assert!(entries.len() >= 2, "{}", hist.body);
2868 assert_eq!(entries[0]["origin"], "api", "newest first");
2869 let du = s.call("docs_diff_unified", json!({ "path": "a.md" }));
2870 assert!(!du.is_error, "{}", du.body);
2871 assert!(du.body["diff"].as_str().unwrap().contains("+Four."));
2872 }
2873
2874 #[test]
2875 fn doc_tools_dry_run_preview_diffs_and_commit_nothing() {
2876 use std::cell::Cell;
2877 use std::rc::Rc;
2878 let fired = Rc::new(Cell::new(0));
2879 let f2 = Rc::clone(&fired);
2880 let mut s = surface().with_mutation_hook(move || f2.set(f2.get() + 1));
2881 let b_src = "# B\n\nTarget.\n";
2882 let a_src = "# A\n\nSee [b](b.md).\n";
2883 let b = s.call("docs_create", json!({ "path": "b.md", "markdown": b_src }));
2884 assert_eq!(b.body["id"], "d_0", "{}", b.body);
2885 let a = s.call("docs_create", json!({ "path": "a.md", "markdown": a_src }));
2886 assert_eq!(a.body["id"], "d_1", "{}", a.body);
2887 assert_eq!(fired.get(), 2);
2888 let count = |s: &Surface, table: &str| -> i64 {
2889 s.store
2890 .conn()
2891 .query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
2892 .unwrap()
2893 };
2894 let snapshot = |s: &Surface| {
2895 (
2896 count(s, "docs"),
2897 count(s, "commits"),
2898 count(s, "revisions"),
2899 count(s, "blocks"),
2900 )
2901 };
2902 let before = snapshot(&s);
2903 let keys = |v: &Json| -> Vec<String> { v.as_object().unwrap().keys().cloned().collect() };
2904
2905 let c = s.call(
2907 "docs_create",
2908 json!({ "path": "c.md", "markdown": "# C", "frontmatter": { "title": "C" }, "dry_run": true }),
2909 );
2910 assert!(!c.is_error, "{}", c.body);
2911 assert_eq!(
2912 serde_json::to_string(&c.body).unwrap(),
2913 r#"{"id":"d_2","path":"/c.md","committed":false,"diffs":{"/c.md":{"before":"","after":"---\ntitle: C\n---\n\n# C\n"}}}"#,
2914 "the reference's key order, byte for byte"
2915 );
2916
2917 let m = s.call(
2920 "docs_move",
2921 json!({ "path": "b.md", "to_path": "notes/b.md", "retarget_inbound": true, "dry_run": true }),
2922 );
2923 assert!(!m.is_error, "{}", m.body);
2924 assert_eq!(
2928 serde_json::to_string(&m.body).unwrap(),
2929 r##"{"id":"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"]}}"##
2930 );
2931 assert_eq!(
2932 keys(&m.body),
2933 ["id", "path", "committed", "diffs", "dangling", "retargeted"]
2934 );
2935 assert_eq!(keys(&m.body["diffs"]), ["/b.md", "/notes/b.md", "/a.md"]);
2936 assert_eq!(
2937 m.body["diffs"]["/a.md"],
2938 json!({ "before": a_src, "after": "# A\n\nSee [b](notes/b.md).\n" })
2939 );
2940 let plain = s.call(
2943 "docs_move",
2944 json!({ "path": "b.md", "to_path": "notes/b.md", "dry_run": true, "retarget_inbound": false }),
2945 );
2946 assert_eq!(
2947 serde_json::to_string(&plain.body).unwrap(),
2948 r##"{"id":"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_id":"d_1","path":"/a.md","block_id":"b_3","target":"b.md","anchor":null}],"retargeted":null}"##
2949 );
2950
2951 let d = s.call("docs_delete", json!({ "path": "a.md", "dry_run": true }));
2953 assert!(!d.is_error, "{}", d.body);
2954 assert_eq!(
2955 d.body,
2956 json!({ "id": "d_1", "path": "/a.md", "committed": false, "diffs": { "/a.md": { "before": a_src, "after": "" } } })
2957 );
2958 assert_eq!(keys(&d.body), ["id", "path", "committed", "diffs"]);
2959
2960 let sm = s.call(
2962 "docs_set_meta",
2963 json!({ "path": "a.md", "set": { "status": "open" }, "dry_run": true }),
2964 );
2965 assert!(!sm.is_error, "{}", sm.body);
2966 assert_eq!(
2967 sm.body,
2968 json!({ "id": "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" } } })
2969 );
2970
2971 assert_eq!(fired.get(), 2, "dry runs never fire the mutation hook");
2973 assert_eq!(snapshot(&s), before);
2974 assert_eq!(
2975 s.call("docs_read", json!({ "id": "d_0" })).body["path"],
2976 "/b.md"
2977 );
2978 assert_eq!(
2979 s.call("docs_read", json!({ "id": "d_1" })).body["raw"],
2980 a_src
2981 );
2982 let taken = s.call(
2983 "docs_create",
2984 json!({ "path": "a.md", "markdown": "x", "dry_run": true }),
2985 );
2986 assert_eq!(taken.body["error"], "path_taken");
2987 let missing = s.call("docs_delete", json!({ "path": "nope.md", "dry_run": true }));
2988 assert_eq!(missing.body["error"], "doc_missing");
2989 let real = s.call(
2991 "docs_create",
2992 json!({ "path": "c.md", "markdown": "# C\n" }),
2993 );
2994 assert_eq!(
2995 real.body,
2996 json!({ "id": "d_3", "path": "/c.md", "committed": true })
2997 );
2998 assert_eq!(fired.get(), 3);
2999 }
3000
3001 #[test]
3002 fn change_summaries_are_rooted_like_the_reference_renders_them() {
3003 assert_eq!(
3004 root_summary("observed: a.md, b/c.md — 3 inserted, 1 edited"),
3005 "observed: /a.md, /b/c.md — 3 inserted, 1 edited"
3006 );
3007 assert_eq!(
3008 root_summary("observed: deleted a.md"),
3009 "observed: deleted /a.md"
3010 );
3011 assert_eq!(
3012 root_summary("api(human:spec): moved a.md → b/a.md"),
3013 "api(human:spec): moved /a.md → /b/a.md"
3014 );
3015 assert_eq!(root_summary("api(?): x.md"), "api(?): /x.md");
3016 assert_eq!(root_summary("observed: "), "observed: ");
3017 assert_eq!(root_summary("nonsense"), "nonsense");
3018 let page = root_changes_page(json!({
3019 "digests": [{ "summary": "observed: a.md", "revisions": [{ "id": "d_0", "path": "a.md", "hash": "ff" }] }],
3020 "cursor": 1, "truncated": false, "head": 1
3021 }));
3022 assert_eq!(page["digests"][0]["revisions"][0]["path"], "/a.md");
3023 assert_eq!(page["digests"][0]["summary"], "observed: /a.md");
3024 }
3025}