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//! The rich-text and table ops (`set_marks`, `set_block_type`, the table
16//! ops, `insert_image`) carry their payload as a struct from `rich_op` and
17//! apply through the document's `RichText` model.
18//!
19//! `restore` and `insert_from_revision` name an earlier revision; the
20//! document service expands them (into the revision's bytes, or an
21//! `insert_after` of the named node's text) before a codec sees the batch.
22//!
23//! Copyright (c) systemprompt.io — Business Source License 1.1.
24//! See <https://systemprompt.io> for licensing details.
25
26use std::num::NonZeroUsize;
27
28use schemars::JsonSchema;
29use serde::{Deserialize, Serialize};
30use systemprompt_identifiers::{DocumentNodeId, DocumentRevisionId};
31
32use super::codec::CodecError;
33use super::layout_op::{ApplyTheme, InsertBlock, SetParagraph, SetSection, SetStyle, UpdateTable};
34use super::projection::NodeKind;
35use super::rich_op::{InsertImage, InsertTable, SetBlockType, SetMarks, TableDelete, TableInsert};
36
37/// The node an anchored op addresses: a node id from a read, or a quote of
38/// the block's text.
39///
40/// A quote matches the innermost block whose text contains `find`; `nth`
41/// (1-based, default 1) picks among matches in document order, counted
42/// inside `within` when given.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
44#[serde(untagged)]
45pub enum NodeTarget {
46    Id(DocumentNodeId),
47    Find(QuoteTarget),
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
51pub struct QuoteTarget {
52    pub find: String,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub nth: Option<NonZeroUsize>,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub within: Option<DocumentNodeId>,
57}
58
59#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
60#[serde(rename_all = "snake_case")]
61pub enum ContentAs {
62    #[default]
63    Text,
64    Markdown,
65}
66
67impl ContentAs {
68    #[must_use]
69    pub const fn is_text(&self) -> bool {
70        matches!(self, Self::Text)
71    }
72}
73
74impl NodeTarget {
75    #[must_use]
76    pub const fn id(&self) -> Option<&DocumentNodeId> {
77        match self {
78            Self::Id(id) => Some(id),
79            Self::Find(_) => None,
80        }
81    }
82
83    pub const fn resolved(&self) -> Result<&DocumentNodeId, CodecError> {
84        match self {
85            Self::Id(id) => Ok(id),
86            Self::Find(_) => Err(CodecError::Malformed {
87                detail: "a quote target reached the codec unresolved",
88            }),
89        }
90    }
91}
92
93impl EditOp {
94    pub fn check_resolved(&self) -> Result<(), CodecError> {
95        match self {
96            Self::ReplaceText {
97                content_as: ContentAs::Markdown,
98                ..
99            } => Err(CodecError::Malformed {
100                detail: "replace_text with markdown content reached the codec unresolved",
101            }),
102            Self::ReplaceText { node, .. }
103            | Self::InsertAfter { node, .. }
104            | Self::Delete { node, .. } => node.resolved().map(|_| ()),
105            Self::Restore { .. } | Self::InsertFromRevision { .. } => Err(CodecError::Malformed {
106                detail: "a revision op reached the codec unexpanded",
107            }),
108            _ => self
109                .rich_guard()
110                .map_or(Ok(()), |(node, _)| node.resolved().map(|_| ())),
111        }
112    }
113}
114
115pub fn required_hash(hash: Option<&String>) -> Result<&str, CodecError> {
116    hash.map(String::as_str).ok_or(CodecError::Malformed {
117        detail: "expected_hash is required when node names a node id",
118    })
119}
120
121const fn paragraph() -> NodeKind {
122    NodeKind::Paragraph
123}
124
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
126#[serde(untagged)]
127pub enum CellValue {
128    Bool(bool),
129    Number(f64),
130    Text(String),
131    Empty,
132}
133
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
135#[serde(tag = "op", rename_all = "snake_case")]
136pub enum EditOp {
137    ReplaceText {
138        node: NodeTarget,
139        #[serde(default, skip_serializing_if = "Option::is_none")]
140        expected_hash: Option<String>,
141        text: String,
142        #[serde(rename = "as", default, skip_serializing_if = "ContentAs::is_text")]
143        content_as: ContentAs,
144    },
145    InsertAfter {
146        node: NodeTarget,
147        #[serde(default = "paragraph")]
148        kind: NodeKind,
149        text: String,
150        #[serde(rename = "as", default, skip_serializing_if = "ContentAs::is_text")]
151        content_as: ContentAs,
152    },
153    Delete {
154        node: NodeTarget,
155        #[serde(default, skip_serializing_if = "Option::is_none")]
156        expected_hash: Option<String>,
157    },
158    AppendMarkdown {
159        markdown: String,
160    },
161    SetCells {
162        sheet: String,
163        range: String,
164        values: Vec<Vec<CellValue>>,
165        #[serde(default, skip_serializing_if = "Option::is_none")]
166        expected_hash: Option<String>,
167    },
168    InsertSlide {
169        #[serde(default, skip_serializing_if = "Option::is_none")]
170        after: Option<DocumentNodeId>,
171        #[serde(default, skip_serializing_if = "Option::is_none")]
172        layout: Option<String>,
173        #[serde(default, skip_serializing_if = "Option::is_none")]
174        title: Option<String>,
175        #[serde(default)]
176        body: Vec<String>,
177    },
178    StrReplace {
179        old_str: String,
180        new_str: String,
181    },
182    SetHtml {
183        html: String,
184    },
185    SetText {
186        text: String,
187    },
188    Restore {
189        revision_id: DocumentRevisionId,
190    },
191    InsertFromRevision {
192        revision_id: DocumentRevisionId,
193        node: DocumentNodeId,
194        after: NodeTarget,
195    },
196    SetMarks(SetMarks),
197    SetBlockType(SetBlockType),
198    InsertTable(InsertTable),
199    TableInsertRow(TableInsert),
200    TableInsertCol(TableInsert),
201    TableDelete(TableDelete),
202    InsertImage(InsertImage),
203    SetParagraph(SetParagraph),
204    InsertBlock(InsertBlock),
205    UpdateTable(UpdateTable),
206    SetSection(SetSection),
207    SetStyle(SetStyle),
208    ApplyTheme(ApplyTheme),
209}
210
211impl EditOp {
212    #[must_use]
213    pub const fn name(&self) -> &'static str {
214        match self {
215            Self::ReplaceText { .. } => "replace_text",
216            Self::InsertAfter { .. } => "insert_after",
217            Self::Delete { .. } => "delete",
218            Self::SetCells { .. } => "set_cells",
219            Self::InsertSlide { .. } => "insert_slide",
220            Self::StrReplace { .. } => "str_replace",
221            Self::SetHtml { .. } => "set_html",
222            Self::SetText { .. } => "set_text",
223            Self::AppendMarkdown { .. } => "append_markdown",
224            Self::Restore { .. } => "restore",
225            Self::InsertFromRevision { .. } => "insert_from_revision",
226            Self::SetMarks(_) => "set_marks",
227            Self::SetBlockType(_) => "set_block_type",
228            Self::InsertTable(_) => "insert_table",
229            Self::TableInsertRow(_) => "table_insert_row",
230            Self::TableInsertCol(_) => "table_insert_col",
231            Self::TableDelete(_) => "table_delete",
232            Self::InsertImage(_) => "insert_image",
233            Self::SetParagraph(_) => "set_paragraph",
234            Self::InsertBlock(_) => "insert_block",
235            Self::UpdateTable(_) => "update_table",
236            Self::SetSection(_) => "set_section",
237            Self::SetStyle(_) => "set_style",
238            Self::ApplyTheme(_) => "apply_theme",
239        }
240    }
241
242    #[must_use]
243    pub const fn touches(&self) -> Option<&DocumentNodeId> {
244        match self {
245            Self::ReplaceText { node, .. }
246            | Self::InsertAfter { node, .. }
247            | Self::Delete { node, .. } => node.id(),
248            Self::InsertFromRevision { after, .. } => after.id(),
249            Self::InsertSlide { after, .. } => after.as_ref(),
250            Self::SetMarks(_)
251            | Self::SetBlockType(_)
252            | Self::InsertTable(_)
253            | Self::TableInsertRow(_)
254            | Self::TableInsertCol(_)
255            | Self::TableDelete(_)
256            | Self::InsertImage(_)
257            | Self::SetParagraph(_)
258            | Self::InsertBlock(_)
259            | Self::UpdateTable(_) => match self.rich_guard() {
260                Some((node, _)) => node.id(),
261                None => None,
262            },
263            Self::SetCells { .. }
264            | Self::StrReplace { .. }
265            | Self::SetHtml { .. }
266            | Self::SetText { .. }
267            | Self::AppendMarkdown { .. }
268            | Self::SetSection(_)
269            | Self::SetStyle(_)
270            | Self::ApplyTheme(_)
271            | Self::Restore { .. } => None,
272        }
273    }
274}