1use serde::{Deserialize, Serialize};
8
9pub mod policy;
10
11pub use policy::{Admission, EvictionStrategy, Volatility};
12
13#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
20#[serde(tag = "type")]
21pub enum EntryKind {
22 #[default]
24 Text,
25 UserMessage,
27 AssistantTurn {
29 tool_calls: Vec<SerializedToolCall>,
33 },
34 ToolResult {
36 tool_call_id: String,
39 tool_name: String,
41 is_error: bool,
44 },
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49pub struct SerializedToolCall {
50 pub id: String,
53 pub name: String,
55 pub arguments: serde_json::Value,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub thought_signature: Option<String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
70pub enum RegionKind {
71 Pinned,
78
79 SlidingWindow {
86 max_items: usize,
88 eviction_strategy: EvictionStrategy,
90 },
91
92 Temporary,
98
99 Compacting {
105 threshold_tokens: usize,
107 },
108
109 Clearable,
116
117 CompactHistory {
124 source_region: String,
126 },
127
128 HashMap {
132 max_entries: Option<usize>,
134 },
135
136 Checklist,
146
147 Custom {
163 script: String,
165 persistent: bool,
168 },
169}
170
171impl PartialEq for RegionKind {
172 #[inline(never)]
173 fn eq(&self, other: &Self) -> bool {
174 match (self, other) {
175 (Self::Pinned, Self::Pinned)
176 | (Self::Temporary, Self::Temporary)
177 | (Self::Clearable, Self::Clearable) => true,
178 (
179 Self::SlidingWindow {
180 max_items: a,
181 eviction_strategy: sa,
182 },
183 Self::SlidingWindow {
184 max_items: b,
185 eviction_strategy: sb,
186 },
187 ) => a == b && sa == sb,
188 (
189 Self::Compacting {
190 threshold_tokens: a,
191 },
192 Self::Compacting {
193 threshold_tokens: b,
194 },
195 ) => a == b,
196 (
197 Self::CompactHistory { source_region: a },
198 Self::CompactHistory { source_region: b },
199 ) => a == b,
200 (Self::HashMap { max_entries: a }, Self::HashMap { max_entries: b }) => a == b,
201 (Self::Checklist, Self::Checklist) => true,
202 (
203 Self::Custom {
204 script: a,
205 persistent: pa,
206 },
207 Self::Custom {
208 script: b,
209 persistent: pb,
210 },
211 ) => a == b && pa == pb,
212 _ => false,
213 }
214 }
215}
216impl Eq for RegionKind {}
217
218#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct ChecklistItem {
226 pub id: usize,
228 pub text: String,
230 pub done: bool,
232 pub note: Option<String>,
234}
235
236const ITEM_ID: &str = "checklist_id";
238const ITEM_DONE: &str = "checklist_done";
240const ITEM_NOTE: &str = "checklist_note";
242
243impl RegionEntry {
244 pub fn as_checklist_item(&self) -> Option<ChecklistItem> {
246 let meta = self.metadata.as_ref()?;
247 Some(ChecklistItem {
248 id: meta.get(ITEM_ID)?.as_u64()? as usize,
249 text: self.content.clone(),
250 done: meta
251 .get(ITEM_DONE)
252 .and_then(|v| v.as_bool())
253 .unwrap_or(false),
254 note: meta
255 .get(ITEM_NOTE)
256 .and_then(|v| v.as_str())
257 .map(str::to_string),
258 })
259 }
260}
261
262impl Region {
263 pub fn checklist_items(&self) -> Vec<ChecklistItem> {
265 self.content
266 .iter()
267 .filter_map(RegionEntry::as_checklist_item)
268 .collect()
269 }
270
271 pub fn open_checklist_items(&self) -> Vec<ChecklistItem> {
273 self.checklist_items()
274 .into_iter()
275 .filter(|i| !i.done)
276 .collect()
277 }
278
279 pub fn add_checklist_item(
286 &mut self,
287 text: String,
288 tokens: usize,
289 ) -> crate::error::Result<usize> {
290 let id = self
291 .checklist_items()
292 .iter()
293 .map(|i| i.id)
294 .max()
295 .unwrap_or(0)
296 + 1;
297 self.add_entry_with_metadata(
298 text,
299 tokens,
300 serde_json::json!({ ITEM_ID: id, ITEM_DONE: false }),
301 )?;
302 Ok(id)
303 }
304
305 pub fn complete_checklist_item(&mut self, id: usize) -> bool {
307 self.set_item_field(id, ITEM_DONE, serde_json::Value::Bool(true))
308 }
309
310 pub fn note_checklist_item(&mut self, id: usize, note: &str) -> bool {
312 self.set_item_field(id, ITEM_NOTE, serde_json::Value::String(note.to_string()))
313 }
314
315 fn set_item_field(&mut self, id: usize, key: &str, value: serde_json::Value) -> bool {
317 for entry in &mut self.content {
318 let is_target = entry
319 .metadata
320 .as_ref()
321 .and_then(|m| m.get(ITEM_ID))
322 .and_then(serde_json::Value::as_u64)
323 .is_some_and(|found| found as usize == id);
324 if is_target && let Some(serde_json::Value::Object(meta)) = entry.metadata.as_mut() {
325 meta.insert(key.to_string(), value);
326 return true;
327 }
328 }
329 false
330 }
331
332 pub fn render_checklist(&self) -> String {
338 let items = self.checklist_items();
339 if items.is_empty() {
340 return String::new();
341 }
342 let (open, done): (Vec<_>, Vec<_>) = items.into_iter().partition(|i| !i.done);
343 let mut out = String::new();
344 for item in open.iter().chain(done.iter()) {
345 let box_ = match item.done {
346 true => "[x]",
347 false => "[ ]",
348 };
349 out.push_str(&format!("{box_} {} {}", item.id, item.text));
350 if let Some(note) = &item.note {
351 out.push_str(&format!("\n note: {note}"));
352 }
353 out.push('\n');
354 }
355 format!(
356 "Checklist ({} open, {} done):\n{}",
357 open.len(),
358 done.len(),
359 out.trim_end()
360 )
361 }
362}
363
364impl RegionKind {
365 pub fn cache_hint(&self) -> crate::cache::CacheHint {
367 match self {
368 RegionKind::Pinned | RegionKind::CompactHistory { .. } => {
369 crate::cache::CacheHint::Always
370 }
371 RegionKind::Compacting { .. } => crate::cache::CacheHint::UntilChanged,
372 RegionKind::SlidingWindow { .. } => crate::cache::CacheHint::SlidingPrefix {
373 stable_fraction: 0.75,
374 },
375 RegionKind::HashMap { .. } => crate::cache::CacheHint::UntilChanged,
376 RegionKind::Checklist => crate::cache::CacheHint::UntilChanged,
379 RegionKind::Temporary | RegionKind::Clearable => crate::cache::CacheHint::Never,
380 RegionKind::Custom { persistent, .. } => {
384 if *persistent {
385 crate::cache::CacheHint::Always
386 } else {
387 crate::cache::CacheHint::UntilChanged
388 }
389 }
390 }
391 }
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
399pub struct Region {
400 pub name: String,
402
403 pub kind: RegionKind,
405
406 pub content: Vec<RegionEntry>,
408
409 pub max_tokens: usize,
411
412 pub current_tokens: usize,
414
415 pub schema: Option<RegionSchema>,
417
418 #[serde(default, skip_serializing_if = "Option::is_none")]
420 pub taint: Option<crate::taint::RegionTaint>,
421
422 #[serde(default)]
426 pub needs_message_compaction: bool,
427
428 #[serde(default = "crate::region::default_true")]
434 pub summarizable: bool,
435
436 #[serde(default)]
438 pub admission: Admission,
439
440 #[serde(default)]
444 pub volatility: Volatility,
445
446 #[serde(default, skip_serializing_if = "Option::is_none")]
453 pub description: Option<String>,
454
455 #[serde(default)]
465 pub describe_in_prompt: bool,
466}
467
468mod schema;
469
470pub use schema::{ContentFormat, RegionSchema, Validator};
471
472pub(crate) fn default_true() -> bool {
474 true
475}
476
477impl Region {
478 pub fn new(name: String, kind: RegionKind, max_tokens: usize) -> Self {
480 Self {
481 name,
482 kind,
483 content: Vec::new(),
484 max_tokens,
485 current_tokens: 0,
486 schema: None,
487 taint: None,
488 needs_message_compaction: false,
489 summarizable: true,
490 admission: Admission::default(),
491 volatility: Volatility::default(),
492 description: None,
493 describe_in_prompt: false,
494 }
495 }
496
497 pub fn with_taint_tracking(mut self) -> Self {
499 self.taint = Some(crate::taint::RegionTaint::new());
500 self
501 }
502
503 pub fn enable_taint_tracking(&mut self) {
505 if self.taint.is_none() {
506 self.taint = Some(crate::taint::RegionTaint::new());
507 }
508 }
509
510 pub fn taint_level(&self) -> Option<crate::taint::TaintLevel> {
512 self.taint.as_ref().map(|t| t.level())
513 }
514
515 fn push_entry(
528 &mut self,
529 content: String,
530 tokens: usize,
531 metadata: Option<serde_json::Value>,
532 kind: EntryKind,
533 taint_level: crate::taint::TaintLevel,
534 key: Option<&str>,
535 ) -> crate::error::Result<()> {
536 if let Some(schema) = &self.schema {
537 schema.validate(&content)?;
538 }
539
540 if self.current_tokens + tokens > self.max_tokens {
541 if self.admission == Admission::Reject && !self.content.is_empty() {
547 return Err(crate::error::Error::RegionFull {
548 region: self.name.clone(),
549 used: self.current_tokens,
550 max: self.max_tokens,
551 });
552 }
553 if self.admission == Admission::Evict && self.kind.rolls_off_oldest() {
559 self.make_room(tokens);
560 }
561 }
562 if self.current_tokens + tokens > self.max_tokens {
566 return Err(crate::error::Error::TokenBudgetExceeded {
567 used: self.current_tokens + tokens,
568 max: self.max_tokens,
569 });
570 }
571 if self.admission == Admission::Reject && self.would_roll_off() {
575 return Err(crate::error::Error::RegionFull {
576 region: self.name.clone(),
577 used: self.current_tokens,
578 max: self.max_tokens,
579 });
580 }
581
582 self.content.push(RegionEntry {
583 content,
584 tokens,
585 timestamp: chrono::Utc::now().timestamp(),
586 metadata,
587 kind,
588 key: key.map(str::to_string),
589 });
590 self.current_tokens += tokens;
591
592 if let Some(taint) = &mut self.taint {
596 taint.add_entry(taint_level);
597 }
598
599 self.enforce_sliding_window();
600
601 Ok(())
602 }
603
604 pub fn make_room(&mut self, tokens: usize) -> usize {
620 if tokens > self.max_tokens {
621 return 0;
622 }
623 let mut evicted = 0;
624 while self.current_tokens + tokens > self.max_tokens && self.remove_oldest().is_some() {
625 evicted += 1;
626 }
627 evicted
628 }
629
630 fn would_roll_off(&self) -> bool {
635 match &self.kind {
636 RegionKind::SlidingWindow { max_items, .. } => self.content.len() + 1 > *max_items,
637 _ => false,
638 }
639 }
640
641 pub fn add_keyed_entry(
647 &mut self,
648 key: &str,
649 content: String,
650 tokens: usize,
651 ) -> crate::error::Result<()> {
652 self.push_entry(
653 content,
654 tokens,
655 None,
656 EntryKind::default(),
657 crate::taint::TaintLevel::Public,
658 Some(key),
659 )
660 }
661
662 pub fn remove_at(&mut self, index: usize) -> bool {
668 if index >= self.content.len() {
669 return false;
670 }
671 let entry = self.content.remove(index);
672 self.current_tokens = self.current_tokens.saturating_sub(entry.tokens);
673 true
674 }
675
676 pub fn release_oldest(&mut self, n: usize) -> usize {
681 let count = n.min(self.content.len());
682 for _ in 0..count {
683 self.remove_oldest();
684 }
685 count
686 }
687
688 pub fn add_tainted_entry(
690 &mut self,
691 content: String,
692 tokens: usize,
693 taint_level: crate::taint::TaintLevel,
694 ) -> crate::error::Result<()> {
695 self.push_entry(
696 content,
697 tokens,
698 None,
699 EntryKind::default(),
700 taint_level,
701 None,
702 )
703 }
704
705 pub fn add_typed_tainted_entry(
714 &mut self,
715 content: String,
716 tokens: usize,
717 kind: EntryKind,
718 taint_level: crate::taint::TaintLevel,
719 ) -> crate::error::Result<()> {
720 self.push_entry(content, tokens, None, kind, taint_level, None)
721 }
722
723 pub fn with_schema(mut self, schema: RegionSchema) -> Self {
725 self.schema = Some(schema);
726 self
727 }
728
729 pub fn add_entry(&mut self, content: String, tokens: usize) -> crate::error::Result<()> {
734 self.push_entry(
735 content,
736 tokens,
737 None,
738 EntryKind::default(),
739 crate::taint::TaintLevel::Public,
740 None,
741 )
742 }
743
744 pub fn add_entry_with_metadata(
746 &mut self,
747 content: String,
748 tokens: usize,
749 metadata: serde_json::Value,
750 ) -> crate::error::Result<()> {
751 self.push_entry(
752 content,
753 tokens,
754 Some(metadata),
755 EntryKind::default(),
756 crate::taint::TaintLevel::Public,
757 None,
758 )
759 }
760
761 pub fn add_typed_entry(
767 &mut self,
768 content: String,
769 tokens: usize,
770 kind: EntryKind,
771 ) -> crate::error::Result<()> {
772 self.push_entry(
773 content,
774 tokens,
775 None,
776 kind,
777 crate::taint::TaintLevel::Public,
778 None,
779 )
780 }
781
782 pub fn carry_entry(&mut self, entry: RegionEntry) -> crate::error::Result<()> {
796 if self.current_tokens + entry.tokens > self.max_tokens {
798 return Err(crate::error::Error::TokenBudgetExceeded {
799 used: self.current_tokens + entry.tokens,
800 max: self.max_tokens,
801 });
802 }
803
804 self.current_tokens += entry.tokens;
805 self.content.push(entry);
806
807 self.enforce_sliding_window();
809
810 Ok(())
811 }
812
813 pub fn upsert_by_key(
816 &mut self,
817 key: &str,
818 content: String,
819 tokens: usize,
820 ) -> Result<(), String> {
821 if let Some(pos) = self
823 .content
824 .iter()
825 .position(|e| e.key.as_deref() == Some(key))
826 {
827 let old_tokens = self.content[pos].tokens;
828 self.current_tokens -= old_tokens;
829 self.content[pos].content = content;
830 self.content[pos].tokens = tokens;
831 self.content[pos].timestamp = chrono::Utc::now().timestamp();
832 self.current_tokens += tokens;
833 return Ok(());
834 }
835
836 let max_entries = if let RegionKind::HashMap {
838 max_entries: Some(max),
839 } = &self.kind
840 {
841 Some(*max)
842 } else {
843 None
844 };
845 if let Some(max) = max_entries {
846 while self.content.len() >= max {
847 self.evict_lru_entry();
848 }
849 }
850
851 while self.current_tokens + tokens > self.max_tokens && !self.content.is_empty() {
853 self.evict_lru_entry();
854 }
855
856 if self.current_tokens + tokens > self.max_tokens {
857 return Err(format!(
858 "Entry ({} tokens) exceeds region budget ({} max)",
859 tokens, self.max_tokens
860 ));
861 }
862
863 self.content.push(RegionEntry {
864 content,
865 tokens,
866 timestamp: chrono::Utc::now().timestamp(),
867 metadata: None,
868 kind: EntryKind::default(),
869 key: Some(key.to_string()),
870 });
871 self.current_tokens += tokens;
872 Ok(())
873 }
874
875 pub fn get_by_key(&self, key: &str) -> Option<&RegionEntry> {
877 self.content.iter().find(|e| e.key.as_deref() == Some(key))
878 }
879
880 pub fn remove_by_key(&mut self, key: &str) -> bool {
882 if let Some(pos) = self
883 .content
884 .iter()
885 .position(|e| e.key.as_deref() == Some(key))
886 {
887 let tokens = self.content[pos].tokens;
888 self.content.remove(pos);
889 self.current_tokens -= tokens;
890 if let Some(taint) = &mut self.taint {
891 taint.remove_at(pos);
892 }
893 true
894 } else {
895 false
896 }
897 }
898
899 pub fn keys(&self) -> Vec<&str> {
901 self.content
902 .iter()
903 .filter_map(|e| e.key.as_deref())
904 .collect()
905 }
906
907 fn evict_lru_entry(&mut self) {
909 if self.content.is_empty() {
910 return;
911 }
912 let oldest_idx = self
913 .content
914 .iter()
915 .enumerate()
916 .min_by_key(|(_, e)| e.timestamp)
917 .map(|(i, _)| i)
918 .unwrap_or(0);
919 let tokens = self.content[oldest_idx].tokens;
920 self.content.remove(oldest_idx);
921 self.current_tokens -= tokens;
922 if let Some(taint) = &mut self.taint {
923 taint.remove_at(oldest_idx);
924 }
925 }
926
927 fn enforce_sliding_window(&mut self) {
938 if let RegionKind::SlidingWindow {
939 max_items,
940 eviction_strategy,
941 } = &self.kind
942 {
943 let max = *max_items;
944 match eviction_strategy.clone() {
945 EvictionStrategy::PerItem => {
946 while self.content.len() > max && self.remove_oldest().is_some() {}
950 }
951 EvictionStrategy::Bulk { overflow } => {
952 if self.content.len() > max + overflow {
953 while self.content.len() > max && self.remove_oldest().is_some() {}
954 }
955 }
956 EvictionStrategy::Compact { compact_count } => {
957 if self.content.len() > max + compact_count * 2 {
958 while self.content.len() > max && self.remove_oldest().is_some() {}
961 self.needs_message_compaction = false;
962 } else if self.content.len() > max + compact_count {
963 self.needs_message_compaction = true;
964 }
965 }
966 }
967 }
968 }
969
970 fn turn_group_size_at(&self, idx: usize) -> usize {
978 if idx >= self.content.len() {
979 return 0;
980 }
981 match &self.content[idx].kind {
982 EntryKind::AssistantTurn { .. } => {
983 let mut size = 1;
984 while idx + size < self.content.len() {
985 if matches!(self.content[idx + size].kind, EntryKind::ToolResult { .. }) {
986 size += 1;
987 } else {
988 break;
989 }
990 }
991 size
992 }
993 _ => 1,
994 }
995 }
996
997 pub fn clear(&mut self) {
999 self.content.clear();
1000 self.current_tokens = 0;
1001 if let Some(taint) = &mut self.taint {
1002 taint.clear();
1003 }
1004 }
1005
1006 pub fn remove_oldest(&mut self) -> Option<RegionEntry> {
1008 if self.content.is_empty() {
1009 return None;
1010 }
1011 let group_size = self.turn_group_size_at(0);
1015 let mut first = None;
1016 let mut extra_tokens = 0usize;
1017 let mut i = 0;
1020 while i < group_size && !self.content.is_empty() {
1021 let entry_tokens = self.content[0].tokens;
1022 self.current_tokens -= entry_tokens;
1023 let removed = self.content.remove(0);
1024 if let Some(taint) = &mut self.taint {
1025 taint.remove_oldest();
1026 }
1027 if i == 0 {
1028 first = Some(removed);
1029 } else {
1030 extra_tokens += entry_tokens;
1031 }
1032 i += 1;
1033 }
1034 first.map(|mut entry| {
1040 entry.tokens += extra_tokens;
1041 entry
1042 })
1043 }
1044
1045 pub fn remove_entries_by_prefix(&mut self, prefix: &str) {
1051 let mut i = 0;
1052 while i < self.content.len() {
1053 if self.content[i].content.starts_with(prefix) {
1054 let tokens = self.content[i].tokens;
1055 self.content.remove(i);
1056 self.current_tokens -= tokens;
1057 if let Some(taint) = &mut self.taint {
1058 taint.remove_at(i);
1059 }
1060 } else {
1061 i += 1;
1062 }
1063 }
1064 }
1065
1066 pub fn entry_count(&self) -> usize {
1068 self.content.len()
1069 }
1070
1071 pub fn needs_compaction(&self) -> bool {
1073 if let RegionKind::Compacting { threshold_tokens } = self.kind {
1074 self.current_tokens > threshold_tokens
1075 } else {
1076 false
1077 }
1078 }
1079}
1080
1081#[derive(Debug, Clone, Serialize, Deserialize)]
1085pub struct RegionEntry {
1086 pub content: String,
1088
1089 pub tokens: usize,
1091
1092 pub timestamp: i64,
1094
1095 pub metadata: Option<serde_json::Value>,
1097
1098 #[serde(default)]
1102 pub kind: EntryKind,
1103
1104 #[serde(default, skip_serializing_if = "Option::is_none")]
1106 pub key: Option<String>,
1107}
1108
1109#[cfg(test)]
1112mod tests {
1113
1114 #[test]
1124 fn an_evicting_region_rolls_the_oldest_off_to_admit_a_write() {
1125 let mut region = Region::new("findings".to_string(), RegionKind::Temporary, 100);
1126 region.add_entry("oldest".to_string(), 40).unwrap();
1127 region.add_entry("middle".to_string(), 40).unwrap();
1128 assert_eq!(region.current_tokens, 80);
1129
1130 region.add_entry("newest".to_string(), 40).unwrap();
1132
1133 assert_eq!(region.current_tokens, 80);
1134 let held: Vec<&str> = region.content.iter().map(|e| e.content.as_str()).collect();
1135 assert_eq!(held, ["middle", "newest"]);
1136 }
1137
1138 #[test]
1140 fn eviction_stops_as_soon_as_the_write_fits() {
1141 let mut region = Region::new("findings".to_string(), RegionKind::Temporary, 100);
1142 for i in 0..5 {
1143 region.add_entry(format!("entry-{i}"), 20).unwrap();
1144 }
1145 region.add_entry("newest".to_string(), 20).unwrap();
1146 let held: Vec<&str> = region.content.iter().map(|e| e.content.as_str()).collect();
1147 assert_eq!(held, ["entry-1", "entry-2", "entry-3", "entry-4", "newest"]);
1148 }
1149
1150 #[test]
1153 fn a_rejecting_region_still_refuses_rather_than_dropping_anything() {
1154 let mut region = Region::new("sources".to_string(), RegionKind::Temporary, 100);
1155 region.admission = Admission::Reject;
1156 region.add_entry("curated".to_string(), 80).unwrap();
1157
1158 let err = region.add_entry("newest".to_string(), 40).unwrap_err();
1159 assert_eq!(
1160 err.to_string(),
1161 "Region 'sources' is full (80/100 tokens) and does not evict automatically - release an entry before adding another"
1162 );
1163 assert_eq!(region.content.len(), 1);
1164 assert_eq!(region.current_tokens, 80);
1165 }
1166
1167 #[test]
1172 fn an_entry_larger_than_the_region_does_not_empty_it() {
1173 let mut region = Region::new("findings".to_string(), RegionKind::Temporary, 100);
1174 region.add_entry("kept".to_string(), 50).unwrap();
1175
1176 let err = region.add_entry("enormous".to_string(), 500).unwrap_err();
1177 assert_eq!(err.to_string(), "Content exceeds token budget: 550 > 100");
1178 assert_eq!(region.content.len(), 1, "the region was not emptied for it");
1179 }
1180
1181 #[test]
1185 fn eviction_never_strands_a_tool_result_without_its_call() {
1186 let mut region = Region::new(
1187 "conversation".to_string(),
1188 RegionKind::SlidingWindow {
1189 max_items: 100,
1190 eviction_strategy: EvictionStrategy::PerItem,
1191 },
1192 100,
1193 );
1194 region
1195 .add_typed_entry(
1196 "call it".to_string(),
1197 30,
1198 EntryKind::AssistantTurn {
1199 tool_calls: vec![crate::SerializedToolCall {
1200 id: "t1".to_string(),
1201 name: "read_file".to_string(),
1202 arguments: serde_json::json!({}),
1203 thought_signature: None,
1204 }],
1205 },
1206 )
1207 .unwrap();
1208 region
1209 .add_typed_entry(
1210 "the answer".to_string(),
1211 30,
1212 EntryKind::ToolResult {
1213 tool_call_id: "t1".to_string(),
1214 tool_name: "read_file".to_string(),
1215 is_error: false,
1216 },
1217 )
1218 .unwrap();
1219
1220 region.add_entry("next turn".to_string(), 60).unwrap();
1222
1223 assert!(
1224 !region
1225 .content
1226 .iter()
1227 .any(|e| matches!(e.kind, EntryKind::ToolResult { .. })),
1228 "the result outlived the call that produced it"
1229 );
1230 }
1231
1232 #[test]
1236 fn kinds_that_own_their_retention_do_not_roll_off() {
1237 assert!(RegionKind::Temporary.rolls_off_oldest());
1238 assert!(RegionKind::Clearable.rolls_off_oldest());
1239 assert!(!RegionKind::Pinned.rolls_off_oldest());
1240 assert!(
1241 !RegionKind::Custom {
1242 script: "r.rhai".to_string(),
1243 persistent: false,
1244 }
1245 .rolls_off_oldest()
1246 );
1247 assert!(!RegionKind::HashMap { max_entries: None }.rolls_off_oldest());
1248
1249 let mut pinned = Region::new("query".to_string(), RegionKind::Pinned, 100);
1251 pinned.add_entry("the task".to_string(), 80).unwrap();
1252 assert!(pinned.add_entry("more".to_string(), 40).is_err());
1253 assert_eq!(pinned.content.len(), 1, "a pinned region kept its content");
1254 }
1255
1256 use super::*;
1257
1258 fn checklist() -> Region {
1261 Region::new("todos".to_string(), RegionKind::Checklist, 10_000)
1262 }
1263
1264 #[test]
1270 fn a_malformed_entry_is_not_an_item() {
1271 let mut r = checklist();
1272 r.add_entry("a plain note".to_string(), 3).unwrap();
1274 r.add_entry_with_metadata(
1276 "something else".to_string(),
1277 3,
1278 serde_json::json!({ "unrelated": true }),
1279 )
1280 .unwrap();
1281 r.add_entry_with_metadata(
1283 "bad id".to_string(),
1284 3,
1285 serde_json::json!({ "checklist_id": "one" }),
1286 )
1287 .unwrap();
1288
1289 assert!(r.checklist_items().is_empty(), "none of those are items");
1290 assert!(r.open_checklist_items().is_empty());
1291 assert!(
1292 r.render_checklist().is_empty(),
1293 "and they do not render as a checklist"
1294 );
1295 }
1296
1297 #[test]
1300 fn a_checklist_caches_until_it_changes() {
1301 assert_eq!(
1302 RegionKind::Checklist.cache_hint(),
1303 crate::cache::CacheHint::UntilChanged
1304 );
1305 }
1306
1307 #[test]
1308 fn a_note_appears_in_the_render() {
1309 let mut r = checklist();
1310 let id = r.add_checklist_item("blocked".to_string(), 2).unwrap();
1311 r.note_checklist_item(id, "waiting on the manual");
1312 let rendered = r.render_checklist();
1313 assert!(
1314 rendered.contains("note: waiting on the manual"),
1315 "{rendered}"
1316 );
1317 }
1318
1319 #[test]
1322 fn an_item_over_budget_is_refused() {
1323 let mut r = Region::new("todos".to_string(), RegionKind::Checklist, 4);
1324 assert!(r.add_checklist_item("x".to_string(), 99).is_err());
1325 assert!(r.checklist_items().is_empty());
1326 }
1327
1328 #[test]
1329 fn an_added_item_starts_open_and_gets_an_id() {
1330 let mut r = checklist();
1331 let first = r
1332 .add_checklist_item("compute the fee table".to_string(), 5)
1333 .unwrap();
1334 let second = r
1335 .add_checklist_item("check the manual".to_string(), 5)
1336 .unwrap();
1337 assert_eq!((first, second), (1, 2), "ids are stable and sequential");
1338 assert_eq!(r.open_checklist_items().len(), 2);
1339 }
1340
1341 #[test]
1342 fn completing_an_item_closes_it_and_nothing_else() {
1343 let mut r = checklist();
1344 let id = r.add_checklist_item("one".to_string(), 2).unwrap();
1345 r.add_checklist_item("two".to_string(), 2).unwrap();
1346
1347 assert!(r.complete_checklist_item(id));
1348 let open = r.open_checklist_items();
1349 assert_eq!(open.len(), 1);
1350 assert_eq!(open[0].text, "two");
1351 assert_eq!(
1352 r.checklist_items().len(),
1353 2,
1354 "done items are kept, not deleted"
1355 );
1356 }
1357
1358 #[test]
1359 fn an_unknown_id_reports_failure_rather_than_ticking_something_else() {
1360 let mut r = checklist();
1363 r.add_checklist_item("one".to_string(), 2).unwrap();
1364 assert!(!r.complete_checklist_item(99));
1365 assert!(!r.note_checklist_item(99, "x"));
1366 assert_eq!(r.open_checklist_items().len(), 1);
1367 }
1368
1369 #[test]
1370 fn a_note_records_without_closing() {
1371 let mut r = checklist();
1372 let id = r
1373 .add_checklist_item("blocked thing".to_string(), 2)
1374 .unwrap();
1375 assert!(r.note_checklist_item(id, "waiting on the manual"));
1376 let item = &r.checklist_items()[0];
1377 assert!(!item.done, "a note is not a completion");
1378 assert_eq!(item.note.as_deref(), Some("waiting on the manual"));
1379 }
1380
1381 #[test]
1384 fn the_render_puts_open_items_first() {
1385 let mut r = checklist();
1386 let done = r
1387 .add_checklist_item("already finished".to_string(), 2)
1388 .unwrap();
1389 r.add_checklist_item("still to do".to_string(), 2).unwrap();
1390 r.complete_checklist_item(done);
1391
1392 let rendered = r.render_checklist();
1393 let open_at = rendered.find("still to do").expect("open item rendered");
1394 let done_at = rendered
1395 .find("already finished")
1396 .expect("done item rendered");
1397 assert!(open_at < done_at, "open before done:\n{rendered}");
1398 assert!(rendered.contains("1 open, 1 done"), "{rendered}");
1399 assert!(
1400 rendered.contains("[x]") && rendered.contains("[ ]"),
1401 "{rendered}"
1402 );
1403 }
1404
1405 #[test]
1406 fn an_empty_checklist_renders_nothing() {
1407 assert!(checklist().render_checklist().is_empty());
1409 }
1410
1411 #[test]
1414 fn ids_do_not_get_reused_after_a_drop() {
1415 let mut r = checklist();
1416 r.add_checklist_item("one".to_string(), 2).unwrap();
1417 let second = r.add_checklist_item("two".to_string(), 2).unwrap();
1418 r.content.remove(0);
1419 let third = r.add_checklist_item("three".to_string(), 2).unwrap();
1420 assert!(third > second, "a reused id would tick off the wrong item");
1421 }
1422
1423 #[test]
1424 fn test_region_creation() {
1425 let region = Region::new("test".to_string(), RegionKind::Pinned, 1000);
1426 assert_eq!(region.name, "test");
1427 assert_eq!(region.max_tokens, 1000);
1428 assert_eq!(region.current_tokens, 0);
1429 }
1430
1431 #[test]
1432 fn test_sliding_window_config() {
1433 let kind = RegionKind::SlidingWindow {
1434 max_items: 10,
1435 eviction_strategy: EvictionStrategy::PerItem,
1436 };
1437 let region = Region::new("history".to_string(), kind.clone(), 5000);
1438 assert_eq!(region.kind, kind);
1439 }
1440
1441 #[test]
1442 fn test_region_kind_equality() {
1443 assert_eq!(RegionKind::Clearable, RegionKind::Clearable);
1444 assert_eq!(
1445 RegionKind::Compacting {
1446 threshold_tokens: 500
1447 },
1448 RegionKind::Compacting {
1449 threshold_tokens: 500
1450 }
1451 );
1452 assert_eq!(
1453 RegionKind::CompactHistory {
1454 source_region: "conv".to_string()
1455 },
1456 RegionKind::CompactHistory {
1457 source_region: "conv".to_string()
1458 }
1459 );
1460 assert_ne!(RegionKind::Pinned, RegionKind::Temporary);
1461 }
1462
1463 #[test]
1464 fn custom_kind_equality_compares_script_and_persistent() {
1465 let a = RegionKind::Custom {
1466 script: "conv.rhai".to_string(),
1467 persistent: false,
1468 };
1469 assert_eq!(a, a.clone());
1470 assert_ne!(
1471 a,
1472 RegionKind::Custom {
1473 script: "other.rhai".to_string(),
1474 persistent: false,
1475 }
1476 );
1477 assert_ne!(
1478 a,
1479 RegionKind::Custom {
1480 script: "conv.rhai".to_string(),
1481 persistent: true,
1482 }
1483 );
1484 assert_ne!(a, RegionKind::Temporary);
1485 }
1486
1487 #[test]
1488 fn custom_kind_serde_round_trips() {
1489 let kind = RegionKind::Custom {
1490 script: "hooks/conv.rhai".to_string(),
1491 persistent: true,
1492 };
1493 let json = serde_json::to_string(&kind).unwrap();
1494 let back: RegionKind = serde_json::from_str(&json).unwrap();
1495 assert_eq!(kind, back);
1496 let old: RegionKind = serde_json::from_str("\"Pinned\"").unwrap();
1498 assert_eq!(old, RegionKind::Pinned);
1499 }
1500
1501 #[test]
1502 fn custom_kind_cache_hint_follows_persistent() {
1503 assert_eq!(
1504 RegionKind::Custom {
1505 script: "s.rhai".to_string(),
1506 persistent: true,
1507 }
1508 .cache_hint(),
1509 crate::cache::CacheHint::Always
1510 );
1511 assert_eq!(
1512 RegionKind::Custom {
1513 script: "s.rhai".to_string(),
1514 persistent: false,
1515 }
1516 .cache_hint(),
1517 crate::cache::CacheHint::UntilChanged
1518 );
1519 }
1520
1521 #[test]
1522 fn carry_entry_preserves_kind_metadata_key_and_timestamp() {
1523 let mut source = Region::new("conversation".to_string(), RegionKind::Temporary, 10_000);
1524 source
1525 .add_typed_entry(
1526 "result body".to_string(),
1527 10,
1528 EntryKind::ToolResult {
1529 tool_call_id: "call_1".to_string(),
1530 tool_name: "read_file".to_string(),
1531 is_error: false,
1532 },
1533 )
1534 .unwrap();
1535 let mut entry = source.content[0].clone();
1536 entry.metadata = Some(serde_json::json!({"origin": "test"}));
1537 entry.key = Some("k".to_string());
1538 let stamped = entry.timestamp;
1539
1540 let mut dest = Region::new("conversation".to_string(), RegionKind::Temporary, 10_000);
1541 dest.carry_entry(entry).unwrap();
1542
1543 let carried = &dest.content[0];
1544 assert!(matches!(
1545 &carried.kind,
1546 EntryKind::ToolResult { tool_call_id, .. } if tool_call_id == "call_1"
1547 ));
1548 assert_eq!(
1549 carried.metadata,
1550 Some(serde_json::json!({"origin": "test"}))
1551 );
1552 assert_eq!(carried.key.as_deref(), Some("k"));
1553 assert_eq!(carried.timestamp, stamped);
1554 assert_eq!(dest.current_tokens, 10);
1555 }
1556
1557 #[test]
1558 fn carry_entry_rejects_over_budget() {
1559 let mut dest = Region::new("small".to_string(), RegionKind::Temporary, 5);
1560 let mut source = Region::new("src".to_string(), RegionKind::Temporary, 100);
1561 source.add_entry("filler".to_string(), 10).unwrap();
1562 let err = dest.carry_entry(source.content[0].clone()).unwrap_err();
1563 assert_eq!(err.to_string(), "Content exceeds token budget: 10 > 5");
1564 assert!(dest.content.is_empty());
1565 assert_eq!(dest.current_tokens, 0);
1566 }
1567
1568 #[test]
1569 fn carry_entry_enforces_sliding_window_max_items() {
1570 let mut source = Region::new("src".to_string(), RegionKind::Temporary, 10_000);
1571 for i in 0..4 {
1572 source.add_entry(format!("msg{i}"), 10).unwrap();
1573 }
1574 let mut dest = Region::new(
1575 "conv".to_string(),
1576 RegionKind::SlidingWindow {
1577 max_items: 3,
1578 eviction_strategy: EvictionStrategy::PerItem,
1579 },
1580 10_000,
1581 );
1582 for entry in &source.content {
1583 dest.carry_entry(entry.clone()).unwrap();
1584 }
1585 assert_eq!(dest.content.len(), 3);
1586 assert_eq!(dest.content[0].content, "msg1");
1587 }
1588
1589 #[test]
1590 fn test_sliding_window_enforces_max_items() {
1591 let mut region = Region::new(
1592 "conv".to_string(),
1593 RegionKind::SlidingWindow {
1594 max_items: 3,
1595 eviction_strategy: EvictionStrategy::PerItem,
1596 },
1597 50000,
1598 );
1599
1600 region.add_entry("msg1".to_string(), 10).unwrap();
1601 region.add_entry("msg2".to_string(), 20).unwrap();
1602 region.add_entry("msg3".to_string(), 30).unwrap();
1603 assert_eq!(region.entry_count(), 3);
1604 assert_eq!(region.current_tokens, 60);
1605
1606 region.add_entry("msg4".to_string(), 40).unwrap();
1608 assert_eq!(region.entry_count(), 3);
1609 assert_eq!(region.content[0].content, "msg2");
1610 assert_eq!(region.content[2].content, "msg4");
1611 assert_eq!(region.current_tokens, 90); region.add_entry("msg5".to_string(), 50).unwrap();
1615 assert_eq!(region.entry_count(), 3);
1616 assert_eq!(region.content[0].content, "msg3");
1617 assert_eq!(region.current_tokens, 120); }
1619
1620 #[test]
1621 fn test_sliding_window_enforces_max_items_with_metadata() {
1622 let mut region = Region::new(
1623 "conv".to_string(),
1624 RegionKind::SlidingWindow {
1625 max_items: 2,
1626 eviction_strategy: EvictionStrategy::PerItem,
1627 },
1628 50000,
1629 );
1630
1631 region
1632 .add_entry_with_metadata("a".to_string(), 10, serde_json::json!({"idx": 1}))
1633 .unwrap();
1634 region
1635 .add_entry_with_metadata("b".to_string(), 20, serde_json::json!({"idx": 2}))
1636 .unwrap();
1637 region
1638 .add_entry_with_metadata("c".to_string(), 30, serde_json::json!({"idx": 3}))
1639 .unwrap();
1640
1641 assert_eq!(region.entry_count(), 2);
1642 assert_eq!(region.content[0].content, "b");
1643 assert_eq!(region.content[1].content, "c");
1644 assert_eq!(region.current_tokens, 50);
1645 }
1646
1647 #[test]
1648 fn test_cache_hint_pinned() {
1649 let kind = RegionKind::Pinned;
1650 assert_eq!(kind.cache_hint(), crate::cache::CacheHint::Always);
1651 }
1652
1653 #[test]
1654 fn test_cache_hint_compact_history() {
1655 let kind = RegionKind::CompactHistory {
1656 source_region: "conv".to_string(),
1657 };
1658 assert_eq!(kind.cache_hint(), crate::cache::CacheHint::Always);
1659 }
1660
1661 #[test]
1662 fn test_cache_hint_compacting() {
1663 let kind = RegionKind::Compacting {
1664 threshold_tokens: 1000,
1665 };
1666 assert_eq!(kind.cache_hint(), crate::cache::CacheHint::UntilChanged);
1667 }
1668
1669 #[test]
1670 fn test_cache_hint_sliding_window() {
1671 let kind = RegionKind::SlidingWindow {
1672 max_items: 10,
1673 eviction_strategy: EvictionStrategy::PerItem,
1674 };
1675 assert_eq!(
1676 kind.cache_hint(),
1677 crate::cache::CacheHint::SlidingPrefix {
1678 stable_fraction: 0.75
1679 }
1680 );
1681 }
1682
1683 #[test]
1684 fn test_cache_hint_temporary() {
1685 assert_eq!(
1686 RegionKind::Temporary.cache_hint(),
1687 crate::cache::CacheHint::Never
1688 );
1689 }
1690
1691 #[test]
1692 fn test_cache_hint_clearable() {
1693 assert_eq!(
1694 RegionKind::Clearable.cache_hint(),
1695 crate::cache::CacheHint::Never
1696 );
1697 }
1698
1699 #[test]
1702 fn test_with_schema_attaches_schema() {
1703 let schema = RegionSchema::new(ContentFormat::Json);
1704 let region =
1705 Region::new("data".to_string(), RegionKind::Temporary, 1000).with_schema(schema);
1706 assert!(region.schema.is_some());
1707 }
1708
1709 #[test]
1710 fn test_add_entry_rejects_content_failing_schema() {
1711 let schema = RegionSchema::new(ContentFormat::Json);
1712 let mut region =
1713 Region::new("data".to_string(), RegionKind::Temporary, 1000).with_schema(schema);
1714 let result = region.add_entry("not json".to_string(), 10);
1715 assert!(result.is_err());
1716 assert_eq!(region.entry_count(), 0);
1717 }
1718
1719 #[test]
1720 fn test_add_entry_accepts_content_passing_schema() {
1721 let schema = RegionSchema::new(ContentFormat::Json);
1722 let mut region =
1723 Region::new("data".to_string(), RegionKind::Temporary, 1000).with_schema(schema);
1724 let result = region.add_entry("{\"a\":1}".to_string(), 10);
1725 assert!(result.is_ok());
1726 assert_eq!(region.entry_count(), 1);
1727 }
1728
1729 #[test]
1730 fn test_add_entry_rejects_over_budget() {
1731 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 10);
1732 let result = region.add_entry("too much".to_string(), 20);
1733 assert_eq!(
1734 result.unwrap_err().to_string(),
1735 "Content exceeds token budget: 20 > 10"
1736 );
1737 assert_eq!(region.entry_count(), 0);
1738 }
1739
1740 #[test]
1741 fn test_add_entry_with_metadata_rejects_content_failing_schema() {
1742 let schema = RegionSchema::new(ContentFormat::Json);
1743 let mut region =
1744 Region::new("data".to_string(), RegionKind::Temporary, 1000).with_schema(schema);
1745 let result =
1746 region.add_entry_with_metadata("not json".to_string(), 10, serde_json::json!({}));
1747 assert!(result.is_err());
1748 }
1749
1750 #[test]
1751 fn test_add_entry_with_metadata_rejects_over_budget() {
1752 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 10);
1753 let result =
1754 region.add_entry_with_metadata("too much".to_string(), 20, serde_json::json!({}));
1755 assert_eq!(
1756 result.unwrap_err().to_string(),
1757 "Content exceeds token budget: 20 > 10"
1758 );
1759 }
1760
1761 #[test]
1762 fn test_add_entry_with_metadata_stores_metadata() {
1763 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1764 region
1765 .add_entry_with_metadata("hello".to_string(), 5, serde_json::json!({"k": "v"}))
1766 .unwrap();
1767 assert_eq!(
1768 region.content[0].metadata,
1769 Some(serde_json::json!({"k": "v"}))
1770 );
1771 }
1772
1773 #[test]
1776 fn test_clear_removes_all_content_and_resets_tokens() {
1777 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1778 region.add_entry("a".to_string(), 10).unwrap();
1779 region.add_entry("b".to_string(), 20).unwrap();
1780 assert_eq!(region.entry_count(), 2);
1781
1782 region.clear();
1783 assert_eq!(region.entry_count(), 0);
1784 assert_eq!(region.current_tokens, 0);
1785 }
1786
1787 #[test]
1788 fn test_remove_oldest_returns_and_removes_first_entry() {
1789 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1790 region.add_entry("first".to_string(), 10).unwrap();
1791 region.add_entry("second".to_string(), 20).unwrap();
1792
1793 let removed = region.remove_oldest().unwrap();
1794 assert_eq!(removed.content, "first");
1795 assert_eq!(region.entry_count(), 1);
1796 assert_eq!(region.current_tokens, 20);
1797 }
1798
1799 #[test]
1800 fn test_remove_oldest_returns_none_when_empty() {
1801 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1802 assert!(region.remove_oldest().is_none());
1803 }
1804
1805 #[test]
1806 fn test_needs_compaction_true_when_over_threshold() {
1807 let mut region = Region::new(
1808 "impl".to_string(),
1809 RegionKind::Compacting {
1810 threshold_tokens: 10,
1811 },
1812 1000,
1813 );
1814 region.add_entry("x".to_string(), 20).unwrap();
1815 assert!(region.needs_compaction());
1816 }
1817
1818 #[test]
1819 fn test_needs_compaction_false_when_under_threshold() {
1820 let mut region = Region::new(
1821 "impl".to_string(),
1822 RegionKind::Compacting {
1823 threshold_tokens: 100,
1824 },
1825 1000,
1826 );
1827 region.add_entry("x".to_string(), 20).unwrap();
1828 assert!(!region.needs_compaction());
1829 }
1830
1831 #[test]
1832 fn test_needs_compaction_false_for_non_compacting_kind() {
1833 let region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1834 assert!(!region.needs_compaction());
1835 }
1836
1837 #[test]
1840 fn test_region_schema_with_custom_script() {
1841 let schema = RegionSchema::new(ContentFormat::Custom {
1842 format_name: "special".to_string(),
1843 })
1844 .with_custom_script("validate_special()".to_string());
1845 assert_eq!(schema.custom_script.as_deref(), Some("validate_special()"));
1846 }
1847
1848 #[test]
1851 fn test_validate_json_valid() {
1852 let schema = RegionSchema::new(ContentFormat::Json);
1853 assert!(schema.validate("{\"a\": 1}").is_ok());
1854 }
1855
1856 #[test]
1857 fn test_validate_json_invalid() {
1858 let schema = RegionSchema::new(ContentFormat::Json);
1859 let err = schema.validate("not json").unwrap_err();
1860 assert!(err.to_string().starts_with("Region validation failed:"));
1861 }
1862
1863 #[test]
1864 fn test_validate_mermaid_valid() {
1865 let schema = RegionSchema::new(ContentFormat::Mermaid);
1866 assert!(schema.validate("graph TD\nA-->B").is_ok());
1867 }
1868
1869 #[test]
1870 fn test_validate_mermaid_all_recognized_diagram_types() {
1871 let schema = RegionSchema::new(ContentFormat::Mermaid);
1872 for kind in [
1873 "graph",
1874 "sequenceDiagram",
1875 "classDiagram",
1876 "stateDiagram",
1877 "erDiagram",
1878 "journey",
1879 "gantt",
1880 "pie",
1881 "flowchart",
1882 ] {
1883 assert!(schema.validate(&format!("{} content", kind)).is_ok());
1884 }
1885 }
1886
1887 #[test]
1888 fn test_validate_mermaid_invalid() {
1889 let schema = RegionSchema::new(ContentFormat::Mermaid);
1890 let err = schema.validate("just some text").unwrap_err();
1891 assert!(err.to_string().starts_with("Region validation failed:"));
1892 }
1893
1894 #[test]
1895 fn test_validate_code_non_empty_is_ok() {
1896 let schema = RegionSchema::new(ContentFormat::Code {
1897 language: "rust".to_string(),
1898 });
1899 assert!(schema.validate("fn main() {}").is_ok());
1900 }
1901
1902 #[test]
1903 fn test_validate_code_empty_is_error() {
1904 let schema = RegionSchema::new(ContentFormat::Code {
1905 language: "rust".to_string(),
1906 });
1907 let err = schema.validate(" ").unwrap_err();
1908 assert!(err.to_string().starts_with("Region validation failed:"));
1909 }
1910
1911 #[test]
1912 fn test_validate_markdown_non_empty_is_ok() {
1913 let schema = RegionSchema::new(ContentFormat::Markdown);
1914 assert!(schema.validate("# Heading").is_ok());
1915 }
1916
1917 #[test]
1918 fn test_validate_markdown_empty_is_error() {
1919 let schema = RegionSchema::new(ContentFormat::Markdown);
1920 let err = schema.validate("").unwrap_err();
1921 assert!(err.to_string().starts_with("Region validation failed:"));
1922 }
1923
1924 #[test]
1925 fn test_validate_text_has_no_restrictions() {
1926 let schema = RegionSchema::new(ContentFormat::Text);
1927 assert!(schema.validate("").is_ok());
1928 assert!(schema.validate("anything at all").is_ok());
1929 }
1930
1931 #[test]
1932 fn test_validate_custom_has_no_restrictions_here() {
1933 let schema = RegionSchema::new(ContentFormat::Custom {
1934 format_name: "special".to_string(),
1935 });
1936 assert!(schema.validate("").is_ok());
1939 assert!(schema.validate("whatever").is_ok());
1940 }
1941
1942 #[test]
1945 fn test_region_schema_clone_preserves_fields() {
1946 let schema = RegionSchema::new(ContentFormat::Text).with_custom_script("s".to_string());
1947 let cloned = schema.clone();
1948 assert_eq!(cloned.custom_script.as_deref(), Some("s"));
1949 assert_eq!(cloned.format, ContentFormat::Text);
1950 }
1951
1952 #[test]
1955 fn test_region_with_taint_tracking() {
1956 let region =
1957 Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
1958 assert!(region.taint.is_some());
1959 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
1960 }
1961
1962 #[test]
1963 fn test_region_without_taint_tracking() {
1964 let region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
1965 assert!(region.taint.is_none());
1966 assert_eq!(region.taint_level(), None);
1967 }
1968
1969 #[test]
1970 fn test_enable_taint_tracking() {
1971 let mut region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
1972 assert!(region.taint.is_none());
1973 region.enable_taint_tracking();
1974 assert!(region.taint.is_some());
1975 region.enable_taint_tracking();
1977 assert!(region.taint.is_some());
1978 }
1979
1980 #[test]
1981 fn test_add_tainted_entry() {
1982 let mut region =
1983 Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
1984 region
1985 .add_tainted_entry(
1986 "secret data".to_string(),
1987 10,
1988 crate::taint::TaintLevel::Private,
1989 )
1990 .unwrap();
1991 assert_eq!(
1992 region.taint_level(),
1993 Some(crate::taint::TaintLevel::Private)
1994 );
1995 assert_eq!(region.entry_count(), 1);
1996 }
1997
1998 #[test]
1999 fn test_add_tainted_entry_validates_schema() {
2000 let mut region = Region::new("test".to_string(), RegionKind::Temporary, 1000)
2001 .with_taint_tracking()
2002 .with_schema(RegionSchema::new(ContentFormat::Json));
2003 let result = region.add_tainted_entry(
2004 "not json".to_string(),
2005 10,
2006 crate::taint::TaintLevel::Internal,
2007 );
2008 assert!(result.is_err());
2009 assert_eq!(region.entry_count(), 0);
2010 }
2011
2012 #[test]
2013 fn test_add_tainted_entry_checks_budget() {
2014 let mut region =
2015 Region::new("test".to_string(), RegionKind::Temporary, 10).with_taint_tracking();
2016 let result = region.add_tainted_entry(
2017 "too much".to_string(),
2018 20,
2019 crate::taint::TaintLevel::Internal,
2020 );
2021 assert!(result.is_err());
2022 }
2023
2024 #[test]
2025 fn test_add_entry_tracks_taint_as_public() {
2026 let mut region =
2027 Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
2028 region.add_entry("public data".to_string(), 10).unwrap();
2029 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2030 }
2031
2032 #[test]
2033 fn test_taint_recovery_on_remove_oldest() {
2034 let mut region =
2035 Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
2036 region
2037 .add_tainted_entry("private".to_string(), 10, crate::taint::TaintLevel::Private)
2038 .unwrap();
2039 region
2040 .add_tainted_entry("public".to_string(), 10, crate::taint::TaintLevel::Public)
2041 .unwrap();
2042 assert_eq!(
2043 region.taint_level(),
2044 Some(crate::taint::TaintLevel::Private)
2045 );
2046
2047 region.remove_oldest(); assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2049 }
2050
2051 #[test]
2052 fn test_taint_recovery_on_clear() {
2053 let mut region =
2054 Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
2055 region
2056 .add_tainted_entry("private".to_string(), 10, crate::taint::TaintLevel::Private)
2057 .unwrap();
2058 region.clear();
2059 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2060 }
2061
2062 #[test]
2063 fn test_taint_recovery_on_sliding_window_eviction() {
2064 let mut region = Region::new(
2065 "conv".to_string(),
2066 RegionKind::SlidingWindow {
2067 max_items: 2,
2068 eviction_strategy: EvictionStrategy::PerItem,
2069 },
2070 50000,
2071 )
2072 .with_taint_tracking();
2073
2074 region
2075 .add_tainted_entry("private".to_string(), 10, crate::taint::TaintLevel::Private)
2076 .unwrap();
2077 region
2078 .add_tainted_entry("public1".to_string(), 10, crate::taint::TaintLevel::Public)
2079 .unwrap();
2080 assert_eq!(
2081 region.taint_level(),
2082 Some(crate::taint::TaintLevel::Private)
2083 );
2084
2085 region
2087 .add_tainted_entry("public2".to_string(), 10, crate::taint::TaintLevel::Public)
2088 .unwrap();
2089 assert_eq!(region.entry_count(), 2);
2090 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2091 }
2092
2093 #[test]
2094 fn test_taint_field_not_serialized_when_none() {
2095 let region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
2096 let json = serde_json::to_string(®ion).unwrap();
2097 assert!(!json.contains("taint"));
2098 }
2099
2100 #[test]
2101 fn test_taint_field_deserialized_as_none_when_missing() {
2102 let json = r#"{"name":"test","kind":"Temporary","content":[],"max_tokens":1000,"current_tokens":0,"schema":null}"#;
2103 let region: Region = serde_json::from_str(json).unwrap();
2104 assert!(region.taint.is_none());
2105 }
2106
2107 #[test]
2108 fn test_add_typed_tainted_entry() {
2109 let mut region = Region::new(
2110 "conversation".to_string(),
2111 RegionKind::SlidingWindow {
2112 max_items: 100,
2113 eviction_strategy: EvictionStrategy::PerItem,
2114 },
2115 1000,
2116 )
2117 .with_taint_tracking();
2118
2119 region
2120 .add_typed_tainted_entry(
2121 "secret data".to_string(),
2122 10,
2123 EntryKind::ToolResult {
2124 tool_call_id: "tc_1".to_string(),
2125 tool_name: "calendar".to_string(),
2126 is_error: false,
2127 },
2128 crate::taint::TaintLevel::Private,
2129 )
2130 .unwrap();
2131
2132 assert_eq!(region.content.len(), 1);
2133 assert_eq!(
2134 region.content[0].kind,
2135 EntryKind::ToolResult {
2136 tool_call_id: "tc_1".to_string(),
2137 tool_name: "calendar".to_string(),
2138 is_error: false,
2139 }
2140 );
2141 assert_eq!(
2142 region.taint_level(),
2143 Some(crate::taint::TaintLevel::Private)
2144 );
2145 }
2146
2147 #[test]
2151 fn serialized_tool_call_round_trips_thought_signature_and_reads_old_json() {
2152 let with = SerializedToolCall {
2153 id: "c1".into(),
2154 name: "shell".into(),
2155 arguments: serde_json::json!({"command": "ls"}),
2156 thought_signature: Some("sig".into()),
2157 };
2158 let json = serde_json::to_string(&with).unwrap();
2159 let back: SerializedToolCall = serde_json::from_str(&json).unwrap();
2160 assert_eq!(back.thought_signature.as_deref(), Some("sig"));
2161
2162 let old = r#"{"id":"c2","name":"shell","arguments":{}}"#;
2164 let back: SerializedToolCall = serde_json::from_str(old).unwrap();
2165 assert_eq!(back.thought_signature, None);
2166
2167 let without = SerializedToolCall {
2170 id: "c3".into(),
2171 name: "shell".into(),
2172 arguments: serde_json::json!({}),
2173 thought_signature: None,
2174 };
2175 assert!(
2176 !serde_json::to_string(&without)
2177 .unwrap()
2178 .contains("thought_signature")
2179 );
2180 }
2181
2182 #[test]
2183 fn test_add_typed_tainted_entry_checks_budget() {
2184 let mut region = Region::new(
2185 "conversation".to_string(),
2186 RegionKind::SlidingWindow {
2187 max_items: 100,
2188 eviction_strategy: EvictionStrategy::PerItem,
2189 },
2190 5,
2191 )
2192 .with_taint_tracking();
2193
2194 let result = region.add_typed_tainted_entry(
2195 "too large".to_string(),
2196 100,
2197 EntryKind::ToolResult {
2198 tool_call_id: "tc_1".to_string(),
2199 tool_name: "tool".to_string(),
2200 is_error: false,
2201 },
2202 crate::taint::TaintLevel::Internal,
2203 );
2204 assert!(result.is_err());
2205 }
2206
2207 #[test]
2208 fn test_add_typed_tainted_entry_validates_schema() {
2209 let mut region = Region::new("test".to_string(), RegionKind::Pinned, 1000)
2210 .with_taint_tracking()
2211 .with_schema(RegionSchema::new(ContentFormat::Json));
2212
2213 let result = region.add_typed_tainted_entry(
2215 "not json".to_string(),
2216 5,
2217 EntryKind::Text,
2218 crate::taint::TaintLevel::Public,
2219 );
2220 assert!(result.is_err());
2221 }
2222
2223 #[test]
2224 fn test_add_typed_tainted_entry_without_taint_tracking() {
2225 let mut region = Region::new(
2228 "conversation".to_string(),
2229 RegionKind::SlidingWindow {
2230 max_items: 100,
2231 eviction_strategy: EvictionStrategy::PerItem,
2232 },
2233 1000,
2234 );
2235 region
2238 .add_typed_tainted_entry(
2239 "data".to_string(),
2240 10,
2241 EntryKind::Text,
2242 crate::taint::TaintLevel::Private,
2243 )
2244 .unwrap();
2245
2246 assert_eq!(region.content.len(), 1);
2247 assert_eq!(region.taint_level(), None); }
2249
2250 #[test]
2253 fn test_turn_group_size_at_assistant_with_tool_results() {
2254 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
2255 region
2256 .add_typed_entry(
2257 "assistant response".to_string(),
2258 10,
2259 EntryKind::AssistantTurn {
2260 tool_calls: vec![
2261 SerializedToolCall {
2262 id: "tc_1".to_string(),
2263 name: "read_file".to_string(),
2264 arguments: serde_json::json!({}),
2265 thought_signature: None,
2266 },
2267 SerializedToolCall {
2268 id: "tc_2".to_string(),
2269 name: "write_file".to_string(),
2270 arguments: serde_json::json!({}),
2271 thought_signature: None,
2272 },
2273 ],
2274 },
2275 )
2276 .unwrap();
2277 region
2278 .add_typed_entry(
2279 "result 1".to_string(),
2280 5,
2281 EntryKind::ToolResult {
2282 tool_call_id: "tc_1".to_string(),
2283 tool_name: "read_file".to_string(),
2284 is_error: false,
2285 },
2286 )
2287 .unwrap();
2288 region
2289 .add_typed_entry(
2290 "result 2".to_string(),
2291 5,
2292 EntryKind::ToolResult {
2293 tool_call_id: "tc_2".to_string(),
2294 tool_name: "write_file".to_string(),
2295 is_error: false,
2296 },
2297 )
2298 .unwrap();
2299
2300 assert_eq!(region.turn_group_size_at(0), 3);
2301 }
2302
2303 #[test]
2304 fn test_turn_group_size_at_assistant_at_end() {
2305 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
2306 region
2307 .add_typed_entry(
2308 "assistant with no tools".to_string(),
2309 10,
2310 EntryKind::AssistantTurn { tool_calls: vec![] },
2311 )
2312 .unwrap();
2313
2314 assert_eq!(region.turn_group_size_at(0), 1);
2315 }
2316
2317 #[test]
2318 fn test_turn_group_size_at_out_of_bounds() {
2319 let region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
2320 assert_eq!(region.turn_group_size_at(0), 0);
2321 assert_eq!(region.turn_group_size_at(99), 0);
2322 }
2323
2324 #[test]
2325 fn test_turn_group_size_at_non_assistant_entries() {
2326 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
2327 region
2328 .add_typed_entry("hello".to_string(), 5, EntryKind::Text)
2329 .unwrap();
2330 region
2331 .add_typed_entry("hi".to_string(), 5, EntryKind::UserMessage)
2332 .unwrap();
2333 region
2334 .add_typed_entry(
2335 "orphan result".to_string(),
2336 5,
2337 EntryKind::ToolResult {
2338 tool_call_id: "tc_x".to_string(),
2339 tool_name: "tool".to_string(),
2340 is_error: false,
2341 },
2342 )
2343 .unwrap();
2344
2345 assert_eq!(region.turn_group_size_at(0), 1); assert_eq!(region.turn_group_size_at(1), 1); assert_eq!(region.turn_group_size_at(2), 1); }
2349
2350 #[test]
2353 fn test_remove_oldest_evicts_entire_turn_group() {
2354 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
2355 region
2357 .add_typed_entry(
2358 "assistant".to_string(),
2359 100,
2360 EntryKind::AssistantTurn {
2361 tool_calls: vec![
2362 SerializedToolCall {
2363 id: "tc_1".to_string(),
2364 name: "read_file".to_string(),
2365 arguments: serde_json::json!({}),
2366 thought_signature: None,
2367 },
2368 SerializedToolCall {
2369 id: "tc_2".to_string(),
2370 name: "list_dir".to_string(),
2371 arguments: serde_json::json!({}),
2372 thought_signature: None,
2373 },
2374 ],
2375 },
2376 )
2377 .unwrap();
2378 region
2379 .add_typed_entry(
2380 "result 1".to_string(),
2381 30,
2382 EntryKind::ToolResult {
2383 tool_call_id: "tc_1".to_string(),
2384 tool_name: "read_file".to_string(),
2385 is_error: false,
2386 },
2387 )
2388 .unwrap();
2389 region
2390 .add_typed_entry(
2391 "result 2".to_string(),
2392 20,
2393 EntryKind::ToolResult {
2394 tool_call_id: "tc_2".to_string(),
2395 tool_name: "list_dir".to_string(),
2396 is_error: false,
2397 },
2398 )
2399 .unwrap();
2400 region
2402 .add_typed_entry("user msg".to_string(), 10, EntryKind::UserMessage)
2403 .unwrap();
2404
2405 assert_eq!(region.entry_count(), 4);
2406 assert_eq!(region.current_tokens, 160);
2407
2408 let removed = region.remove_oldest().unwrap();
2409 assert_eq!(removed.content, "assistant");
2412 assert_eq!(removed.tokens, 100 + 30 + 20); assert_eq!(region.entry_count(), 1);
2415 assert_eq!(region.content[0].content, "user msg");
2416 assert_eq!(region.current_tokens, 10);
2417 }
2418
2419 #[test]
2422 fn test_remove_oldest_turn_group_calls_taint_remove_for_each_entry() {
2423 let mut region =
2424 Region::new("conv".to_string(), RegionKind::Temporary, 50000).with_taint_tracking();
2425
2426 region
2428 .add_typed_tainted_entry(
2429 "assistant".to_string(),
2430 10,
2431 EntryKind::AssistantTurn {
2432 tool_calls: vec![SerializedToolCall {
2433 id: "tc_1".to_string(),
2434 name: "tool".to_string(),
2435 arguments: serde_json::json!({}),
2436 thought_signature: None,
2437 }],
2438 },
2439 crate::taint::TaintLevel::Private,
2440 )
2441 .unwrap();
2442 region
2443 .add_typed_tainted_entry(
2444 "result".to_string(),
2445 5,
2446 EntryKind::ToolResult {
2447 tool_call_id: "tc_1".to_string(),
2448 tool_name: "tool".to_string(),
2449 is_error: false,
2450 },
2451 crate::taint::TaintLevel::Internal,
2452 )
2453 .unwrap();
2454 region
2455 .add_tainted_entry(
2456 "public stuff".to_string(),
2457 5,
2458 crate::taint::TaintLevel::Public,
2459 )
2460 .unwrap();
2461
2462 assert_eq!(
2463 region.taint_level(),
2464 Some(crate::taint::TaintLevel::Private)
2465 );
2466 assert_eq!(region.taint.as_ref().unwrap().entry_count(), 3);
2467
2468 let removed = region.remove_oldest().unwrap();
2470 assert_eq!(removed.content, "assistant");
2471 assert_eq!(region.entry_count(), 1);
2472 assert_eq!(region.taint.as_ref().unwrap().entry_count(), 1);
2475 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2476 }
2477
2478 #[test]
2481 fn test_sliding_window_evicts_entire_turn_group() {
2482 let mut region = Region::new(
2483 "conv".to_string(),
2484 RegionKind::SlidingWindow {
2485 max_items: 3,
2486 eviction_strategy: EvictionStrategy::PerItem,
2487 },
2488 50000,
2489 );
2490
2491 region
2493 .add_typed_entry(
2494 "assistant".to_string(),
2495 10,
2496 EntryKind::AssistantTurn {
2497 tool_calls: vec![
2498 SerializedToolCall {
2499 id: "tc_1".to_string(),
2500 name: "t1".to_string(),
2501 arguments: serde_json::json!({}),
2502 thought_signature: None,
2503 },
2504 SerializedToolCall {
2505 id: "tc_2".to_string(),
2506 name: "t2".to_string(),
2507 arguments: serde_json::json!({}),
2508 thought_signature: None,
2509 },
2510 ],
2511 },
2512 )
2513 .unwrap();
2514 region
2515 .add_typed_entry(
2516 "r1".to_string(),
2517 5,
2518 EntryKind::ToolResult {
2519 tool_call_id: "tc_1".to_string(),
2520 tool_name: "t1".to_string(),
2521 is_error: false,
2522 },
2523 )
2524 .unwrap();
2525 region
2526 .add_typed_entry(
2527 "r2".to_string(),
2528 5,
2529 EntryKind::ToolResult {
2530 tool_call_id: "tc_2".to_string(),
2531 tool_name: "t2".to_string(),
2532 is_error: false,
2533 },
2534 )
2535 .unwrap();
2536
2537 assert_eq!(region.entry_count(), 3);
2538
2539 region
2542 .add_typed_entry("user msg".to_string(), 15, EntryKind::UserMessage)
2543 .unwrap();
2544
2545 assert_eq!(region.entry_count(), 1);
2547 assert_eq!(region.content[0].content, "user msg");
2548 assert_eq!(region.current_tokens, 15);
2549 }
2550
2551 #[test]
2554 fn test_add_entry_with_metadata_tracks_taint_as_public() {
2555 let mut region =
2556 Region::new("data".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
2557
2558 region
2559 .add_entry_with_metadata("content".to_string(), 10, serde_json::json!({"key": "val"}))
2560 .unwrap();
2561
2562 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2563 assert_eq!(region.taint.as_ref().unwrap().entry_count(), 1);
2564 assert_eq!(
2565 region.taint.as_ref().unwrap().entry_taint(0),
2566 Some(crate::taint::TaintLevel::Public)
2567 );
2568 }
2569
2570 #[test]
2573 fn test_add_typed_entry_tracks_taint_as_public() {
2574 let mut region =
2575 Region::new("conv".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
2576
2577 region
2578 .add_typed_entry(
2579 "assistant response".to_string(),
2580 10,
2581 EntryKind::AssistantTurn { tool_calls: vec![] },
2582 )
2583 .unwrap();
2584
2585 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2586 assert_eq!(region.taint.as_ref().unwrap().entry_count(), 1);
2587 assert_eq!(
2588 region.taint.as_ref().unwrap().entry_taint(0),
2589 Some(crate::taint::TaintLevel::Public)
2590 );
2591 }
2592
2593 #[test]
2596 fn test_per_item_strategy_evicts_one_at_a_time() {
2597 let mut region = Region::new(
2598 "conv".to_string(),
2599 RegionKind::SlidingWindow {
2600 max_items: 3,
2601 eviction_strategy: EvictionStrategy::PerItem,
2602 },
2603 50000,
2604 );
2605 for i in 0..5 {
2606 region.add_entry(format!("msg{}", i), 10).unwrap();
2607 }
2608 assert_eq!(region.entry_count(), 3);
2609 assert_eq!(region.content[0].content, "msg2");
2610 assert_eq!(region.content[1].content, "msg3");
2611 assert_eq!(region.content[2].content, "msg4");
2612 }
2613
2614 #[test]
2615 fn test_bulk_eviction_triggers_on_overflow() {
2616 let mut region = Region::new(
2617 "conv".to_string(),
2618 RegionKind::SlidingWindow {
2619 max_items: 5,
2620 eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2621 },
2622 50000,
2623 );
2624 for i in 0..8 {
2627 region.add_entry(format!("msg{}", i), 10).unwrap();
2628 }
2629 assert_eq!(region.entry_count(), 8);
2630
2631 region.add_entry("msg8".to_string(), 10).unwrap();
2633 assert_eq!(region.entry_count(), 5);
2634 assert_eq!(region.content[0].content, "msg4");
2635 }
2636
2637 #[test]
2638 fn test_bulk_eviction_respects_turn_groups() {
2639 let mut region = Region::new(
2640 "conv".to_string(),
2641 RegionKind::SlidingWindow {
2642 max_items: 3,
2643 eviction_strategy: EvictionStrategy::Bulk { overflow: 2 },
2644 },
2645 50000,
2646 );
2647 region
2649 .add_typed_entry(
2650 "assistant".to_string(),
2651 10,
2652 EntryKind::AssistantTurn {
2653 tool_calls: vec![SerializedToolCall {
2654 id: "tc1".to_string(),
2655 name: "tool".to_string(),
2656 arguments: serde_json::json!({}),
2657 thought_signature: None,
2658 }],
2659 },
2660 )
2661 .unwrap();
2662 region
2663 .add_typed_entry(
2664 "result".to_string(),
2665 5,
2666 EntryKind::ToolResult {
2667 tool_call_id: "tc1".to_string(),
2668 tool_name: "tool".to_string(),
2669 is_error: false,
2670 },
2671 )
2672 .unwrap();
2673 region.add_entry("msg2".to_string(), 10).unwrap();
2675 region.add_entry("msg3".to_string(), 10).unwrap();
2676 region.add_entry("msg4".to_string(), 10).unwrap();
2677 assert_eq!(region.entry_count(), 5);
2679
2680 region.add_entry("msg5".to_string(), 10).unwrap();
2682 assert_eq!(region.entry_count(), 3);
2685 assert_eq!(region.content[0].content, "msg3");
2686 }
2687
2688 #[test]
2689 fn test_bulk_eviction_under_overflow_no_eviction() {
2690 let mut region = Region::new(
2691 "conv".to_string(),
2692 RegionKind::SlidingWindow {
2693 max_items: 5,
2694 eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2695 },
2696 50000,
2697 );
2698 for i in 0..7 {
2700 region.add_entry(format!("msg{}", i), 10).unwrap();
2701 }
2702 assert_eq!(region.entry_count(), 7);
2704 }
2705
2706 #[test]
2707 fn test_compact_sets_needs_message_compaction_flag() {
2708 let mut region = Region::new(
2709 "conv".to_string(),
2710 RegionKind::SlidingWindow {
2711 max_items: 5,
2712 eviction_strategy: EvictionStrategy::Compact { compact_count: 3 },
2713 },
2714 50000,
2715 );
2716 assert!(!region.needs_message_compaction);
2717
2718 for i in 0..9 {
2720 region.add_entry(format!("msg{}", i), 10).unwrap();
2721 }
2722 assert!(region.needs_message_compaction);
2723 assert_eq!(region.entry_count(), 9);
2725 }
2726
2727 #[test]
2728 fn test_compact_fallback_to_bulk_eviction() {
2729 let mut region = Region::new(
2730 "conv".to_string(),
2731 RegionKind::SlidingWindow {
2732 max_items: 5,
2733 eviction_strategy: EvictionStrategy::Compact { compact_count: 3 },
2734 },
2735 50000,
2736 );
2737 for i in 0..12 {
2740 region.add_entry(format!("msg{}", i), 10).unwrap();
2741 }
2742 assert_eq!(region.entry_count(), 5);
2744 assert_eq!(region.content[0].content, "msg7");
2745 assert!(!region.needs_message_compaction);
2747 }
2748
2749 #[test]
2750 fn test_eviction_strategy_default_is_per_item() {
2751 assert_eq!(EvictionStrategy::default(), EvictionStrategy::PerItem);
2752 }
2753
2754 #[test]
2755 fn test_remove_entries_by_prefix() {
2756 let mut region = Region::new("system".to_string(), RegionKind::Pinned, 50000);
2757 region
2758 .add_entry("[Stage instructions: Be terse.]".to_string(), 10)
2759 .unwrap();
2760 region
2761 .add_entry("Core identity block".to_string(), 20)
2762 .unwrap();
2763 region
2764 .add_entry("[Stage instructions: Be verbose.]".to_string(), 15)
2765 .unwrap();
2766
2767 assert_eq!(region.entry_count(), 3);
2768 region.remove_entries_by_prefix("[Stage instructions:");
2769 assert_eq!(region.entry_count(), 1);
2770 assert_eq!(region.content[0].content, "Core identity block");
2771 assert_eq!(region.current_tokens, 20);
2772 }
2773
2774 #[test]
2775 fn test_remove_entries_by_prefix_with_taint_tracking() {
2776 let mut region =
2777 Region::new("system".to_string(), RegionKind::Pinned, 50000).with_taint_tracking();
2778 region
2779 .add_tainted_entry(
2780 "[Stage instructions: Be terse.]".to_string(),
2781 10,
2782 crate::taint::TaintLevel::Private,
2783 )
2784 .unwrap();
2785 region
2786 .add_tainted_entry(
2787 "Core identity block".to_string(),
2788 20,
2789 crate::taint::TaintLevel::Public,
2790 )
2791 .unwrap();
2792 region
2793 .add_tainted_entry(
2794 "[Stage instructions: Be verbose.]".to_string(),
2795 15,
2796 crate::taint::TaintLevel::Internal,
2797 )
2798 .unwrap();
2799
2800 assert_eq!(region.entry_count(), 3);
2801 assert_eq!(
2802 region.taint_level(),
2803 Some(crate::taint::TaintLevel::Private)
2804 );
2805
2806 region.remove_entries_by_prefix("[Stage instructions:");
2807 assert_eq!(region.entry_count(), 1);
2808 assert_eq!(region.content[0].content, "Core identity block");
2809 assert_eq!(region.current_tokens, 20);
2810 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2812 assert_eq!(region.taint.as_ref().unwrap().entry_count(), 1);
2813 }
2814
2815 #[test]
2816 fn test_compact_below_threshold_no_flag() {
2817 let mut region = Region::new(
2819 "conv".to_string(),
2820 RegionKind::SlidingWindow {
2821 max_items: 5,
2822 eviction_strategy: EvictionStrategy::Compact { compact_count: 3 },
2823 },
2824 50000,
2825 );
2826 for i in 0..8 {
2827 region.add_entry(format!("msg{}", i), 10).unwrap();
2828 }
2829 assert!(!region.needs_message_compaction);
2831 assert_eq!(region.entry_count(), 8);
2832 }
2833
2834 #[test]
2835 fn test_bulk_eviction_with_taint_tracking() {
2836 let mut region = Region::new(
2837 "conv".to_string(),
2838 RegionKind::SlidingWindow {
2839 max_items: 3,
2840 eviction_strategy: EvictionStrategy::Bulk { overflow: 2 },
2841 },
2842 50000,
2843 )
2844 .with_taint_tracking();
2845
2846 region
2848 .add_tainted_entry("private".to_string(), 10, crate::taint::TaintLevel::Private)
2849 .unwrap();
2850 for i in 1..5 {
2851 region
2852 .add_tainted_entry(format!("pub{}", i), 10, crate::taint::TaintLevel::Public)
2853 .unwrap();
2854 }
2855 assert_eq!(region.entry_count(), 5);
2856
2857 region
2859 .add_tainted_entry("pub5".to_string(), 10, crate::taint::TaintLevel::Public)
2860 .unwrap();
2861 assert_eq!(region.entry_count(), 3);
2862 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2864 }
2865
2866 #[test]
2867 fn test_eviction_strategy_serde_roundtrip() {
2868 let bulk = EvictionStrategy::Bulk { overflow: 5 };
2869 let json = serde_json::to_string(&bulk).unwrap();
2870 let parsed: EvictionStrategy = serde_json::from_str(&json).unwrap();
2871 assert_eq!(parsed, bulk);
2872
2873 let compact = EvictionStrategy::Compact { compact_count: 10 };
2874 let json = serde_json::to_string(&compact).unwrap();
2875 let parsed: EvictionStrategy = serde_json::from_str(&json).unwrap();
2876 assert_eq!(parsed, compact);
2877
2878 let per_item = EvictionStrategy::PerItem;
2879 let json = serde_json::to_string(&per_item).unwrap();
2880 let parsed: EvictionStrategy = serde_json::from_str(&json).unwrap();
2881 assert_eq!(parsed, per_item);
2882 }
2883
2884 #[test]
2885 fn test_sliding_window_kind_equality_with_eviction_strategy() {
2886 assert_eq!(
2887 RegionKind::SlidingWindow {
2888 max_items: 10,
2889 eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2890 },
2891 RegionKind::SlidingWindow {
2892 max_items: 10,
2893 eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2894 }
2895 );
2896 assert_ne!(
2897 RegionKind::SlidingWindow {
2898 max_items: 10,
2899 eviction_strategy: EvictionStrategy::PerItem,
2900 },
2901 RegionKind::SlidingWindow {
2902 max_items: 10,
2903 eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2904 }
2905 );
2906 }
2907
2908 #[test]
2909 fn test_needs_message_compaction_default_false() {
2910 let region = Region::new("conv".to_string(), RegionKind::Temporary, 1000);
2911 assert!(!region.needs_message_compaction);
2912 }
2913
2914 #[test]
2917 fn test_add_typed_entry_validates_schema() {
2918 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000)
2919 .with_schema(RegionSchema::new(ContentFormat::Json));
2920 let result = region.add_typed_entry("not json".to_string(), 5, EntryKind::Text);
2921 assert!(result.is_err());
2922 assert_eq!(region.entry_count(), 0);
2923 }
2924
2925 #[test]
2926 fn test_add_typed_entry_checks_budget() {
2927 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 10);
2928 let result = region.add_typed_entry("too big".to_string(), 20, EntryKind::UserMessage);
2929 assert!(result.is_err());
2930 assert_eq!(region.entry_count(), 0);
2931 }
2932
2933 #[test]
2934 fn test_add_tainted_entry_without_taint_tracking() {
2935 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
2937 region
2938 .add_tainted_entry("data".to_string(), 10, crate::taint::TaintLevel::Private)
2939 .unwrap();
2940 assert_eq!(region.entry_count(), 1);
2941 assert_eq!(region.taint_level(), None);
2942 }
2943
2944 #[test]
2945 fn test_remove_entries_by_prefix_no_match() {
2946 let mut region = Region::new("system".to_string(), RegionKind::Pinned, 50000);
2947 region.add_entry("Keep this".to_string(), 10).unwrap();
2948 region.add_entry("And this".to_string(), 20).unwrap();
2949 region.remove_entries_by_prefix("[Stage instructions:");
2950 assert_eq!(region.entry_count(), 2);
2951 assert_eq!(region.current_tokens, 30);
2952 }
2953
2954 #[test]
2957 fn test_hashmap_region_upsert_and_get() {
2958 let mut region = Region::new(
2959 "files".to_string(),
2960 RegionKind::HashMap { max_entries: None },
2961 10000,
2962 );
2963 region
2964 .upsert_by_key("src/main.rs", "fn main() {}".to_string(), 10)
2965 .unwrap();
2966 region
2967 .upsert_by_key("src/lib.rs", "pub mod foo;".to_string(), 8)
2968 .unwrap();
2969
2970 assert_eq!(region.entry_count(), 2);
2971 assert_eq!(region.current_tokens, 18);
2972
2973 let entry = region.get_by_key("src/main.rs").unwrap();
2974 assert_eq!(entry.content, "fn main() {}");
2975 assert_eq!(entry.key.as_deref(), Some("src/main.rs"));
2976 }
2977
2978 #[test]
2979 fn test_hashmap_region_upsert_replaces_existing() {
2980 let mut region = Region::new(
2981 "files".to_string(),
2982 RegionKind::HashMap { max_entries: None },
2983 10000,
2984 );
2985 region
2986 .upsert_by_key("file.rs", "version 1".to_string(), 10)
2987 .unwrap();
2988 assert_eq!(region.current_tokens, 10);
2989
2990 region
2991 .upsert_by_key("file.rs", "version 2".to_string(), 15)
2992 .unwrap();
2993 assert_eq!(region.entry_count(), 1);
2994 assert_eq!(region.current_tokens, 15);
2995 assert_eq!(region.get_by_key("file.rs").unwrap().content, "version 2");
2996 }
2997
2998 #[test]
2999 fn test_hashmap_region_remove_by_key() {
3000 let mut region = Region::new(
3001 "files".to_string(),
3002 RegionKind::HashMap { max_entries: None },
3003 10000,
3004 );
3005 region.upsert_by_key("a.rs", "aaa".to_string(), 10).unwrap();
3006 region.upsert_by_key("b.rs", "bbb".to_string(), 20).unwrap();
3007
3008 assert!(region.remove_by_key("a.rs"));
3009 assert_eq!(region.entry_count(), 1);
3010 assert_eq!(region.current_tokens, 20);
3011 assert!(region.get_by_key("a.rs").is_none());
3012 assert!(!region.remove_by_key("nonexistent"));
3013 }
3014
3015 #[test]
3016 fn test_hashmap_region_keys() {
3017 let mut region = Region::new(
3018 "files".to_string(),
3019 RegionKind::HashMap { max_entries: None },
3020 10000,
3021 );
3022 region.upsert_by_key("x.rs", "x".to_string(), 5).unwrap();
3023 region.upsert_by_key("y.rs", "y".to_string(), 5).unwrap();
3024
3025 let keys = region.keys();
3026 assert_eq!(keys.len(), 2);
3027 assert!(keys.contains(&"x.rs"));
3028 assert!(keys.contains(&"y.rs"));
3029 }
3030
3031 #[test]
3032 fn test_hashmap_region_lru_eviction_on_max_tokens() {
3033 let mut region = Region::new(
3034 "files".to_string(),
3035 RegionKind::HashMap { max_entries: None },
3036 30, );
3038 region.upsert_by_key("a.rs", "aaa".to_string(), 10).unwrap();
3039 region.content[0].timestamp -= 100;
3041 region.upsert_by_key("b.rs", "bbb".to_string(), 10).unwrap();
3042 region.upsert_by_key("c.rs", "ccc".to_string(), 10).unwrap();
3043 assert_eq!(region.entry_count(), 3);
3044 assert_eq!(region.current_tokens, 30);
3045
3046 region.upsert_by_key("d.rs", "ddd".to_string(), 10).unwrap();
3048 assert_eq!(region.entry_count(), 3);
3049 assert!(region.get_by_key("a.rs").is_none());
3050 assert!(region.get_by_key("d.rs").is_some());
3051 }
3052
3053 #[test]
3054 fn test_hashmap_region_max_entries_eviction() {
3055 let mut region = Region::new(
3056 "files".to_string(),
3057 RegionKind::HashMap {
3058 max_entries: Some(2),
3059 },
3060 10000,
3061 );
3062 region.upsert_by_key("a.rs", "aaa".to_string(), 10).unwrap();
3063 region.content[0].timestamp -= 100; region.upsert_by_key("b.rs", "bbb".to_string(), 10).unwrap();
3065 assert_eq!(region.entry_count(), 2);
3066
3067 region.upsert_by_key("c.rs", "ccc".to_string(), 10).unwrap();
3069 assert_eq!(region.entry_count(), 2);
3070 assert!(region.get_by_key("a.rs").is_none());
3071 assert!(region.get_by_key("c.rs").is_some());
3072 }
3073
3074 #[test]
3075 fn test_hashmap_region_upsert_too_large_for_budget() {
3076 let mut region = Region::new(
3077 "files".to_string(),
3078 RegionKind::HashMap { max_entries: None },
3079 5, );
3081 let result = region.upsert_by_key("big.rs", "huge content".to_string(), 100);
3082 assert!(result.is_err());
3083 }
3084
3085 #[test]
3086 fn test_hashmap_region_kind_equality() {
3087 assert_eq!(
3088 RegionKind::HashMap {
3089 max_entries: Some(10)
3090 },
3091 RegionKind::HashMap {
3092 max_entries: Some(10)
3093 }
3094 );
3095 assert_ne!(
3096 RegionKind::HashMap {
3097 max_entries: Some(10)
3098 },
3099 RegionKind::HashMap {
3100 max_entries: Some(20)
3101 }
3102 );
3103 assert_ne!(
3104 RegionKind::HashMap { max_entries: None },
3105 RegionKind::Pinned
3106 );
3107 }
3108
3109 #[test]
3110 fn test_hashmap_cache_hint() {
3111 let kind = RegionKind::HashMap { max_entries: None };
3112 assert_eq!(kind.cache_hint(), crate::cache::CacheHint::UntilChanged);
3113 }
3114
3115 #[test]
3116 fn test_region_entry_key_default_none() {
3117 let mut region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
3118 region.add_entry("content".to_string(), 10).unwrap();
3119 assert!(region.content[0].key.is_none());
3120 }
3121
3122 #[test]
3123 fn test_region_entry_key_serde_skip_when_none() {
3124 let entry = RegionEntry {
3125 content: "test".to_string(),
3126 tokens: 5,
3127 timestamp: 0,
3128 metadata: None,
3129 kind: EntryKind::default(),
3130 key: None,
3131 };
3132 let json = serde_json::to_string(&entry).unwrap();
3133 assert!(!json.contains("key"));
3134 }
3135
3136 #[test]
3137 fn test_region_entry_key_serde_roundtrip() {
3138 let entry = RegionEntry {
3139 content: "test".to_string(),
3140 tokens: 5,
3141 timestamp: 0,
3142 metadata: None,
3143 kind: EntryKind::default(),
3144 key: Some("mykey".to_string()),
3145 };
3146 let json = serde_json::to_string(&entry).unwrap();
3147 assert!(json.contains("mykey"));
3148 let back: RegionEntry = serde_json::from_str(&json).unwrap();
3149 assert_eq!(back.key.as_deref(), Some("mykey"));
3150 }
3151
3152 #[test]
3155 fn test_hashmap_region_creation_and_basic_properties() {
3156 let region = Region::new(
3157 "lookup".to_string(),
3158 RegionKind::HashMap {
3159 max_entries: Some(5),
3160 },
3161 2000,
3162 );
3163 assert_eq!(region.name, "lookup");
3164 assert_eq!(
3165 region.kind,
3166 RegionKind::HashMap {
3167 max_entries: Some(5)
3168 }
3169 );
3170 assert_eq!(region.max_tokens, 2000);
3171 assert_eq!(region.current_tokens, 0);
3172 assert_eq!(region.entry_count(), 0);
3173 assert!(region.content.is_empty());
3174 }
3175
3176 #[test]
3177 fn test_hashmap_upsert_insert_new_entry() {
3178 let mut region = Region::new(
3179 "store".to_string(),
3180 RegionKind::HashMap {
3181 max_entries: Some(5),
3182 },
3183 5000,
3184 );
3185 region
3186 .upsert_by_key("config.toml", "[package]\nname = \"foo\"".to_string(), 12)
3187 .unwrap();
3188
3189 assert_eq!(region.entry_count(), 1);
3190 assert_eq!(region.current_tokens, 12);
3191
3192 let entry = region.get_by_key("config.toml").unwrap();
3193 assert_eq!(entry.content, "[package]\nname = \"foo\"");
3194 assert_eq!(entry.tokens, 12);
3195 assert_eq!(entry.key.as_deref(), Some("config.toml"));
3196 }
3197
3198 #[test]
3199 fn test_hashmap_upsert_update_existing_entry() {
3200 let mut region = Region::new(
3201 "store".to_string(),
3202 RegionKind::HashMap { max_entries: None },
3203 5000,
3204 );
3205 region
3206 .upsert_by_key("readme.md", "# Old".to_string(), 20)
3207 .unwrap();
3208 assert_eq!(region.current_tokens, 20);
3209
3210 region
3211 .upsert_by_key("readme.md", "# New and improved".to_string(), 35)
3212 .unwrap();
3213 assert_eq!(region.entry_count(), 1);
3214 assert_eq!(region.current_tokens, 35);
3215
3216 let entry = region.get_by_key("readme.md").unwrap();
3217 assert_eq!(entry.content, "# New and improved");
3218 assert_eq!(entry.tokens, 35);
3219 }
3220
3221 #[test]
3222 fn test_hashmap_upsert_lru_eviction_on_max_tokens() {
3223 let mut region = Region::new(
3224 "files".to_string(),
3225 RegionKind::HashMap { max_entries: None },
3226 100, );
3228
3229 region
3231 .upsert_by_key("first.rs", "first content".to_string(), 40)
3232 .unwrap();
3233 region.content[0].timestamp -= 200; region
3236 .upsert_by_key("second.rs", "second content".to_string(), 40)
3237 .unwrap();
3238 region.content[1].timestamp -= 100; region
3241 .upsert_by_key("third.rs", "third content".to_string(), 20)
3242 .unwrap();
3243 region
3247 .upsert_by_key("fourth.rs", "fourth content".to_string(), 30)
3248 .unwrap();
3249
3250 assert!(region.get_by_key("first.rs").is_none());
3252 assert!(region.get_by_key("fourth.rs").is_some());
3253 assert!(region.current_tokens <= 100);
3255 }
3256
3257 #[test]
3258 fn test_hashmap_upsert_max_entries_enforcement() {
3259 let mut region = Region::new(
3260 "cache".to_string(),
3261 RegionKind::HashMap {
3262 max_entries: Some(2),
3263 },
3264 50000,
3265 );
3266
3267 region
3268 .upsert_by_key("alpha", "aaa".to_string(), 10)
3269 .unwrap();
3270 region.content[0].timestamp -= 200; region.upsert_by_key("beta", "bbb".to_string(), 10).unwrap();
3273 region.content[1].timestamp -= 100;
3274
3275 region
3276 .upsert_by_key("gamma", "ccc".to_string(), 10)
3277 .unwrap();
3278
3279 assert_eq!(region.entry_count(), 2);
3281 assert!(region.get_by_key("alpha").is_none());
3282 assert!(region.get_by_key("beta").is_some());
3283 assert!(region.get_by_key("gamma").is_some());
3284 }
3285
3286 #[test]
3287 fn test_hashmap_get_by_key_found_and_not_found() {
3288 let mut region = Region::new(
3289 "data".to_string(),
3290 RegionKind::HashMap { max_entries: None },
3291 5000,
3292 );
3293 region
3294 .upsert_by_key("exists", "hello".to_string(), 5)
3295 .unwrap();
3296
3297 let found = region.get_by_key("exists");
3299 assert!(found.is_some());
3300 assert_eq!(found.unwrap().content, "hello");
3301
3302 let missing = region.get_by_key("does_not_exist");
3304 assert!(missing.is_none());
3305 }
3306
3307 #[test]
3308 fn test_hashmap_remove_by_key_exists() {
3309 let mut region = Region::new(
3310 "data".to_string(),
3311 RegionKind::HashMap { max_entries: None },
3312 5000,
3313 );
3314 region
3315 .upsert_by_key("target", "remove me".to_string(), 25)
3316 .unwrap();
3317 assert_eq!(region.current_tokens, 25);
3318
3319 let removed = region.remove_by_key("target");
3320 assert!(removed);
3321 assert_eq!(region.entry_count(), 0);
3322 assert_eq!(region.current_tokens, 0);
3323 assert!(region.get_by_key("target").is_none());
3324 }
3325
3326 #[test]
3327 fn test_hashmap_remove_by_key_does_not_exist() {
3328 let mut region = Region::new(
3329 "data".to_string(),
3330 RegionKind::HashMap { max_entries: None },
3331 5000,
3332 );
3333 let removed = region.remove_by_key("ghost");
3334 assert!(!removed);
3335 }
3336
3337 #[test]
3338 fn test_hashmap_keys_empty_populated_after_removal() {
3339 let mut region = Region::new(
3340 "data".to_string(),
3341 RegionKind::HashMap { max_entries: None },
3342 5000,
3343 );
3344
3345 assert!(region.keys().is_empty());
3347
3348 region.upsert_by_key("one", "1".to_string(), 5).unwrap();
3350 region.upsert_by_key("two", "2".to_string(), 5).unwrap();
3351 region.upsert_by_key("three", "3".to_string(), 5).unwrap();
3352
3353 let keys = region.keys();
3354 assert_eq!(keys.len(), 3);
3355 assert!(keys.contains(&"one"));
3356 assert!(keys.contains(&"two"));
3357 assert!(keys.contains(&"three"));
3358
3359 region.remove_by_key("two");
3361 let keys = region.keys();
3362 assert_eq!(keys.len(), 2);
3363 assert!(keys.contains(&"one"));
3364 assert!(!keys.contains(&"two"));
3365 assert!(keys.contains(&"three"));
3366 }
3367
3368 #[test]
3369 fn test_region_entry_serialization_with_key_field() {
3370 let entry_with_key = RegionEntry {
3372 content: "some data".to_string(),
3373 tokens: 10,
3374 timestamp: 1234567890,
3375 metadata: None,
3376 kind: EntryKind::default(),
3377 key: Some("mykey".to_string()),
3378 };
3379 let json = serde_json::to_string(&entry_with_key).unwrap();
3380 let deserialized: RegionEntry = serde_json::from_str(&json).unwrap();
3381 assert_eq!(deserialized.key.as_deref(), Some("mykey"));
3382 assert_eq!(deserialized.content, "some data");
3383 assert_eq!(deserialized.tokens, 10);
3384
3385 let entry_no_key = RegionEntry {
3387 content: "no key data".to_string(),
3388 tokens: 7,
3389 timestamp: 1234567890,
3390 metadata: None,
3391 kind: EntryKind::default(),
3392 key: None,
3393 };
3394 let json = serde_json::to_string(&entry_no_key).unwrap();
3395 assert!(!json.contains("\"key\""));
3396 let deserialized: RegionEntry = serde_json::from_str(&json).unwrap();
3397 assert!(deserialized.key.is_none());
3398 assert_eq!(deserialized.content, "no key data");
3399 }
3400
3401 #[test]
3402 fn test_hashmap_partial_eq() {
3403 let a = RegionKind::HashMap {
3404 max_entries: Some(5),
3405 };
3406 let b = RegionKind::HashMap {
3407 max_entries: Some(5),
3408 };
3409 let c = RegionKind::HashMap {
3410 max_entries: Some(10),
3411 };
3412 let d = RegionKind::HashMap { max_entries: None };
3413
3414 assert_eq!(a, b);
3415 assert_ne!(a, c);
3416 assert_ne!(a, d);
3417 assert_ne!(c, d);
3418 assert_ne!(a, RegionKind::Pinned);
3419 assert_ne!(a, RegionKind::Temporary);
3420 }
3421
3422 #[test]
3423 fn test_hashmap_cache_hint_returns_until_changed() {
3424 let kind = RegionKind::HashMap { max_entries: None };
3425 assert_eq!(kind.cache_hint(), crate::cache::CacheHint::UntilChanged);
3426
3427 let kind_with_max = RegionKind::HashMap {
3428 max_entries: Some(10),
3429 };
3430 assert_eq!(
3431 kind_with_max.cache_hint(),
3432 crate::cache::CacheHint::UntilChanged
3433 );
3434 }
3435
3436 #[test]
3439 fn test_remove_by_key_recomputes_taint_when_tracking_enabled() {
3440 let mut region = Region::new(
3443 "kv".to_string(),
3444 RegionKind::HashMap { max_entries: None },
3445 10_000,
3446 )
3447 .with_taint_tracking();
3448 region
3449 .upsert_by_key("k1", "value one".to_string(), 10)
3450 .unwrap();
3451 region
3452 .upsert_by_key("k2", "value two".to_string(), 10)
3453 .unwrap();
3454
3455 assert!(region.remove_by_key("k1"));
3456 assert!(!region.remove_by_key("missing"));
3457 assert_eq!(region.entry_count(), 1);
3458 assert_eq!(region.current_tokens, 10);
3459 }
3460
3461 #[test]
3462 fn test_evict_lru_entry_runs_taint_fixup() {
3463 let mut region = Region::new(
3467 "kv".to_string(),
3468 RegionKind::HashMap {
3469 max_entries: Some(1),
3470 },
3471 10_000,
3472 )
3473 .with_taint_tracking();
3474 region
3475 .upsert_by_key("first", "aaa".to_string(), 10)
3476 .unwrap();
3477 region
3478 .upsert_by_key("second", "bbb".to_string(), 10)
3479 .unwrap();
3480
3481 assert_eq!(region.entry_count(), 1);
3483 assert!(region.get_by_key("second").is_some());
3484 assert!(region.get_by_key("first").is_none());
3485 }
3486
3487 #[test]
3488 fn test_evict_lru_entry_on_empty_region_is_noop() {
3489 let mut region = Region::new(
3493 "kv".to_string(),
3494 RegionKind::HashMap {
3495 max_entries: Some(4),
3496 },
3497 1000,
3498 );
3499 assert_eq!(region.entry_count(), 0);
3500 region.evict_lru_entry();
3501 assert_eq!(region.entry_count(), 0);
3502 assert_eq!(region.current_tokens, 0);
3503 }
3504
3505 #[test]
3509 fn a_keyed_entry_can_be_added_to_any_region_kind_and_found_again() {
3510 for kind in [
3511 RegionKind::Temporary,
3512 RegionKind::Clearable,
3513 RegionKind::Pinned,
3514 ] {
3515 let mut region = Region::new("r".to_string(), kind.clone(), 1000);
3516 region
3517 .add_keyed_entry("doc", "body".to_string(), 10)
3518 .unwrap();
3519 assert_eq!(
3520 region.get_by_key("doc").map(|e| e.content.as_str()),
3521 Some("body"),
3522 "{kind:?}"
3523 );
3524 assert!(region.remove_by_key("doc"), "{kind:?}");
3525 assert_eq!(region.current_tokens, 0, "{kind:?}");
3526 }
3527 }
3528
3529 #[test]
3533 fn appending_under_one_key_twice_keeps_both_entries() {
3534 let mut region = Region::new("r".to_string(), RegionKind::Temporary, 1000);
3535 region
3536 .add_keyed_entry("doc", "first".to_string(), 5)
3537 .unwrap();
3538 region
3539 .add_keyed_entry("doc", "second".to_string(), 5)
3540 .unwrap();
3541 assert_eq!(region.content.len(), 2);
3542 assert_eq!(region.current_tokens, 10);
3543 }
3544
3545 #[test]
3548 fn a_refused_keyed_write_adds_nothing() {
3549 let mut region = Region::new("r".to_string(), RegionKind::Temporary, 10);
3550 assert!(
3551 region
3552 .add_keyed_entry("doc", "too big".to_string(), 99)
3553 .is_err()
3554 );
3555 assert!(region.content.is_empty());
3556 assert_eq!(region.current_tokens, 0);
3557 }
3558
3559 #[test]
3562 fn remove_at_releases_by_position_and_reports_a_miss() {
3563 let mut region = Region::new("r".to_string(), RegionKind::Temporary, 1000);
3564 for text in ["a", "b", "c"] {
3565 region.add_entry(text.to_string(), 5).unwrap();
3566 }
3567 assert!(region.remove_at(1));
3568 assert_eq!(region.current_tokens, 10);
3569 let left: Vec<_> = region.content.iter().map(|e| e.content.as_str()).collect();
3570 assert_eq!(left, vec!["a", "c"]);
3571
3572 assert!(!region.remove_at(9), "nothing at that position");
3573 assert_eq!(region.content.len(), 2, "a miss changes nothing");
3574 }
3575
3576 #[test]
3579 fn release_oldest_takes_what_it_can_and_says_how_much() {
3580 let mut region = Region::new("r".to_string(), RegionKind::Temporary, 1000);
3581 for text in ["a", "b", "c"] {
3582 region.add_entry(text.to_string(), 5).unwrap();
3583 }
3584 assert_eq!(region.release_oldest(2), 2);
3585 assert_eq!(
3586 region.content.first().map(|e| e.content.as_str()),
3587 Some("c"),
3588 "the oldest two went"
3589 );
3590 assert_eq!(region.release_oldest(10), 1, "only one was left");
3591 assert_eq!(region.release_oldest(3), 0, "and now none");
3592 assert_eq!(region.current_tokens, 0);
3593 }
3594
3595 #[test]
3599 fn a_reject_region_distinguishes_being_full_from_an_oversized_write() {
3600 let mut region = Region::new("r".to_string(), RegionKind::Temporary, 100);
3601 region.admission = Admission::Reject;
3602
3603 let err = region
3609 .add_entry("huge".to_string(), 500)
3610 .unwrap_err()
3611 .to_string();
3612 assert!(err.contains("exceeds token budget"), "{err}");
3613
3614 region.add_entry("fits".to_string(), 90).unwrap();
3615 let err = region
3616 .add_entry("more".to_string(), 50)
3617 .unwrap_err()
3618 .to_string();
3619 assert!(err.contains("Region 'r' is full"), "{err}");
3620 assert!(err.contains("90/100 tokens"), "{err}");
3621 assert!(err.contains("release an entry"), "says what to do: {err}");
3622 }
3623
3624 #[test]
3629 fn a_reject_sliding_window_refuses_rather_than_rolling_off() {
3630 let mut region = Region::new(
3631 "r".to_string(),
3632 RegionKind::SlidingWindow {
3633 max_items: 2,
3634 eviction_strategy: EvictionStrategy::PerItem,
3635 },
3636 1000,
3637 );
3638 region.admission = Admission::Reject;
3639 region.add_entry("one".to_string(), 5).unwrap();
3640 region.add_entry("two".to_string(), 5).unwrap();
3641
3642 let err = region
3643 .add_entry("three".to_string(), 5)
3644 .unwrap_err()
3645 .to_string();
3646 assert!(err.contains("is full"), "{err}");
3647 assert_eq!(region.content.len(), 2);
3648 assert_eq!(
3649 region.content.first().map(|e| e.content.as_str()),
3650 Some("one"),
3651 "the oldest survived"
3652 );
3653
3654 let mut evicting = Region::new(
3657 "r".to_string(),
3658 RegionKind::SlidingWindow {
3659 max_items: 2,
3660 eviction_strategy: EvictionStrategy::PerItem,
3661 },
3662 1000,
3663 );
3664 for text in ["one", "two", "three"] {
3665 evicting.add_entry(text.to_string(), 5).unwrap();
3666 }
3667 assert_eq!(evicting.content.len(), 2);
3668 assert_eq!(
3669 evicting.content.first().map(|e| e.content.as_str()),
3670 Some("two"),
3671 "the oldest rolled off as it always did"
3672 );
3673 }
3674}