1use serde::Serialize;
4
5use super::anchor::insertion_counts;
6use super::types::{Document, Range, StructuralElement};
7use super::write_types::{
8 CreateNamedRangeRequest, DocsRequest, NamedRangeIdRequest, ReplaceNamedRangeContentRequest,
9 TabsCriteria,
10};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Scope {
15 pub tab_id: Option<String>,
17 pub segment_id: Option<String>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum Mutation {
24 Create {
26 name: String,
28 start_index: i64,
30 end_index: i64,
32 },
33 Delete {
35 id: String,
37 },
38 Replace {
40 id: String,
42 text: String,
44 },
45}
46
47impl Mutation {
48 pub(crate) fn validate(&self, scope: &Scope) -> Result<(), String> {
50 if scope.tab_id.as_ref().is_some_and(String::is_empty)
51 || scope.segment_id.as_ref().is_some_and(String::is_empty)
52 {
53 return Err("scope IDs cannot be empty".into());
54 }
55 match self {
56 Self::Create {
57 name,
58 start_index,
59 end_index,
60 } => {
61 if !(1..=256).contains(&name.encode_utf16().count()) {
62 return Err("named-range name must be 1–256 UTF-16 units".into());
63 }
64 if *start_index < 0 || end_index <= start_index {
65 return Err("named-range indices must describe a nonempty bounded span".into());
66 }
67 }
68 Self::Delete { id } | Self::Replace { id, .. } if id.is_empty() => {
69 return Err("named-range ID cannot be empty".into());
70 }
71 Self::Replace { text, .. } if !text.is_empty() && insertion_counts(text).0 == 0 => {
72 return Err("replacement becomes empty after Docs strips unsupported characters; use explicit empty text for deletion".into());
73 }
74 _ => {}
75 }
76 Ok(())
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
82#[serde(rename_all = "kebab-case")]
83pub enum Error {
84 InvalidScope,
86 UnresolvedId,
88 ScopeMismatch,
90 DiscontinuousRange,
92 UnsafeRange,
94 SuggestedContent,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
100#[serde(rename_all = "kebab-case")]
101pub enum Effect {
102 CreateMetadata,
104 DeleteMetadata,
106 ReplaceContent,
108 DeleteContent,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
114pub struct Preview {
115 pub effect: Effect,
117 #[serde(skip_serializing_if = "Option::is_none")]
119 pub named_range_id: Option<String>,
120 pub ranges: Vec<Range>,
122 pub removed_chars: usize,
124 pub removed_bytes: usize,
126 pub inserted_chars: usize,
128 pub inserted_bytes: usize,
130}
131
132pub(crate) fn resolve(
134 document: &Document,
135 scope: &Scope,
136 mutation: &Mutation,
137) -> Result<(DocsRequest, Preview), Error> {
138 let tabs = document.resolved_tabs();
139 let mut ids = std::collections::HashSet::new();
142 if !document.tabs.is_empty()
143 && tabs
144 .iter()
145 .any(|tab| tab.tab_id.is_none_or(|id| id.is_empty() || !ids.insert(id)))
146 {
147 return Err(Error::InvalidScope);
148 }
149 let selected: Vec<_> = tabs
150 .iter()
151 .filter(|tab| tab.tab_id == scope.tab_id.as_deref())
152 .collect();
153 let [tab] = selected.as_slice() else {
154 return Err(Error::InvalidScope);
155 };
156 let content = if let Some(segment_id) = scope.segment_id.as_deref() {
157 let segments: Vec<_> = tab
158 .headers()
159 .into_iter()
160 .chain(tab.footers())
161 .chain(tab.footnotes())
162 .filter(|segment| segment.segment_id == segment_id)
163 .collect();
164 let [segment] = segments.as_slice() else {
165 return Err(Error::InvalidScope);
166 };
167 segment.content
168 } else {
169 &tab.body.ok_or(Error::InvalidScope)?.content
170 };
171 let mut preview = Preview {
172 effect: match mutation {
173 Mutation::Create { .. } => Effect::CreateMetadata,
174 Mutation::Delete { .. } => Effect::DeleteMetadata,
175 Mutation::Replace { text, .. } if text.is_empty() => Effect::DeleteContent,
176 Mutation::Replace { .. } => Effect::ReplaceContent,
177 },
178 named_range_id: None,
179 ranges: vec![],
180 removed_chars: 0,
181 removed_bytes: 0,
182 inserted_chars: 0,
183 inserted_bytes: 0,
184 };
185 if let Mutation::Create {
186 name,
187 start_index,
188 end_index,
189 } = mutation
190 {
191 validate_text_range(
192 content,
193 *start_index,
194 *end_index,
195 scope.segment_id.is_some(),
196 )?;
197 let range = Range {
198 start_index: Some(*start_index),
199 end_index: Some(*end_index),
200 tab_id: scope.tab_id.clone(),
201 segment_id: scope.segment_id.clone(),
202 };
203 preview.ranges.push(range.clone());
204 return Ok((
205 DocsRequest::CreateNamedRange(CreateNamedRangeRequest {
206 name: name.clone(),
207 range,
208 }),
209 preview,
210 ));
211 }
212 let id = match mutation {
213 Mutation::Delete { id } | Mutation::Replace { id, .. } => id,
214 Mutation::Create { .. } => return Err(Error::UnsafeRange), };
216 let matches: Vec<_> = tab
217 .named_ranges
218 .values()
219 .flat_map(|group| &group.named_ranges)
220 .filter(|range| range.named_range_id.as_deref() == Some(id))
221 .collect();
222 let [named] = matches.as_slice() else {
223 return Err(Error::UnresolvedId);
224 };
225 if named.ranges.is_empty() {
226 return Err(Error::UnsafeRange);
227 }
228 for range in &named.ranges {
229 if range
232 .tab_id
233 .as_deref()
234 .filter(|id| !id.is_empty())
235 .is_some_and(|id| Some(id) != tab.tab_id)
236 || range.segment_id.as_deref().filter(|id| !id.is_empty())
237 != scope.segment_id.as_deref()
238 {
239 return Err(Error::ScopeMismatch);
240 }
241 let start = range.start_index.unwrap_or(0); let end = range.end_index.ok_or(Error::UnsafeRange)?;
243 if start < 0 || end <= start {
244 return Err(Error::UnsafeRange);
245 }
246 preview.ranges.push(Range {
247 tab_id: scope.tab_id.clone(),
248 segment_id: scope.segment_id.clone(),
249 start_index: Some(start),
250 end_index: Some(end),
251 });
252 }
253 preview.named_range_id = Some(id.clone());
254 let target = NamedRangeIdRequest {
255 named_range_id: id.clone(),
256 tabs_criteria: scope.tab_id.as_ref().map(|id| TabsCriteria {
257 tab_ids: [id.clone()],
258 }),
259 };
260 match mutation {
261 Mutation::Delete { .. } => Ok((DocsRequest::DeleteNamedRange(target), preview)),
262 Mutation::Replace { text, .. } => {
263 let [range] = preview.ranges.as_slice() else {
264 return Err(Error::DiscontinuousRange);
265 };
266 let (chars, bytes) = validate_text_range(
267 content,
268 range.start_index.ok_or(Error::UnsafeRange)?,
269 range.end_index.ok_or(Error::UnsafeRange)?,
270 scope.segment_id.is_some(),
271 )?;
272 preview.removed_chars = chars;
273 preview.removed_bytes = bytes;
274 (preview.inserted_chars, preview.inserted_bytes) = insertion_counts(text);
275 Ok((
276 DocsRequest::ReplaceNamedRangeContent(ReplaceNamedRangeContentRequest {
277 target,
278 text: text.clone(),
279 }),
280 preview,
281 ))
282 }
283 Mutation::Create { .. } => Err(Error::UnsafeRange), }
285}
286
287fn validate_text_range(
291 elements: &[StructuralElement],
292 start: i64,
293 end: i64,
294 non_body: bool,
295) -> Result<(usize, usize), Error> {
296 if start < i64::from(!non_body) || end <= start {
297 return Err(Error::UnsafeRange);
298 }
299 let mut candidates = Vec::new();
300 matching_paragraphs(elements, start, end, &mut candidates);
301 let [element] = candidates.as_slice() else {
302 return Err(Error::UnsafeRange);
303 };
304 let paragraph = element.paragraph.as_ref().ok_or(Error::UnsafeRange)?;
305 let p_start = element.start_index.unwrap_or(0);
306 let p_end = element.end_index.ok_or(Error::UnsafeRange)?;
307 if p_start < i64::from(!non_body) || p_end <= p_start {
308 return Err(Error::UnsafeRange);
309 }
310 let mut previous = p_start;
311 let mut cursor = start;
312 let mut chars = 0;
313 let mut bytes = 0;
314 for inline in ¶graph.elements {
315 let lo = inline.start_index.unwrap_or(0);
316 let hi = inline.end_index.ok_or(Error::UnsafeRange)?;
317 if lo < previous || hi <= lo || hi > p_end {
318 return Err(Error::UnsafeRange);
319 }
320 previous = hi;
321 let Some(run) = &inline.text_run else {
322 if lo < end && hi > start {
323 return Err(Error::UnsafeRange);
324 }
325 continue;
326 };
327 if lo.checked_add(
328 i64::try_from(run.content.encode_utf16().count()).map_err(|_| Error::UnsafeRange)?,
329 ) != Some(hi)
330 {
331 return Err(Error::UnsafeRange);
332 }
333 if hi <= start || lo >= end {
334 continue;
335 }
336 if lo > cursor {
337 return Err(Error::UnsafeRange);
338 }
339 if !run.suggested_insertion_ids.is_empty() || !run.suggested_deletion_ids.is_empty() {
340 return Err(Error::SuggestedContent);
341 }
342 let mut index = lo;
343 for ch in run.content.chars() {
344 let next = index + ch.len_utf16() as i64;
345 if index < end && next > start {
346 if index < start || next > end || ch == '\n' {
347 return Err(Error::UnsafeRange);
348 }
349 chars += 1;
350 bytes += ch.len_utf8();
351 }
352 index = next;
353 }
354 cursor = hi.min(end);
355 }
356 if cursor == end
357 && paragraph
358 .elements
359 .last()
360 .and_then(|e| e.text_run.as_ref())
361 .is_some_and(|run| previous == p_end && run.content.ends_with('\n'))
362 {
363 return Ok((chars, bytes));
364 }
365 Err(Error::UnsafeRange)
366}
367
368fn matching_paragraphs<'a>(
369 elements: &'a [StructuralElement],
370 start: i64,
371 end: i64,
372 out: &mut Vec<&'a StructuralElement>,
373) {
374 for element in elements {
375 if element.paragraph.is_some()
376 && element.start_index.unwrap_or(0) <= start
377 && element.end_index.is_some_and(|p_end| end < p_end)
378 {
379 out.push(element);
380 }
381 if let Some(table) = &element.table {
382 for row in &table.table_rows {
383 for cell in &row.table_cells {
384 matching_paragraphs(&cell.content, start, end, out);
385 }
386 }
387 }
388 }
389}
390
391impl Preview {
392 pub(crate) fn describe_spans(&self) -> String {
394 let mut spans = self
395 .ranges
396 .iter()
397 .take(50)
398 .map(|range| {
399 format!(
400 "[{}, {}) UTF-16, tab {}, segment {}",
401 range.start_index.unwrap_or(0),
402 range.end_index.unwrap_or(0),
403 range.tab_id.as_deref().unwrap_or("legacy"),
404 range.segment_id.as_deref().unwrap_or("body")
405 )
406 })
407 .collect::<Vec<_>>()
408 .join("; ");
409 if self.ranges.len() > 50 {
410 spans.push_str(&format!("; {} more span(s)", self.ranges.len() - 50));
411 }
412 spans
413 }
414}
415
416#[cfg(test)]
417#[allow(clippy::unwrap_used, clippy::expect_used)]
418mod tests {
419 use super::*;
420 use serde_json::{json, Value};
421
422 fn paragraph(start: i64, text: &str) -> Value {
423 let end = start + text.encode_utf16().count() as i64;
424 json!({"startIndex": start, "endIndex": end, "paragraph": {"elements": [
425 {"startIndex": start, "endIndex": end, "textRun": {"content": text}}
426 ]}})
427 }
428
429 fn fixture(segment: Option<&str>, spans: Vec<Value>) -> (Document, Scope) {
430 let mut tab = json!({"body": {"content": [paragraph(1, "😀 label\n")]},
431 "namedRanges": {"label": {"name": "label", "namedRanges": [
432 {"namedRangeId": "nr", "name": "label", "ranges": spans}
433 ]}}});
434 if let Some(segment) = segment {
435 tab["headers"] = json!({segment: {"content": [paragraph(0, "😀 label\n")]}});
436 }
437 let document = serde_json::from_value(json!({"tabs": [{"tabProperties": {"tabId": "parent"},
438 "documentTab": {}, "childTabs": [{"tabProperties": {"tabId": "child"}, "documentTab": tab}]}]})).unwrap();
439 (
440 document,
441 Scope {
442 tab_id: Some("child".into()),
443 segment_id: segment.map(str::to_owned),
444 },
445 )
446 }
447
448 fn replace() -> Mutation {
449 Mutation::Replace {
450 id: "nr".into(),
451 text: "😀".into(),
452 }
453 }
454
455 #[test]
456 fn nested_tab_and_segment_wire_shapes_are_bounded_and_leased() {
457 for segment in [None, Some("h")] {
458 let start = i64::from(segment.is_none());
459 let (document, scope) = fixture(
460 segment,
461 vec![json!({"startIndex": start, "endIndex": start + 2, "segmentId": segment})],
462 );
463 for mutation in [
464 Mutation::Create {
465 name: "label".into(),
466 start_index: start,
467 end_index: start + 2,
468 },
469 Mutation::Delete { id: "nr".into() },
470 replace(),
471 ] {
472 let (request, preview) = resolve(&document, &scope, &mutation).unwrap();
473 let wire = serde_json::to_value(
474 super::super::write_types::BatchUpdateDocumentRequest::new(request, "r"),
475 )
476 .unwrap();
477 assert_eq!(wire["writeControl"], json!({"requiredRevisionId": "r"}));
478 assert_eq!(wire["requests"].as_array().unwrap().len(), 1);
479 assert_eq!(preview.ranges[0].tab_id.as_deref(), Some("child"));
480 match mutation {
481 Mutation::Create { .. } => {
482 assert_eq!(
483 wire["requests"][0]["createNamedRange"]["range"]["tabId"],
484 "child"
485 );
486 assert_eq!(
487 wire["requests"][0]["createNamedRange"]["range"]["segmentId"],
488 json!(segment)
489 );
490 }
491 Mutation::Delete { .. } | Mutation::Replace { .. } => {
492 let op = if matches!(mutation, Mutation::Delete { .. }) {
493 "deleteNamedRange"
494 } else {
495 "replaceNamedRangeContent"
496 };
497 assert_eq!(wire["requests"][0][op]["namedRangeId"], "nr");
498 assert_eq!(
499 wire["requests"][0][op]["tabsCriteria"],
500 json!({"tabIds": ["child"]})
501 );
502 assert!(wire["requests"][0][op].get("name").is_none());
503 assert!(wire["requests"][0][op].get("namedRangeName").is_none());
504 }
505 }
506 }
507 let (_, preview) = resolve(&document, &scope, &replace()).unwrap();
508 assert_eq!(
509 (
510 preview.removed_chars,
511 preview.removed_bytes,
512 preview.inserted_chars,
513 preview.inserted_bytes
514 ),
515 (1, 4, 1, 4)
516 );
517 }
518 }
519
520 #[test]
521 fn name_limit_is_utf16_and_stripped_text_cannot_hide_deletion() {
522 let scope = Scope {
523 tab_id: None,
524 segment_id: None,
525 };
526 for (name, valid) in [
527 (String::new(), false),
528 ("😀".repeat(128), true),
529 ("😀".repeat(129), false),
530 ] {
531 assert_eq!(
532 Mutation::Create {
533 name,
534 start_index: 1,
535 end_index: 2
536 }
537 .validate(&scope)
538 .is_ok(),
539 valid
540 );
541 }
542 assert!(Mutation::Replace {
543 id: "nr".into(),
544 text: "\0\u{e000}".into()
545 }
546 .validate(&scope)
547 .is_err());
548 assert!(Mutation::Replace {
549 id: "nr".into(),
550 text: String::new()
551 }
552 .validate(&scope)
553 .is_ok());
554 assert!(Mutation::Delete { id: String::new() }
555 .validate(&scope)
556 .is_err());
557 }
558
559 #[test]
560 fn missing_duplicate_or_conflicting_scope_and_ids_are_refused() {
561 let (document, scope) = fixture(None, vec![json!({"startIndex": 1, "endIndex": 3})]);
562 for bad in [
563 Scope {
564 tab_id: None,
565 segment_id: None,
566 },
567 Scope {
568 tab_id: Some("missing".into()),
569 segment_id: None,
570 },
571 Scope {
572 tab_id: Some("child".into()),
573 segment_id: Some("missing".into()),
574 },
575 ] {
576 assert_eq!(
577 resolve(&document, &bad, &replace()),
578 Err(Error::InvalidScope)
579 );
580 }
581 let mut duplicate = document.clone();
582 duplicate.tabs.push(document.tabs[0].clone());
583 assert_eq!(
584 resolve(&duplicate, &scope, &replace()),
585 Err(Error::InvalidScope)
586 );
587 let mut no_identity = document.clone();
588 no_identity.tabs[0].tab_properties.as_mut().unwrap().tab_id = None;
589 assert_eq!(
590 resolve(&no_identity, &scope, &replace()),
591 Err(Error::InvalidScope)
592 );
593 let mut duplicate = document.clone();
594 let groups = &mut duplicate.tabs[0].child_tabs[0]
595 .document_tab
596 .as_mut()
597 .unwrap()
598 .named_ranges;
599 groups.insert("duplicate".into(), groups["label"].clone());
600 assert_eq!(
601 resolve(&duplicate, &scope, &replace()),
602 Err(Error::UnresolvedId)
603 );
604 assert_eq!(
605 resolve(
606 &document,
607 &scope,
608 &Mutation::Delete {
609 id: "absent".into()
610 }
611 ),
612 Err(Error::UnresolvedId)
613 );
614 for span in [
615 json!({"startIndex": 1, "endIndex": 3, "tabId": "parent"}),
616 json!({"startIndex": 1, "endIndex": 3, "segmentId": "h"}),
617 ] {
618 let (document, scope) = fixture(None, vec![span]);
619 assert_eq!(
620 resolve(&document, &scope, &Mutation::Delete { id: "nr".into() }),
621 Err(Error::ScopeMismatch)
622 );
623 }
624 }
625
626 #[test]
627 fn discontinuous_metadata_deletion_is_not_content_deletion() {
628 let (document, scope) = fixture(
629 None,
630 vec![
631 json!({"startIndex": 1, "endIndex": 3}),
632 json!({"startIndex": 4, "endIndex": 9}),
633 ],
634 );
635 assert_eq!(
636 resolve(&document, &scope, &replace()),
637 Err(Error::DiscontinuousRange)
638 );
639 let (request, preview) =
640 resolve(&document, &scope, &Mutation::Delete { id: "nr".into() }).unwrap();
641 assert!(matches!(request, DocsRequest::DeleteNamedRange(_)));
642 assert_eq!(preview.ranges.len(), 2);
643 assert_eq!(preview.removed_chars, 0);
644 }
645
646 #[test]
647 fn numeric_spans_reject_surrogate_splits_newlines_and_invalid_bounds() {
648 for (start, end) in [(1, 2), (2, 3), (0, 3), (1, 10), (1, 20), (5, 4)] {
649 let (document, scope) =
650 fixture(None, vec![json!({"startIndex": start, "endIndex": end})]);
651 assert_eq!(
652 resolve(&document, &scope, &replace()),
653 Err(Error::UnsafeRange),
654 "{start}..{end}"
655 );
656 }
657 let (document, scope) = fixture(None, vec![json!({"startIndex": 1})]);
658 assert_eq!(
659 resolve(&document, &scope, &replace()),
660 Err(Error::UnsafeRange)
661 );
662 }
663
664 #[test]
665 fn split_runs_keep_indices_but_objects_suggestions_and_gaps_are_refused() {
666 for kind in ["split", "object", "gap", "suggested", "bad-index", "toc"] {
667 let (mut document, scope) =
668 fixture(None, vec![json!({"startIndex": 4, "endIndex": 9})]);
669 let body = document.tabs[0].child_tabs[0]
670 .document_tab
671 .as_mut()
672 .unwrap()
673 .body
674 .as_mut()
675 .unwrap();
676 let first = json!({"startIndex": 1, "endIndex": 6, "textRun": {"content": "😀 la"}});
677 let mut second =
678 json!({"startIndex": 6, "endIndex": 10, "textRun": {"content": "bel\n"}});
679 match kind {
680 "suggested" => second["textRun"]["suggestedInsertionIds"] = json!(["s"]),
681 "gap" => second["startIndex"] = json!(7),
682 "bad-index" => second["endIndex"] = json!(11),
683 "object" => {
684 second = json!({"startIndex": 6, "endIndex": 10, "inlineObjectElement": {"inlineObjectId": "o"}});
685 }
686 _ => {}
687 }
688 body.content = serde_json::from_value(json!([{"startIndex": 1, "endIndex": 10, "paragraph": {"elements": [first, second]}}])).unwrap();
689 if kind == "toc" {
690 body.content = serde_json::from_value(json!([{"startIndex": 1, "endIndex": 10, "tableOfContents": {"content": [paragraph(1, "😀 label\n")]}}])).unwrap();
691 }
692 match kind {
693 "split" => assert!(resolve(&document, &scope, &replace()).is_ok()),
694 "suggested" => assert_eq!(
695 resolve(&document, &scope, &replace()),
696 Err(Error::SuggestedContent)
697 ),
698 _ => assert_eq!(
699 resolve(&document, &scope, &replace()),
700 Err(Error::UnsafeRange)
701 ),
702 }
703 }
704 }
705
706 #[test]
707 fn legacy_scope_omits_tab_criteria_and_nonbody_proto3_zero_is_valid() {
708 let document: Document = serde_json::from_value(json!({"body": {"content": [paragraph(1, "abc\n")]},
709 "footnotes": {"f": {"content": [paragraph(0, "abc\n")]}},
710 "namedRanges": {"label": {"namedRanges": [{"namedRangeId": "nr", "ranges": [{"endIndex": 3, "segmentId": "f"}]}]}}})).unwrap();
711 let scope = Scope {
712 tab_id: None,
713 segment_id: Some("f".into()),
714 };
715 let (request, preview) = resolve(&document, &scope, &replace()).unwrap();
716 assert_eq!(preview.ranges[0].start_index, Some(0));
717 let wire = serde_json::to_value(request).unwrap();
718 assert!(wire["replaceNamedRangeContent"]
719 .get("tabsCriteria")
720 .is_none());
721 }
722 #[test]
723 fn table_cells_preserve_suggestion_refusals_and_overlapping_indices_fail_closed() {
724 let (mut document, scope) = fixture(None, vec![json!({"startIndex": 3, "endIndex": 5})]);
725 let mut p = paragraph(3, "😀 label\n");
726 let body = document.tabs[0].child_tabs[0]
727 .document_tab
728 .as_mut()
729 .unwrap()
730 .body
731 .as_mut()
732 .unwrap();
733 body.content = serde_json::from_value(json!([{"startIndex": 1, "endIndex": 14,
734 "table": {"tableRows": [{"tableCells": [{"content": [p.clone()]}]}]}}]))
735 .unwrap();
736 assert!(resolve(&document, &scope, &replace()).is_ok());
737 p["paragraph"]["elements"][0]["textRun"]["suggestedDeletionIds"] = json!(["suggestion"]);
738 document.tabs[0].child_tabs[0]
739 .document_tab
740 .as_mut()
741 .unwrap()
742 .body
743 .as_mut()
744 .unwrap()
745 .content = serde_json::from_value(json!([{"startIndex": 1, "endIndex": 14,
746 "table": {"tableRows": [{"tableCells": [{"content": [p.clone()]}]}]}}]))
747 .unwrap();
748 assert_eq!(
749 resolve(&document, &scope, &replace()),
750 Err(Error::SuggestedContent)
751 );
752 let mut plain = paragraph(3, "😀 label\n");
753 document.tabs[0].child_tabs[0]
754 .document_tab
755 .as_mut()
756 .unwrap()
757 .body
758 .as_mut()
759 .unwrap()
760 .content = serde_json::from_value(json!([plain.clone(), plain.clone()])).unwrap();
761 assert_eq!(
762 resolve(&document, &scope, &replace()),
763 Err(Error::UnsafeRange)
764 );
765 plain.as_object_mut().unwrap().remove("startIndex");
766 document.tabs[0].child_tabs[0]
767 .document_tab
768 .as_mut()
769 .unwrap()
770 .body
771 .as_mut()
772 .unwrap()
773 .content = serde_json::from_value(json!([plain])).unwrap();
774 assert_eq!(
775 resolve(&document, &scope, &replace()),
776 Err(Error::UnsafeRange)
777 );
778 }
779
780 fn set_body(document: &mut Document, content: Value) {
783 document.tabs[0].child_tabs[0]
784 .document_tab
785 .as_mut()
786 .unwrap()
787 .body
788 .as_mut()
789 .unwrap()
790 .content = serde_json::from_value(content).unwrap();
791 }
792
793 #[test]
794 fn empty_scope_ids_and_unbounded_create_spans_are_refused_before_any_read() {
795 let delete = Mutation::Delete { id: "nr".into() };
796 for scope in [
797 Scope {
798 tab_id: Some(String::new()),
799 segment_id: None,
800 },
801 Scope {
802 tab_id: None,
803 segment_id: Some(String::new()),
804 },
805 ] {
806 assert_eq!(
807 delete.validate(&scope).unwrap_err(),
808 "scope IDs cannot be empty"
809 );
810 }
811 let scope = Scope {
812 tab_id: None,
813 segment_id: None,
814 };
815 for (start_index, end_index) in [(-1, 2), (3, 3), (4, 3)] {
816 let create = Mutation::Create {
817 name: "label".into(),
818 start_index,
819 end_index,
820 };
821 assert_eq!(
822 create.validate(&scope).unwrap_err(),
823 "named-range indices must describe a nonempty bounded span",
824 "{start_index}..{end_index}"
825 );
826 }
827 }
828
829 #[test]
830 fn create_refuses_a_span_that_is_not_safe_plain_text() {
831 let (document, scope) = fixture(None, vec![json!({"startIndex": 1, "endIndex": 3})]);
832 let create = Mutation::Create {
834 name: "label".into(),
835 start_index: 1,
836 end_index: 10,
837 };
838 assert_eq!(resolve(&document, &scope, &create), Err(Error::UnsafeRange));
839 }
840
841 #[test]
842 fn an_existing_range_with_no_spans_is_unsafe_to_mutate() {
843 let (document, scope) = fixture(None, vec![]);
844 for mutation in [Mutation::Delete { id: "nr".into() }, replace()] {
845 assert_eq!(
846 resolve(&document, &scope, &mutation),
847 Err(Error::UnsafeRange)
848 );
849 }
850 }
851
852 #[test]
855 fn elements_outside_the_span_are_skipped() {
856 let (mut document, scope) = fixture(None, vec![json!({"startIndex": 3, "endIndex": 5})]);
857 set_body(
858 &mut document,
859 json!([{"startIndex": 1, "endIndex": 10, "paragraph": {"elements": [
860 {"startIndex": 1, "endIndex": 2, "inlineObjectElement": {"inlineObjectId": "o"}},
861 {"startIndex": 2, "endIndex": 10, "textRun": {"content": "abcdefg\n"}},
862 ]}}]),
863 );
864 let (_, preview) = resolve(&document, &scope, &replace()).unwrap();
865 assert_eq!((preview.removed_chars, preview.removed_bytes), (2, 2));
866
867 let (mut document, scope) = fixture(None, vec![json!({"startIndex": 4, "endIndex": 6})]);
868 set_body(
869 &mut document,
870 json!([{"startIndex": 1, "endIndex": 10, "paragraph": {"elements": [
871 {"startIndex": 1, "endIndex": 3, "textRun": {"content": "ab"}},
872 {"startIndex": 3, "endIndex": 7, "textRun": {"content": "cdef"}},
873 {"startIndex": 7, "endIndex": 10, "textRun": {"content": "gh\n"}},
874 ]}}]),
875 );
876 let (_, preview) = resolve(&document, &scope, &replace()).unwrap();
877 assert_eq!((preview.removed_chars, preview.removed_bytes), (2, 2));
878 }
879
880 #[test]
881 fn an_index_gap_inside_the_span_is_refused() {
882 let (mut document, scope) = fixture(None, vec![json!({"startIndex": 4, "endIndex": 9})]);
883 set_body(
885 &mut document,
886 json!([{"startIndex": 1, "endIndex": 11, "paragraph": {"elements": [
887 {"startIndex": 1, "endIndex": 6, "textRun": {"content": "😀 la"}},
888 {"startIndex": 7, "endIndex": 11, "textRun": {"content": "bel\n"}},
889 ]}}]),
890 );
891 assert_eq!(
892 resolve(&document, &scope, &replace()),
893 Err(Error::UnsafeRange)
894 );
895 }
896
897 #[test]
900 fn a_paragraph_that_does_not_end_in_its_newline_run_is_refused() {
901 for (span_start, span_end, paragraph_end, content) in [
902 (1, 3, 10, "abcdefghi"),
904 (1, 3, 12, "abcdefgh\n"),
906 (6, 8, 12, "abcd\n"),
908 ] {
909 let (mut document, scope) = fixture(
910 None,
911 vec![json!({"startIndex": span_start, "endIndex": span_end})],
912 );
913 let run_end = 1 + content.encode_utf16().count();
914 set_body(
915 &mut document,
916 json!([{"startIndex": 1, "endIndex": paragraph_end, "paragraph": {"elements": [
917 {"startIndex": 1, "endIndex": run_end, "textRun": {"content": content}},
918 ]}}]),
919 );
920 assert_eq!(
921 resolve(&document, &scope, &replace()),
922 Err(Error::UnsafeRange),
923 "{content:?} in {span_start}..{span_end}"
924 );
925 }
926 }
927
928 #[test]
929 fn effects_and_bounded_rendering_distinguish_metadata_from_content_deletion() {
930 let (document, scope) = fixture(None, vec![json!({"startIndex": 1, "endIndex": 3})]);
931 let (_, metadata) =
932 resolve(&document, &scope, &Mutation::Delete { id: "nr".into() }).unwrap();
933 let (_, content) = resolve(
934 &document,
935 &scope,
936 &Mutation::Replace {
937 id: "nr".into(),
938 text: String::new(),
939 },
940 )
941 .unwrap();
942 assert_eq!(
943 serde_json::to_value(&metadata).unwrap()["effect"],
944 "delete-metadata"
945 );
946 assert_eq!(
947 serde_json::to_value(&content).unwrap()["effect"],
948 "delete-content"
949 );
950 assert!(metadata
951 .describe_spans()
952 .contains("[1, 3) UTF-16, tab child, segment body"));
953 let mut many = metadata;
954 many.ranges = vec![many.ranges[0].clone(); 51];
955 assert!(many.describe_spans().ends_with("1 more span(s)"));
956 assert_eq!(
957 serde_json::to_value(many).unwrap()["ranges"]
958 .as_array()
959 .unwrap()
960 .len(),
961 51
962 );
963 }
964}