Skip to main content

systemprompt_provider_contracts/documents/
edit_op.rs

1//! The edit operations an agent or client submits against a projection.
2//!
3//! Anchored operations (`replace_text`, `delete`, `insert_after`) name their
4//! node by id or by quoted text ([`NodeTarget`]). An id target quotes the
5//! `hash8` the caller read; a codec compares it with the node's current hash
6//! and fails with a stale-anchor error rather than overwrite unseen text. A
7//! quote target needs no hash: the quoted text is what the caller read, and
8//! [`resolve_targets`](super::resolve_targets) turns it into the id and hash
9//! of the matching block before a codec sees the batch.
10//!
11//! `insert_after` and `replace_text` take their text as plain text or, with
12//! `as: markdown`, as Markdown that each codec lowers into native blocks;
13//! `append_markdown` adds lowered blocks at the end of the document body.
14//!
15//! `restore` and `insert_from_revision` name an earlier revision; the
16//! document service expands them (into the revision's bytes, or an
17//! `insert_after` of the named node's text) before a codec sees the batch.
18//!
19//! Copyright (c) systemprompt.io — Business Source License 1.1.
20//! See <https://systemprompt.io> for licensing details.
21
22use std::num::NonZeroUsize;
23
24use schemars::JsonSchema;
25use serde::{Deserialize, Serialize};
26use systemprompt_identifiers::{DocumentNodeId, DocumentRevisionId};
27
28use super::codec::CodecError;
29use super::projection::NodeKind;
30
31/// The node an anchored op addresses: a node id from a read, or a quote of
32/// the block's text.
33///
34/// A quote matches the innermost block whose text contains `find`; `nth`
35/// (1-based, default 1) picks among matches in document order, counted
36/// inside `within` when given.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
38#[serde(untagged)]
39pub enum NodeTarget {
40    Id(DocumentNodeId),
41    Find(QuoteTarget),
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
45pub struct QuoteTarget {
46    pub find: String,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub nth: Option<NonZeroUsize>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub within: Option<DocumentNodeId>,
51}
52
53#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
54#[serde(rename_all = "snake_case")]
55pub enum ContentAs {
56    #[default]
57    Text,
58    Markdown,
59}
60
61impl ContentAs {
62    #[must_use]
63    pub const fn is_text(&self) -> bool {
64        matches!(self, Self::Text)
65    }
66}
67
68impl NodeTarget {
69    #[must_use]
70    pub const fn id(&self) -> Option<&DocumentNodeId> {
71        match self {
72            Self::Id(id) => Some(id),
73            Self::Find(_) => None,
74        }
75    }
76
77    pub const fn resolved(&self) -> Result<&DocumentNodeId, CodecError> {
78        match self {
79            Self::Id(id) => Ok(id),
80            Self::Find(_) => Err(CodecError::Malformed {
81                detail: "a quote target reached the codec unresolved",
82            }),
83        }
84    }
85}
86
87impl EditOp {
88    pub fn check_resolved(&self) -> Result<(), CodecError> {
89        match self {
90            Self::ReplaceText {
91                content_as: ContentAs::Markdown,
92                ..
93            } => Err(CodecError::Malformed {
94                detail: "replace_text with markdown content reached the codec unresolved",
95            }),
96            Self::ReplaceText { node, .. }
97            | Self::InsertAfter { node, .. }
98            | Self::Delete { node, .. } => node.resolved().map(|_| ()),
99            Self::Restore { .. } | Self::InsertFromRevision { .. } => Err(CodecError::Malformed {
100                detail: "a revision op reached the codec unexpanded",
101            }),
102            _ => Ok(()),
103        }
104    }
105}
106
107pub fn required_hash(hash: Option<&String>) -> Result<&str, CodecError> {
108    hash.map(String::as_str).ok_or(CodecError::Malformed {
109        detail: "expected_hash is required when node names a node id",
110    })
111}
112
113const fn paragraph() -> NodeKind {
114    NodeKind::Paragraph
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
118#[serde(untagged)]
119pub enum CellValue {
120    Bool(bool),
121    Number(f64),
122    Text(String),
123    Empty,
124}
125
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
127#[serde(tag = "op", rename_all = "snake_case")]
128pub enum EditOp {
129    ReplaceText {
130        node: NodeTarget,
131        #[serde(default, skip_serializing_if = "Option::is_none")]
132        expected_hash: Option<String>,
133        text: String,
134        #[serde(rename = "as", default, skip_serializing_if = "ContentAs::is_text")]
135        content_as: ContentAs,
136    },
137    InsertAfter {
138        node: NodeTarget,
139        #[serde(default = "paragraph")]
140        kind: NodeKind,
141        text: String,
142        #[serde(rename = "as", default, skip_serializing_if = "ContentAs::is_text")]
143        content_as: ContentAs,
144    },
145    Delete {
146        node: NodeTarget,
147        #[serde(default, skip_serializing_if = "Option::is_none")]
148        expected_hash: Option<String>,
149    },
150    AppendMarkdown {
151        markdown: String,
152    },
153    SetCells {
154        sheet: String,
155        range: String,
156        values: Vec<Vec<CellValue>>,
157        #[serde(default, skip_serializing_if = "Option::is_none")]
158        expected_hash: Option<String>,
159    },
160    InsertSlide {
161        #[serde(default, skip_serializing_if = "Option::is_none")]
162        after: Option<DocumentNodeId>,
163        #[serde(default, skip_serializing_if = "Option::is_none")]
164        layout: Option<String>,
165        #[serde(default, skip_serializing_if = "Option::is_none")]
166        title: Option<String>,
167        #[serde(default)]
168        body: Vec<String>,
169    },
170    StrReplace {
171        old_str: String,
172        new_str: String,
173    },
174    SetHtml {
175        html: String,
176    },
177    SetText {
178        text: String,
179    },
180    Restore {
181        revision_id: DocumentRevisionId,
182    },
183    InsertFromRevision {
184        revision_id: DocumentRevisionId,
185        node: DocumentNodeId,
186        after: NodeTarget,
187    },
188}
189
190impl EditOp {
191    #[must_use]
192    pub const fn name(&self) -> &'static str {
193        match self {
194            Self::ReplaceText { .. } => "replace_text",
195            Self::InsertAfter { .. } => "insert_after",
196            Self::Delete { .. } => "delete",
197            Self::SetCells { .. } => "set_cells",
198            Self::InsertSlide { .. } => "insert_slide",
199            Self::StrReplace { .. } => "str_replace",
200            Self::SetHtml { .. } => "set_html",
201            Self::SetText { .. } => "set_text",
202            Self::AppendMarkdown { .. } => "append_markdown",
203            Self::Restore { .. } => "restore",
204            Self::InsertFromRevision { .. } => "insert_from_revision",
205        }
206    }
207
208    #[must_use]
209    pub const fn touches(&self) -> Option<&DocumentNodeId> {
210        match self {
211            Self::ReplaceText { node, .. }
212            | Self::InsertAfter { node, .. }
213            | Self::Delete { node, .. } => node.id(),
214            Self::InsertFromRevision { after, .. } => after.id(),
215            Self::InsertSlide { after, .. } => after.as_ref(),
216            Self::SetCells { .. }
217            | Self::StrReplace { .. }
218            | Self::SetHtml { .. }
219            | Self::SetText { .. }
220            | Self::AppendMarkdown { .. }
221            | Self::Restore { .. } => None,
222        }
223    }
224}