Skip to main content

ragrig_gpui/editor/
protocol.rs

1//! The editor-agnostic container protocol of the roundtrip.
2//!
3//! Everything the agent is shown from the editor is wrapped in a versioned,
4//! self-describing container, so the chat prompt can tell the author's
5//! own text apart from retrieved literature:
6//!
7//! ```text
8//! <scico:v1:context source="creator">
9//! <scico:v1:document name="paper.md" path="docs/paper.md" cursor="42:7" chars="12345">
10//! …source…
11//! </scico:v1:document>
12//! <scico:v1:selection name="paper.md" range="120..480" chars="360" digest="b3:…">
13//! …the marked passage…
14//! </scico:v1:selection>
15//! </scico:v1:context>
16//! ```
17//!
18//! The result half is [`Replacement::extract`]: the agent answers with a
19//! fenced `scico:v1:replace` block, the host lifts it out of the chat and
20//! writes it back only when [`digest`] still matches the marked passage.
21
22use std::ops::Range;
23use std::path::{Path, PathBuf};
24
25/// Protocol version prefix shared by every marker.
26pub const PROTOCOL: &str = "scico:v1";
27/// Container for one block of context.
28pub const MARKER_CONTEXT: &str = "scico:v1:context";
29/// Container for one document inside a context block.
30pub const MARKER_DOCUMENT: &str = "scico:v1:document";
31/// Container for the author's bibliography.
32pub const MARKER_LIBRARY: &str = "scico:v1:library";
33/// Container for a passage the author marked for a question.
34pub const MARKER_SELECTION: &str = "scico:v1:selection";
35/// Marks where a truncated context was cut.
36pub const MARKER_TRUNCATED: &str = "scico:v1:truncated";
37/// Info string of the result-side replacement block.
38pub const MARKER_REPLACE: &str = "scico:v1:replace";
39
40/// One attached context carries at most this many characters.
41pub const MAX_CONTEXT_CHARS: usize = 100_000;
42
43/// Every marker the protocol defines, in prompt order.
44pub const MARKERS: &[&str] = &[
45    MARKER_CONTEXT,
46    MARKER_DOCUMENT,
47    MARKER_LIBRARY,
48    MARKER_SELECTION,
49    MARKER_TRUNCATED,
50    MARKER_REPLACE,
51];
52
53/// The markers `prompt` does not mention.
54#[must_use]
55pub fn missing_markers(prompt: &str) -> Vec<&'static str> {
56    MARKERS
57        .iter()
58        .copied()
59        .filter(|marker| !prompt.contains(marker))
60        .collect()
61}
62
63/// The editor facts a [`RoundtripContext`] is built from.
64#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
65pub struct EditorBuffer<'a> {
66    /// Display name of the document (`draft.md`, `Untitled-1`).
67    pub name: &'a str,
68    /// Its path, when it is backed by a file.
69    pub path: Option<&'a Path>,
70    /// Caret position (line, column, 0-based).
71    pub cursor: (usize, usize),
72    /// The document source.
73    pub content: &'a str,
74}
75
76impl EditorBuffer<'_> {
77    /// Whether the buffer carries text worth attaching.
78    #[must_use]
79    pub fn has_text(&self) -> bool {
80        !self.content.trim().is_empty()
81    }
82}
83
84/// Which region of the editor a context came from.
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86pub enum RoundtripKind {
87    /// The active document.
88    Document,
89    /// The author's bibliography.
90    Library,
91    /// A passage the author marked.
92    Selection,
93}
94
95impl RoundtripKind {
96    /// The container marker for this kind.
97    #[must_use]
98    pub fn marker(self) -> &'static str {
99        match self {
100            Self::Document => MARKER_DOCUMENT,
101            Self::Library => MARKER_LIBRARY,
102            Self::Selection => MARKER_SELECTION,
103        }
104    }
105}
106
107/// What the transcript needs to note an attached context.
108#[derive(Clone, Debug, PartialEq, Eq)]
109pub struct ContextNote {
110    /// Which region was attached.
111    pub kind: RoundtripKind,
112    /// Display name of the document the region came from.
113    pub name: String,
114    /// Characters in the full text (before truncation).
115    pub chars: usize,
116    /// Whether the model only saw an excerpt.
117    pub truncated: bool,
118    /// Characters the model actually saw.
119    pub shown: usize,
120}
121
122/// One region of the editor attached to a query.
123#[derive(Clone, Debug, PartialEq, Eq)]
124pub struct RoundtripContext {
125    kind: RoundtripKind,
126    name: String,
127    path: Option<PathBuf>,
128    cursor: Option<(usize, usize)>,
129    range: Option<Range<usize>>,
130    digest: String,
131    chars: usize,
132    truncated: bool,
133    limit: usize,
134    text: String,
135}
136
137impl RoundtripContext {
138    /// The active document, or `None` when there is nothing worth attaching.
139    #[must_use]
140    pub fn document(buffer: EditorBuffer<'_>) -> Option<Self> {
141        Self::document_with_limit(buffer, MAX_CONTEXT_CHARS)
142    }
143
144    /// The active document, shortened to at most `limit` characters when the
145    /// model window is smaller than the protocol's hard cap.
146    #[must_use]
147    pub fn document_with_limit(buffer: EditorBuffer<'_>, limit: usize) -> Option<Self> {
148        if !buffer.has_text() {
149            return None;
150        }
151        let limit = limit.clamp(1, MAX_CONTEXT_CHARS);
152        let (text, chars, truncated) = excerpt(buffer.content, limit);
153        Some(Self {
154            kind: RoundtripKind::Document,
155            name: buffer.name.to_string(),
156            path: buffer.path.map(Path::to_path_buf),
157            cursor: Some(buffer.cursor),
158            range: None,
159            digest: digest(buffer.content),
160            chars,
161            truncated,
162            limit,
163            text,
164        })
165    }
166
167    /// The author's bibliography, attached as a whole.
168    #[must_use]
169    pub fn library(path: &Path, content: &str) -> Option<Self> {
170        Self::library_with_limit(path, content, MAX_CONTEXT_CHARS)
171    }
172
173    /// The bibliography, shortened to at most `limit` characters.
174    #[must_use]
175    pub fn library_with_limit(path: &Path, content: &str, limit: usize) -> Option<Self> {
176        if content.trim().is_empty() {
177            return None;
178        }
179        let limit = limit.clamp(1, MAX_CONTEXT_CHARS);
180        let (text, chars, truncated) = excerpt(content, limit);
181        Some(Self {
182            kind: RoundtripKind::Library,
183            name: path
184                .file_name()
185                .map(|name| name.to_string_lossy().into_owned())
186                .unwrap_or_else(|| path.display().to_string()),
187            path: Some(path.to_path_buf()),
188            cursor: None,
189            range: None,
190            digest: digest(content),
191            chars,
192            truncated,
193            limit,
194            text,
195        })
196    }
197
198    /// A passage the author marked, or `None` when the marked text is blank.
199    ///
200    /// `range` is char-indexed and document-relative, matching
201    /// [`replace_at`]. The digest covers the full marked text, so an answer
202    /// stays checkable even when the model only saw a truncated excerpt.
203    #[must_use]
204    pub fn selection(buffer: EditorBuffer<'_>, range: Range<usize>) -> Option<Self> {
205        Self::selection_with_limit(buffer, range, MAX_CONTEXT_CHARS)
206    }
207
208    /// A marked passage, shortened to at most `limit` characters.
209    #[must_use]
210    pub fn selection_with_limit(
211        buffer: EditorBuffer<'_>,
212        range: Range<usize>,
213        limit: usize,
214    ) -> Option<Self> {
215        debug_assert!(range.start <= range.end, "range must be ordered: {range:?}");
216        let total = buffer.content.chars().count();
217        let start = range.start.min(total);
218        let end = range.end.min(total).max(start);
219        let marked: String = buffer
220            .content
221            .chars()
222            .skip(start)
223            .take(end - start)
224            .collect();
225        if marked.trim().is_empty() {
226            return None;
227        }
228        let limit = limit.clamp(1, MAX_CONTEXT_CHARS);
229        let (text, chars, truncated) = excerpt(&marked, limit);
230        Some(Self {
231            kind: RoundtripKind::Selection,
232            name: buffer.name.to_string(),
233            path: buffer.path.map(Path::to_path_buf),
234            cursor: None,
235            range: Some(start..end),
236            digest: digest(&marked),
237            chars,
238            truncated,
239            limit,
240            text,
241        })
242    }
243
244    /// The raw passage text, without the protocol container.
245    ///
246    /// Enriched retrieval uses this to embed a marked passage together with
247    /// the question.
248    #[must_use]
249    pub fn text(&self) -> &str {
250        &self.text
251    }
252
253    /// The container as the model sees it.
254    #[must_use]
255    pub fn container(&self) -> String {
256        let marker = self.kind.marker();
257        let mut out = format!(
258            "<{marker}{}>\n{}\n",
259            self.attributes(),
260            self.text.trim_end()
261        );
262        if self.truncated {
263            out.push_str(&format!(
264                "<{MARKER_TRUNCATED} shown=\"{}\" total=\"{}\"/>\n",
265                self.text.chars().count(),
266                self.chars
267            ));
268        }
269        out.push_str(&format!("</{marker}>"));
270        out
271    }
272
273    /// A pending attachment whose content is this container.
274    ///
275    /// The worker's `ScicoAttach` passes pre-built containers through, so
276    /// the attributes survive unchanged.
277    #[must_use]
278    pub fn attachment_label(&self) -> String {
279        match self.kind {
280            RoundtripKind::Document => self.name.clone(),
281            RoundtripKind::Library => self.name.clone(),
282            RoundtripKind::Selection => format!("selection {}", self.name),
283        }
284    }
285
286    /// The facts the transcript note is built from.
287    #[must_use]
288    pub fn note(&self) -> ContextNote {
289        ContextNote {
290            kind: self.kind,
291            name: self.name.clone(),
292            chars: self.chars,
293            truncated: self.truncated,
294            shown: self.text.chars().count(),
295        }
296    }
297
298    /// Characters in the full text (before truncation).
299    #[must_use]
300    pub fn chars(&self) -> usize {
301        self.chars
302    }
303
304    /// A copy shortened to at most `limit` characters.
305    ///
306    /// Used when a small model window cannot fit the protocol's hard cap; the
307    /// full length and digest stay attached to the shortened container.
308    #[must_use]
309    pub fn shortened(&self, limit: usize) -> Self {
310        let limit = limit.clamp(1, MAX_CONTEXT_CHARS);
311        let (text, _, _) = excerpt(&self.text, limit);
312        let mut shortened = self.clone();
313        shortened.text = text;
314        shortened.truncated = self.chars > limit;
315        shortened.limit = limit;
316        shortened
317    }
318
319    /// The char range of the marked passage, for selections only.
320    #[must_use]
321    pub fn range(&self) -> Option<Range<usize>> {
322        self.range.clone()
323    }
324
325    /// Identity of the full text, for write-back guards.
326    #[must_use]
327    pub fn digest(&self) -> &str {
328        &self.digest
329    }
330
331    /// Which part of the editor this context came from.
332    #[must_use]
333    pub fn kind(&self) -> RoundtripKind {
334        self.kind
335    }
336
337    fn attributes(&self) -> String {
338        let mut out = format!(" name=\"{}\"", attribute(&self.name));
339        if let Some(path) = &self.path {
340            out.push_str(&format!(
341                " path=\"{}\"",
342                attribute(&path.display().to_string())
343            ));
344        }
345        if let Some((line, column)) = self.cursor {
346            out.push_str(&format!(" cursor=\"{line}:{column}\""));
347        }
348        if let Some(range) = &self.range {
349            out.push_str(&format!(" range=\"{}..{}\"", range.start, range.end));
350        }
351        out.push_str(&format!(" chars=\"{}\"", self.chars));
352        if self.kind == RoundtripKind::Selection {
353            out.push_str(&format!(" digest=\"{}\"", self.digest));
354        }
355        out
356    }
357}
358
359/// The note a transcript shows for an attached context.
360#[must_use]
361pub fn context_note(note: &ContextNote) -> String {
362    let label = match note.kind {
363        RoundtripKind::Document => "Document attached",
364        RoundtripKind::Library => "Library attached",
365        RoundtripKind::Selection => "Marked region attached",
366    };
367    if note.truncated {
368        format!(
369            "{label}: {} (first {} of {} chars)",
370            note.name, note.shown, note.chars
371        )
372    } else {
373        format!("{label}: {} ({} chars)", note.name, note.chars)
374    }
375}
376
377/// Cut `text` to [`MAX_CONTEXT_CHARS`], with its full length and whether the
378/// cut happened — char-counted, so multi-byte text never splits a codepoint.
379fn excerpt(text: &str, limit: usize) -> (String, usize, bool) {
380    let mut out = String::new();
381    let mut chars = 0usize;
382    for character in text.chars() {
383        chars += 1;
384        if chars <= limit {
385            out.push(character);
386        }
387    }
388    (out, chars, chars > limit)
389}
390
391/// A short, stable identity for one version of a text.
392#[must_use]
393pub fn digest(text: &str) -> String {
394    let hash = blake3::hash(text.as_bytes()).to_hex();
395    format!("b3:{}", &hash.as_str()[..16])
396}
397
398/// Replace the character range `range` of `content` with `replacement`.
399///
400/// Char-indexed, so multi-byte text and emoji never split a codepoint. A range
401/// past the end clamps to the length.
402#[must_use]
403pub fn replace_at(content: &str, range: Range<usize>, replacement: &str) -> String {
404    debug_assert!(range.start <= range.end, "range must be ordered: {range:?}");
405    let mut out = String::with_capacity(content.len() + replacement.len());
406    out.extend(content.chars().take(range.start));
407    out.push_str(replacement);
408    out.extend(content.chars().skip(range.end));
409    out
410}
411
412/// The byte range of a char range, clamped to `text`.
413#[must_use]
414pub fn char_range_to_bytes(text: &str, range: Range<usize>) -> Range<usize> {
415    let start = text
416        .char_indices()
417        .nth(range.start)
418        .map_or(text.len(), |(offset, _)| offset);
419    let end = text
420        .char_indices()
421        .nth(range.end)
422        .map_or(text.len(), |(offset, _)| offset);
423    start..end.max(start)
424}
425
426/// The byte range of a marked passage, but only when its digest still
427/// matches `guard`.
428///
429/// This is the write-back guard: the agent may have started from a version
430/// the author has since edited, and a replacement must never land in text it
431/// was not written for.
432#[must_use]
433pub fn verified_bytes(content: &str, range: Range<usize>, guard: &str) -> Option<Range<usize>> {
434    let total = content.chars().count();
435    let start = range.start.min(total);
436    let end = range.end.min(total).max(start);
437    let marked: String = content.chars().skip(start).take(end - start).collect();
438    (digest(&marked) == guard).then(|| char_range_to_bytes(content, start..end))
439}
440
441/// The char index of a byte offset.
442#[must_use]
443pub fn byte_to_char_index(text: &str, byte: usize) -> usize {
444    text[..byte.min(text.len())].chars().count()
445}
446
447/// The result half of the protocol: one replacement an answer asked for.
448#[derive(Clone, Debug, PartialEq, Eq)]
449pub struct Replacement {
450    /// The passage the answer names in its `target="a..b"` attribute.
451    ///
452    /// Informational: the host applies at the range the author actually
453    /// marked, never at what the model says.
454    pub target: Option<Range<usize>>,
455    /// The finished text for the passage.
456    pub body: String,
457}
458
459impl Replacement {
460    /// Lift every replacement block out of `answer`.
461    ///
462    /// Returns the prose with the blocks removed and the blocks in document
463    /// order. Only fenced blocks whose info string starts with
464    /// [`MARKER_REPLACE`] are lifted; every other code block stays in the
465    /// prose.
466    #[must_use]
467    pub fn extract(answer: &str) -> Option<(String, Vec<Self>)> {
468        let arena = comrak::Arena::new();
469        let root = comrak::parse_document(&arena, answer, &comrak::Options::default());
470        let mut blocks = Vec::new();
471        let mut fences: Vec<std::ops::RangeInclusive<usize>> = Vec::new();
472        for node in root.descendants() {
473            let data = node.data.borrow();
474            let comrak::nodes::NodeValue::CodeBlock(code) = &data.value else {
475                continue;
476            };
477            if info_marker(&code.info) != Some(MARKER_REPLACE) {
478                continue;
479            }
480            blocks.push(Self {
481                target: target_attribute(&code.info),
482                body: code
483                    .literal
484                    .strip_suffix('\n')
485                    .unwrap_or(&code.literal)
486                    .to_string(),
487            });
488            fences.push(data.sourcepos.start.line..=data.sourcepos.end.line);
489        }
490        if blocks.is_empty() {
491            return None;
492        }
493        let mut prose = String::new();
494        for (index, line) in answer.lines().enumerate() {
495            if fences.iter().any(|fence| fence.contains(&(index + 1))) {
496                continue;
497            }
498            if line.trim().is_empty() && prose.ends_with("\n\n") {
499                continue;
500            }
501            prose.push_str(line);
502            prose.push('\n');
503        }
504        Some((prose.trim().to_string(), blocks))
505    }
506}
507
508/// The first word of a code block's info string.
509fn info_marker(info: &str) -> Option<&str> {
510    info.split_whitespace().next()
511}
512
513/// The `target="a..b"` attribute of a replacement info string.
514fn target_attribute(info: &str) -> Option<Range<usize>> {
515    let value = info
516        .split_once("target=")
517        .map(|(_, rest)| rest.trim_start())?
518        .strip_prefix('"')?;
519    let value = value.split('"').next()?;
520    let (start, end) = value.split_once("..")?;
521    Some(start.trim().parse().ok()?..end.trim().parse().ok()?)
522}
523
524/// Strip everything that would break a quoted XML-like attribute.
525fn attribute(value: &str) -> String {
526    value
527        .chars()
528        .map(|c| match c {
529            '"' | '<' | '>' | '\n' | '\r' | '\t' => ' ',
530            other => other,
531        })
532        .collect::<String>()
533        .split_whitespace()
534        .collect::<Vec<_>>()
535        .join(" ")
536        .trim()
537        .to_string()
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    fn buffer<'a>(name: &'a str, path: Option<&'a Path>, content: &'a str) -> EditorBuffer<'a> {
545        EditorBuffer {
546            name,
547            path,
548            cursor: (2, 3),
549            content,
550        }
551    }
552
553    #[test]
554    fn a_document_container_carries_name_path_cursor_and_length() {
555        let path = Path::new("/tmp/draft.md");
556        let context = RoundtripContext::document(buffer("draft.md", Some(path), "hello world"))
557            .expect("non-empty document");
558        let container = context.container();
559
560        assert!(container.starts_with("<scico:v1:document"));
561        assert!(container.contains("name=\"draft.md\""));
562        assert!(container.contains("path=\"/tmp/draft.md\""));
563        assert!(container.contains("cursor=\"2:3\""));
564        assert!(container.contains("chars=\"11\""));
565        assert!(!container.contains("digest="));
566        assert!(container.ends_with("</scico:v1:document>"));
567    }
568
569    #[test]
570    fn a_blank_document_attaches_nothing() {
571        assert!(RoundtripContext::document(buffer("draft.md", None, " \n ")).is_none());
572    }
573
574    #[test]
575    fn a_selection_is_char_indexed_and_multibyte_safe() {
576        let text = "aé😀z";
577        let context =
578            RoundtripContext::selection(buffer("draft.md", None, text), 1..3).expect("selection");
579        assert_eq!(context.range(), Some(1..3));
580        assert!(context.container().contains("\né😀\n"));
581        assert!(context.container().contains("range=\"1..3\""));
582        assert!(context.container().contains(context.digest()));
583    }
584
585    #[test]
586    fn a_blank_selection_attaches_nothing() {
587        assert!(RoundtripContext::selection(buffer("draft.md", None, "abc"), 1..1).is_none());
588    }
589
590    #[test]
591    fn a_library_container_has_no_cursor() {
592        let context = RoundtripContext::library(Path::new("/tmp/library.bib"), "@article{A}")
593            .expect("library");
594        let container = context.container();
595        assert!(container.starts_with("<scico:v1:library"));
596        assert!(!container.contains("cursor="));
597        assert!(container.contains("name=\"library.bib\""));
598    }
599
600    #[test]
601    fn an_oversized_document_is_truncated_with_a_marker() {
602        let text = "a".repeat(MAX_CONTEXT_CHARS + 10);
603        let context =
604            RoundtripContext::document(buffer("draft.md", None, &text)).expect("document");
605        assert!(context.truncated);
606        assert_eq!(context.chars(), MAX_CONTEXT_CHARS + 10);
607        assert!(context.container().contains(&format!(
608            "<{MARKER_TRUNCATED} shown=\"{MAX_CONTEXT_CHARS}\""
609        )));
610    }
611
612    #[test]
613    fn attribute_values_cannot_break_the_tag() {
614        let context =
615            RoundtripContext::document(buffer("a\"<b>\nc", None, "text")).expect("document");
616        assert!(context.container().contains("name=\"a b c\""));
617    }
618
619    #[test]
620    fn replace_at_is_char_indexed_and_clamps() {
621        assert_eq!(replace_at("aé😀z", 1..3, "X"), "aXz");
622        assert_eq!(replace_at("abc", 2..99, "Z"), "abZ");
623        assert_eq!(replace_at("abc", 1..1, "Z"), "aZbc");
624    }
625
626    #[test]
627    fn char_and_byte_ranges_round_trip() {
628        let text = "aé😀z";
629        assert_eq!(char_range_to_bytes(text, 1..3), 1..7);
630        assert_eq!(byte_to_char_index(text, 7), 3);
631        assert_eq!(char_range_to_bytes(text, 2..99), 3..8);
632    }
633
634    #[test]
635    fn a_verified_range_requires_the_marked_version() {
636        let text = "aé😀z";
637        let guard = digest("é😀");
638        assert_eq!(verified_bytes(text, 1..3, &guard), Some(1..7));
639        assert_eq!(verified_bytes(text, 1..3, &digest("changed")), None);
640        assert_eq!(verified_bytes(text, 2..99, &digest("😀z")), Some(3..8));
641    }
642
643    #[test]
644    fn digests_are_stable_and_distinguish_versions() {
645        assert_eq!(digest("abc"), digest("abc"));
646        assert_ne!(digest("abc"), digest("abd"));
647        assert!(digest("abc").starts_with("b3:"));
648        assert_eq!(digest("abc").len(), 19);
649    }
650
651    #[test]
652    fn a_replacement_block_is_lifted_out_of_the_answer() {
653        let answer =
654            "Here is the shorter version.\n\n```scico:v1:replace target=\"4..9\"\nnew text\n```\n";
655        let (prose, blocks) = Replacement::extract(answer).expect("a replacement block");
656        assert_eq!(prose, "Here is the shorter version.");
657        assert_eq!(blocks.len(), 1);
658        assert_eq!(blocks[0].target, Some(4..9));
659        assert_eq!(blocks[0].body, "new text");
660    }
661
662    #[test]
663    fn a_block_before_the_prose_is_lifted_too() {
664        let answer = "```scico:v1:replace\nbody\n```\n\nProse follows.";
665        let (prose, blocks) = Replacement::extract(answer).expect("a replacement block");
666        assert_eq!(prose, "Prose follows.");
667        assert_eq!(blocks[0].body, "body");
668        assert_eq!(blocks[0].target, None);
669    }
670
671    #[test]
672    fn only_our_blocks_are_lifted() {
673        let answer = "```rust\nfn main() {}\n```";
674        assert_eq!(Replacement::extract(answer), None);
675    }
676
677    #[test]
678    fn a_plain_answer_carries_no_block() {
679        assert_eq!(Replacement::extract("just prose"), None);
680    }
681
682    #[test]
683    fn an_empty_block_is_still_lifted() {
684        let (prose, blocks) =
685            Replacement::extract("Prose.\n\n```scico:v1:replace\n```").expect("block");
686        assert_eq!(prose, "Prose.");
687        assert_eq!(blocks[0].body, "");
688    }
689
690    #[test]
691    fn several_blocks_come_back_in_order() {
692        let answer = "```scico:v1:replace target=\"0..1\"\none\n```\n\
693                      ```scico:v1:replace target=\"1..2\"\ntwo\n```";
694        let (_, blocks) = Replacement::extract(answer).expect("blocks");
695        assert_eq!(blocks.len(), 2);
696        assert_eq!(blocks[0].body, "one");
697        assert_eq!(blocks[1].body, "two");
698    }
699
700    #[test]
701    fn a_garbled_target_is_not_a_target() {
702        let (_, blocks) =
703            Replacement::extract("```scico:v1:replace target=\"x..y\"\nbody\n```").expect("block");
704        assert_eq!(blocks[0].target, None);
705    }
706
707    #[test]
708    fn a_custom_limit_shortens_a_context_and_keeps_the_full_length() {
709        let text = "a".repeat(500);
710        let context = RoundtripContext::document_with_limit(buffer("draft.md", None, &text), 120)
711            .expect("document");
712        assert!(context.truncated);
713        assert_eq!(context.note().shown, 120);
714        assert_eq!(context.note().chars, 500);
715        assert!(context.container().contains("shown=\"120\" total=\"500\""));
716    }
717
718    #[test]
719    fn shortened_keeps_the_digest_and_respects_the_new_limit() {
720        let selected =
721            RoundtripContext::selection(buffer("draft.md", None, &"x".repeat(400)), 0..400)
722                .expect("selection");
723        let short = selected.shortened(90);
724        assert_eq!(short.digest(), selected.digest());
725        assert!(short.truncated);
726        assert_eq!(short.note().shown, 90);
727        assert!(short.container().contains("chars=\"400\""));
728    }
729
730    #[test]
731    fn notes_name_the_region_and_its_size() {
732        let context =
733            RoundtripContext::document(buffer("draft.md", None, "hello")).expect("document");
734        assert_eq!(
735            context_note(&context.note()),
736            "Document attached: draft.md (5 chars)"
737        );
738        assert_eq!(context.attachment_label(), "draft.md");
739    }
740}