systemprompt_provider_contracts/documents/
edit_op.rs1use 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#[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}