1use std::ops::Range;
23use std::path::{Path, PathBuf};
24
25pub const PROTOCOL: &str = "scico:v1";
27pub const MARKER_CONTEXT: &str = "scico:v1:context";
29pub const MARKER_DOCUMENT: &str = "scico:v1:document";
31pub const MARKER_LIBRARY: &str = "scico:v1:library";
33pub const MARKER_SELECTION: &str = "scico:v1:selection";
35pub const MARKER_TRUNCATED: &str = "scico:v1:truncated";
37pub const MARKER_REPLACE: &str = "scico:v1:replace";
39
40pub const MAX_CONTEXT_CHARS: usize = 100_000;
42
43pub const MARKERS: &[&str] = &[
45 MARKER_CONTEXT,
46 MARKER_DOCUMENT,
47 MARKER_LIBRARY,
48 MARKER_SELECTION,
49 MARKER_TRUNCATED,
50 MARKER_REPLACE,
51];
52
53#[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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
65pub struct EditorBuffer<'a> {
66 pub name: &'a str,
68 pub path: Option<&'a Path>,
70 pub cursor: (usize, usize),
72 pub content: &'a str,
74}
75
76impl EditorBuffer<'_> {
77 #[must_use]
79 pub fn has_text(&self) -> bool {
80 !self.content.trim().is_empty()
81 }
82}
83
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86pub enum RoundtripKind {
87 Document,
89 Library,
91 Selection,
93}
94
95impl RoundtripKind {
96 #[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#[derive(Clone, Debug, PartialEq, Eq)]
109pub struct ContextNote {
110 pub kind: RoundtripKind,
112 pub name: String,
114 pub chars: usize,
116 pub truncated: bool,
118 pub shown: usize,
120}
121
122#[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 #[must_use]
140 pub fn document(buffer: EditorBuffer<'_>) -> Option<Self> {
141 Self::document_with_limit(buffer, MAX_CONTEXT_CHARS)
142 }
143
144 #[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 #[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 #[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 #[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 #[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 #[must_use]
249 pub fn text(&self) -> &str {
250 &self.text
251 }
252
253 #[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 #[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 #[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 #[must_use]
300 pub fn chars(&self) -> usize {
301 self.chars
302 }
303
304 #[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 #[must_use]
321 pub fn range(&self) -> Option<Range<usize>> {
322 self.range.clone()
323 }
324
325 #[must_use]
327 pub fn digest(&self) -> &str {
328 &self.digest
329 }
330
331 #[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#[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
377fn 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#[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#[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#[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#[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#[must_use]
443pub fn byte_to_char_index(text: &str, byte: usize) -> usize {
444 text[..byte.min(text.len())].chars().count()
445}
446
447#[derive(Clone, Debug, PartialEq, Eq)]
449pub struct Replacement {
450 pub target: Option<Range<usize>>,
455 pub body: String,
457}
458
459impl Replacement {
460 #[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
508fn info_marker(info: &str) -> Option<&str> {
510 info.split_whitespace().next()
511}
512
513fn 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
524fn 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}