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
262mod evict;
263
264impl Region {
265 pub fn checklist_items(&self) -> Vec<ChecklistItem> {
267 self.content
268 .iter()
269 .filter_map(RegionEntry::as_checklist_item)
270 .collect()
271 }
272
273 pub fn open_checklist_items(&self) -> Vec<ChecklistItem> {
275 self.checklist_items()
276 .into_iter()
277 .filter(|i| !i.done)
278 .collect()
279 }
280
281 pub fn add_checklist_item(
288 &mut self,
289 text: String,
290 tokens: usize,
291 ) -> crate::error::Result<usize> {
292 let id = self
293 .checklist_items()
294 .iter()
295 .map(|i| i.id)
296 .max()
297 .unwrap_or(0)
298 + 1;
299 self.add_entry_with_metadata(
300 text,
301 tokens,
302 serde_json::json!({ ITEM_ID: id, ITEM_DONE: false }),
303 )?;
304 Ok(id)
305 }
306
307 pub fn complete_checklist_item(&mut self, id: usize) -> bool {
309 self.set_item_field(id, ITEM_DONE, serde_json::Value::Bool(true))
310 }
311
312 pub fn note_checklist_item(&mut self, id: usize, note: &str) -> bool {
314 self.set_item_field(id, ITEM_NOTE, serde_json::Value::String(note.to_string()))
315 }
316
317 fn set_item_field(&mut self, id: usize, key: &str, value: serde_json::Value) -> bool {
319 for entry in &mut self.content {
320 let is_target = entry
321 .metadata
322 .as_ref()
323 .and_then(|m| m.get(ITEM_ID))
324 .and_then(serde_json::Value::as_u64)
325 .is_some_and(|found| found as usize == id);
326 if is_target && let Some(serde_json::Value::Object(meta)) = entry.metadata.as_mut() {
327 meta.insert(key.to_string(), value);
328 return true;
329 }
330 }
331 false
332 }
333
334 pub fn render_checklist(&self) -> String {
340 let items = self.checklist_items();
341 if items.is_empty() {
342 return String::new();
343 }
344 let (open, done): (Vec<_>, Vec<_>) = items.into_iter().partition(|i| !i.done);
345 let mut out = String::new();
346 for item in open.iter().chain(done.iter()) {
347 let box_ = match item.done {
348 true => "[x]",
349 false => "[ ]",
350 };
351 out.push_str(&format!("{box_} {} {}", item.id, item.text));
352 if let Some(note) = &item.note {
353 out.push_str(&format!("\n note: {note}"));
354 }
355 out.push('\n');
356 }
357 format!(
358 "Checklist ({} open, {} done):\n{}",
359 open.len(),
360 done.len(),
361 out.trim_end()
362 )
363 }
364}
365
366impl RegionKind {
367 pub fn cache_hint(&self) -> crate::cache::CacheHint {
369 match self {
370 RegionKind::Pinned | RegionKind::CompactHistory { .. } => {
371 crate::cache::CacheHint::Always
372 }
373 RegionKind::Compacting { .. } => crate::cache::CacheHint::UntilChanged,
374 RegionKind::SlidingWindow { .. } => crate::cache::CacheHint::SlidingPrefix {
375 stable_fraction: 0.75,
376 },
377 RegionKind::HashMap { .. } => crate::cache::CacheHint::UntilChanged,
378 RegionKind::Checklist => crate::cache::CacheHint::UntilChanged,
381 RegionKind::Temporary | RegionKind::Clearable => crate::cache::CacheHint::Never,
382 RegionKind::Custom { persistent, .. } => {
386 if *persistent {
387 crate::cache::CacheHint::Always
388 } else {
389 crate::cache::CacheHint::UntilChanged
390 }
391 }
392 }
393 }
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct Region {
402 pub name: String,
404
405 pub kind: RegionKind,
407
408 pub content: Vec<RegionEntry>,
410
411 pub max_tokens: usize,
413
414 pub current_tokens: usize,
416
417 pub schema: Option<RegionSchema>,
419
420 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub taint: Option<crate::taint::RegionTaint>,
423
424 #[serde(default)]
428 pub needs_message_compaction: bool,
429
430 #[serde(default = "crate::default_true")]
436 pub summarizable: bool,
437
438 #[serde(default)]
440 pub admission: Admission,
441
442 #[serde(default)]
446 pub volatility: Volatility,
447
448 #[serde(default, skip_serializing_if = "Option::is_none")]
455 pub description: Option<String>,
456
457 #[serde(default)]
467 pub describe_in_prompt: bool,
468}
469
470mod schema;
471
472pub use schema::{ContentFormat, RegionSchema, Validator};
473
474impl Region {
475 pub fn new(name: String, kind: RegionKind, max_tokens: usize) -> Self {
477 Self {
478 name,
479 kind,
480 content: Vec::new(),
481 max_tokens,
482 current_tokens: 0,
483 schema: None,
484 taint: None,
485 needs_message_compaction: false,
486 summarizable: true,
487 admission: Admission::default(),
488 volatility: Volatility::default(),
489 description: None,
490 describe_in_prompt: false,
491 }
492 }
493
494 pub fn with_taint_tracking(mut self) -> Self {
496 self.taint = Some(crate::taint::RegionTaint::new());
497 self
498 }
499
500 pub fn enable_taint_tracking(&mut self) {
502 if self.taint.is_none() {
503 self.taint = Some(crate::taint::RegionTaint::new());
504 }
505 }
506
507 pub fn taint_level(&self) -> Option<crate::taint::TaintLevel> {
509 self.taint.as_ref().map(|t| t.level())
510 }
511
512 fn push_entry(
525 &mut self,
526 content: String,
527 tokens: usize,
528 metadata: Option<serde_json::Value>,
529 kind: EntryKind,
530 taint_level: crate::taint::TaintLevel,
531 key: Option<&str>,
532 ) -> crate::error::Result<()> {
533 if let Some(schema) = &self.schema {
534 schema.validate(&content)?;
535 }
536
537 if self.current_tokens + tokens > self.max_tokens {
538 if self.admission == Admission::Reject && !self.content.is_empty() {
544 return Err(crate::error::Error::RegionFull {
545 region: self.name.clone(),
546 used: self.current_tokens,
547 max: self.max_tokens,
548 });
549 }
550 if self.admission == Admission::Evict && self.kind.rolls_off_oldest() {
556 self.make_room(tokens);
557 }
558 }
559 if self.current_tokens + tokens > self.max_tokens {
563 return Err(crate::error::Error::TokenBudgetExceeded {
564 used: self.current_tokens + tokens,
565 max: self.max_tokens,
566 });
567 }
568 if self.admission == Admission::Reject && self.would_roll_off() {
572 return Err(crate::error::Error::RegionFull {
573 region: self.name.clone(),
574 used: self.current_tokens,
575 max: self.max_tokens,
576 });
577 }
578
579 self.content.push(RegionEntry {
580 content,
581 tokens,
582 timestamp: chrono::Utc::now().timestamp(),
583 metadata,
584 kind,
585 key: key.map(str::to_string),
586 reasoning: None,
587 });
588 self.current_tokens += tokens;
589
590 if let Some(taint) = &mut self.taint {
594 taint.add_entry(taint_level);
595 }
596
597 self.enforce_sliding_window();
598
599 Ok(())
600 }
601
602 pub fn add_keyed_entry(
608 &mut self,
609 key: &str,
610 content: String,
611 tokens: usize,
612 ) -> crate::error::Result<()> {
613 self.push_entry(
614 content,
615 tokens,
616 None,
617 EntryKind::default(),
618 crate::taint::TaintLevel::Public,
619 Some(key),
620 )
621 }
622
623 pub fn remove_at(&mut self, index: usize) -> bool {
629 if index >= self.content.len() {
630 return false;
631 }
632 let entry = self.content.remove(index);
633 self.current_tokens = self.current_tokens.saturating_sub(entry.tokens);
634 true
635 }
636
637 pub fn add_tainted_entry(
639 &mut self,
640 content: String,
641 tokens: usize,
642 taint_level: crate::taint::TaintLevel,
643 ) -> crate::error::Result<()> {
644 self.push_entry(
645 content,
646 tokens,
647 None,
648 EntryKind::default(),
649 taint_level,
650 None,
651 )
652 }
653
654 pub fn add_typed_tainted_entry(
663 &mut self,
664 content: String,
665 tokens: usize,
666 kind: EntryKind,
667 taint_level: crate::taint::TaintLevel,
668 ) -> crate::error::Result<()> {
669 self.push_entry(content, tokens, None, kind, taint_level, None)
670 }
671
672 pub fn with_schema(mut self, schema: RegionSchema) -> Self {
674 self.schema = Some(schema);
675 self
676 }
677
678 pub fn add_entry(&mut self, content: String, tokens: usize) -> crate::error::Result<()> {
683 self.push_entry(
684 content,
685 tokens,
686 None,
687 EntryKind::default(),
688 crate::taint::TaintLevel::Public,
689 None,
690 )
691 }
692
693 pub fn add_entry_with_metadata(
695 &mut self,
696 content: String,
697 tokens: usize,
698 metadata: serde_json::Value,
699 ) -> crate::error::Result<()> {
700 self.push_entry(
701 content,
702 tokens,
703 Some(metadata),
704 EntryKind::default(),
705 crate::taint::TaintLevel::Public,
706 None,
707 )
708 }
709
710 pub fn add_typed_entry(
716 &mut self,
717 content: String,
718 tokens: usize,
719 kind: EntryKind,
720 ) -> crate::error::Result<()> {
721 self.add_typed_entry_with_reasoning(content, tokens, kind, None)
722 }
723
724 pub fn add_typed_entry_with_reasoning(
731 &mut self,
732 content: String,
733 tokens: usize,
734 kind: EntryKind,
735 reasoning: Option<String>,
736 ) -> crate::error::Result<()> {
737 self.push_entry(
738 content,
739 tokens,
740 None,
741 kind,
742 crate::taint::TaintLevel::Public,
743 None,
744 )?;
745 if reasoning.is_some()
748 && let Some(entry) = self.content.last_mut()
749 {
750 entry.reasoning = reasoning;
751 }
752 Ok(())
753 }
754
755 pub fn carry_entry(&mut self, entry: RegionEntry) -> crate::error::Result<()> {
769 if self.current_tokens + entry.tokens > self.max_tokens {
771 return Err(crate::error::Error::TokenBudgetExceeded {
772 used: self.current_tokens + entry.tokens,
773 max: self.max_tokens,
774 });
775 }
776
777 self.current_tokens += entry.tokens;
778 self.content.push(entry);
779
780 self.enforce_sliding_window();
782
783 Ok(())
784 }
785
786 pub fn upsert_by_key(
789 &mut self,
790 key: &str,
791 content: String,
792 tokens: usize,
793 ) -> Result<(), String> {
794 if let Some(pos) = self
796 .content
797 .iter()
798 .position(|e| e.key.as_deref() == Some(key))
799 {
800 let old_tokens = self.content[pos].tokens;
801 self.current_tokens -= old_tokens;
802 self.content[pos].content = content;
803 self.content[pos].tokens = tokens;
804 self.content[pos].timestamp = chrono::Utc::now().timestamp();
805 self.current_tokens += tokens;
806 return Ok(());
807 }
808
809 let max_entries = if let RegionKind::HashMap {
811 max_entries: Some(max),
812 } = &self.kind
813 {
814 Some(*max)
815 } else {
816 None
817 };
818 if let Some(max) = max_entries {
819 while self.content.len() >= max {
820 self.evict_lru_entry();
821 }
822 }
823
824 while self.current_tokens + tokens > self.max_tokens && !self.content.is_empty() {
826 self.evict_lru_entry();
827 }
828
829 if self.current_tokens + tokens > self.max_tokens {
830 return Err(format!(
831 "Entry ({} tokens) exceeds region budget ({} max)",
832 tokens, self.max_tokens
833 ));
834 }
835
836 self.content.push(RegionEntry {
837 content,
838 tokens,
839 timestamp: chrono::Utc::now().timestamp(),
840 metadata: None,
841 kind: EntryKind::default(),
842 key: Some(key.to_string()),
843 reasoning: None,
844 });
845 self.current_tokens += tokens;
846 Ok(())
847 }
848
849 pub fn get_by_key(&self, key: &str) -> Option<&RegionEntry> {
851 self.content.iter().find(|e| e.key.as_deref() == Some(key))
852 }
853
854 pub fn remove_by_key(&mut self, key: &str) -> bool {
856 if let Some(pos) = self
857 .content
858 .iter()
859 .position(|e| e.key.as_deref() == Some(key))
860 {
861 let tokens = self.content[pos].tokens;
862 self.content.remove(pos);
863 self.current_tokens -= tokens;
864 if let Some(taint) = &mut self.taint {
865 taint.remove_at(pos);
866 }
867 true
868 } else {
869 false
870 }
871 }
872
873 pub fn keys(&self) -> Vec<&str> {
875 self.content
876 .iter()
877 .filter_map(|e| e.key.as_deref())
878 .collect()
879 }
880
881 pub fn clear(&mut self) {
883 self.content.clear();
884 self.current_tokens = 0;
885 if let Some(taint) = &mut self.taint {
886 taint.clear();
887 }
888 }
889
890 pub fn remove_entries_by_prefix(&mut self, prefix: &str) {
896 let mut i = 0;
897 while i < self.content.len() {
898 if self.content[i].content.starts_with(prefix) {
899 let tokens = self.content[i].tokens;
900 self.content.remove(i);
901 self.current_tokens -= tokens;
902 if let Some(taint) = &mut self.taint {
903 taint.remove_at(i);
904 }
905 } else {
906 i += 1;
907 }
908 }
909 }
910
911 pub fn entry_count(&self) -> usize {
913 self.content.len()
914 }
915
916 pub fn needs_compaction(&self) -> bool {
918 if let RegionKind::Compacting { threshold_tokens } = self.kind {
919 self.current_tokens > threshold_tokens
920 } else {
921 false
922 }
923 }
924}
925
926#[derive(Debug, Clone, Serialize, Deserialize)]
930pub struct RegionEntry {
931 pub content: String,
933
934 pub tokens: usize,
936
937 pub timestamp: i64,
939
940 pub metadata: Option<serde_json::Value>,
942
943 #[serde(default)]
947 pub kind: EntryKind,
948
949 #[serde(default, skip_serializing_if = "Option::is_none")]
951 pub key: Option<String>,
952
953 #[serde(default, skip_serializing_if = "Option::is_none")]
971 pub reasoning: Option<String>,
972}
973
974#[cfg(test)]
977mod tests {
978
979 #[test]
989 fn an_opaque_reasoning_token_rides_along_with_the_entry_it_belongs_to() {
990 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 100);
991 region
992 .add_typed_entry_with_reasoning(
993 "the answer".to_string(),
994 10,
995 EntryKind::AssistantTurn { tool_calls: vec![] },
996 Some("sealed-blob".to_string()),
997 )
998 .unwrap();
999 assert_eq!(region.content[0].reasoning.as_deref(), Some("sealed-blob"));
1000 }
1001
1002 #[test]
1003 fn an_entry_written_without_one_carries_none() {
1004 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 100);
1005 region.add_entry("plain".to_string(), 10).unwrap();
1006 assert_eq!(region.content[0].reasoning, None);
1007 }
1008
1009 #[test]
1010 fn a_rejected_write_attaches_nothing() {
1011 let mut region = Region::new("conv".to_string(), RegionKind::Pinned, 10);
1014 region.add_entry("first".to_string(), 10).unwrap();
1015 let refused = region.add_typed_entry_with_reasoning(
1016 "second".to_string(),
1017 10,
1018 EntryKind::AssistantTurn { tool_calls: vec![] },
1019 Some("sealed-blob".to_string()),
1020 );
1021 assert!(refused.is_err(), "the region had no room");
1022 assert!(region.content.iter().all(|e| e.reasoning.is_none()));
1023 }
1024
1025 #[test]
1026 fn a_reasoning_token_survives_a_serde_round_trip() {
1027 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 100);
1030 region
1031 .add_typed_entry_with_reasoning(
1032 "x".to_string(),
1033 1,
1034 EntryKind::AssistantTurn { tool_calls: vec![] },
1035 Some("sealed-blob".to_string()),
1036 )
1037 .unwrap();
1038 let json = serde_json::to_string(®ion.content[0]).unwrap();
1039 let back: RegionEntry = serde_json::from_str(&json).unwrap();
1040 assert_eq!(back.reasoning.as_deref(), Some("sealed-blob"));
1041
1042 let older: RegionEntry =
1044 serde_json::from_str(r#"{"content":"x","tokens":1,"timestamp":0,"metadata":null}"#)
1045 .unwrap();
1046 assert_eq!(older.reasoning, None);
1047 }
1048
1049 #[test]
1050 fn an_evicting_region_rolls_the_oldest_off_to_admit_a_write() {
1051 let mut region = Region::new("findings".to_string(), RegionKind::Temporary, 100);
1052 region.add_entry("oldest".to_string(), 40).unwrap();
1053 region.add_entry("middle".to_string(), 40).unwrap();
1054 assert_eq!(region.current_tokens, 80);
1055
1056 region.add_entry("newest".to_string(), 40).unwrap();
1058
1059 assert_eq!(region.current_tokens, 80);
1060 let held: Vec<&str> = region.content.iter().map(|e| e.content.as_str()).collect();
1061 assert_eq!(held, ["middle", "newest"]);
1062 }
1063
1064 #[test]
1066 fn eviction_stops_as_soon_as_the_write_fits() {
1067 let mut region = Region::new("findings".to_string(), RegionKind::Temporary, 100);
1068 for i in 0..5 {
1069 region.add_entry(format!("entry-{i}"), 20).unwrap();
1070 }
1071 region.add_entry("newest".to_string(), 20).unwrap();
1072 let held: Vec<&str> = region.content.iter().map(|e| e.content.as_str()).collect();
1073 assert_eq!(held, ["entry-1", "entry-2", "entry-3", "entry-4", "newest"]);
1074 }
1075
1076 #[test]
1079 fn a_rejecting_region_still_refuses_rather_than_dropping_anything() {
1080 let mut region = Region::new("sources".to_string(), RegionKind::Temporary, 100);
1081 region.admission = Admission::Reject;
1082 region.add_entry("curated".to_string(), 80).unwrap();
1083
1084 let err = region.add_entry("newest".to_string(), 40).unwrap_err();
1085 assert_eq!(
1086 err.to_string(),
1087 "Region 'sources' is full (80/100 tokens) and does not evict automatically - release an entry before adding another"
1088 );
1089 assert_eq!(region.content.len(), 1);
1090 assert_eq!(region.current_tokens, 80);
1091 }
1092
1093 #[test]
1098 fn an_entry_larger_than_the_region_does_not_empty_it() {
1099 let mut region = Region::new("findings".to_string(), RegionKind::Temporary, 100);
1100 region.add_entry("kept".to_string(), 50).unwrap();
1101
1102 let err = region.add_entry("enormous".to_string(), 500).unwrap_err();
1103 assert_eq!(err.to_string(), "Content exceeds token budget: 550 > 100");
1104 assert_eq!(region.content.len(), 1, "the region was not emptied for it");
1105 }
1106
1107 #[test]
1111 fn eviction_never_strands_a_tool_result_without_its_call() {
1112 let mut region = Region::new(
1113 "conversation".to_string(),
1114 RegionKind::SlidingWindow {
1115 max_items: 100,
1116 eviction_strategy: EvictionStrategy::PerItem,
1117 },
1118 100,
1119 );
1120 region
1121 .add_typed_entry(
1122 "call it".to_string(),
1123 30,
1124 EntryKind::AssistantTurn {
1125 tool_calls: vec![crate::SerializedToolCall {
1126 id: "t1".to_string(),
1127 name: "read_file".to_string(),
1128 arguments: serde_json::json!({}),
1129 thought_signature: None,
1130 }],
1131 },
1132 )
1133 .unwrap();
1134 region
1135 .add_typed_entry(
1136 "the answer".to_string(),
1137 30,
1138 EntryKind::ToolResult {
1139 tool_call_id: "t1".to_string(),
1140 tool_name: "read_file".to_string(),
1141 is_error: false,
1142 },
1143 )
1144 .unwrap();
1145
1146 region.add_entry("next turn".to_string(), 60).unwrap();
1148
1149 assert!(
1150 !region
1151 .content
1152 .iter()
1153 .any(|e| matches!(e.kind, EntryKind::ToolResult { .. })),
1154 "the result outlived the call that produced it"
1155 );
1156 }
1157
1158 #[test]
1162 fn kinds_that_own_their_retention_do_not_roll_off() {
1163 assert!(RegionKind::Temporary.rolls_off_oldest());
1164 assert!(RegionKind::Clearable.rolls_off_oldest());
1165 assert!(!RegionKind::Pinned.rolls_off_oldest());
1166 assert!(
1167 !RegionKind::Custom {
1168 script: "r.rhai".to_string(),
1169 persistent: false,
1170 }
1171 .rolls_off_oldest()
1172 );
1173 assert!(!RegionKind::HashMap { max_entries: None }.rolls_off_oldest());
1174
1175 let mut pinned = Region::new("query".to_string(), RegionKind::Pinned, 100);
1177 pinned.add_entry("the task".to_string(), 80).unwrap();
1178 assert!(pinned.add_entry("more".to_string(), 40).is_err());
1179 assert_eq!(pinned.content.len(), 1, "a pinned region kept its content");
1180 }
1181
1182 use super::*;
1183
1184 fn checklist() -> Region {
1187 Region::new("todos".to_string(), RegionKind::Checklist, 10_000)
1188 }
1189
1190 #[test]
1196 fn a_malformed_entry_is_not_an_item() {
1197 let mut r = checklist();
1198 r.add_entry("a plain note".to_string(), 3).unwrap();
1200 r.add_entry_with_metadata(
1202 "something else".to_string(),
1203 3,
1204 serde_json::json!({ "unrelated": true }),
1205 )
1206 .unwrap();
1207 r.add_entry_with_metadata(
1209 "bad id".to_string(),
1210 3,
1211 serde_json::json!({ "checklist_id": "one" }),
1212 )
1213 .unwrap();
1214
1215 assert!(r.checklist_items().is_empty(), "none of those are items");
1216 assert!(r.open_checklist_items().is_empty());
1217 assert!(
1218 r.render_checklist().is_empty(),
1219 "and they do not render as a checklist"
1220 );
1221 }
1222
1223 #[test]
1226 fn a_checklist_caches_until_it_changes() {
1227 assert_eq!(
1228 RegionKind::Checklist.cache_hint(),
1229 crate::cache::CacheHint::UntilChanged
1230 );
1231 }
1232
1233 #[test]
1234 fn a_note_appears_in_the_render() {
1235 let mut r = checklist();
1236 let id = r.add_checklist_item("blocked".to_string(), 2).unwrap();
1237 r.note_checklist_item(id, "waiting on the manual");
1238 let rendered = r.render_checklist();
1239 assert!(
1240 rendered.contains("note: waiting on the manual"),
1241 "{rendered}"
1242 );
1243 }
1244
1245 #[test]
1248 fn an_item_over_budget_is_refused() {
1249 let mut r = Region::new("todos".to_string(), RegionKind::Checklist, 4);
1250 assert!(r.add_checklist_item("x".to_string(), 99).is_err());
1251 assert!(r.checklist_items().is_empty());
1252 }
1253
1254 #[test]
1255 fn an_added_item_starts_open_and_gets_an_id() {
1256 let mut r = checklist();
1257 let first = r
1258 .add_checklist_item("compute the fee table".to_string(), 5)
1259 .unwrap();
1260 let second = r
1261 .add_checklist_item("check the manual".to_string(), 5)
1262 .unwrap();
1263 assert_eq!((first, second), (1, 2), "ids are stable and sequential");
1264 assert_eq!(r.open_checklist_items().len(), 2);
1265 }
1266
1267 #[test]
1268 fn completing_an_item_closes_it_and_nothing_else() {
1269 let mut r = checklist();
1270 let id = r.add_checklist_item("one".to_string(), 2).unwrap();
1271 r.add_checklist_item("two".to_string(), 2).unwrap();
1272
1273 assert!(r.complete_checklist_item(id));
1274 let open = r.open_checklist_items();
1275 assert_eq!(open.len(), 1);
1276 assert_eq!(open[0].text, "two");
1277 assert_eq!(
1278 r.checklist_items().len(),
1279 2,
1280 "done items are kept, not deleted"
1281 );
1282 }
1283
1284 #[test]
1285 fn an_unknown_id_reports_failure_rather_than_ticking_something_else() {
1286 let mut r = checklist();
1289 r.add_checklist_item("one".to_string(), 2).unwrap();
1290 assert!(!r.complete_checklist_item(99));
1291 assert!(!r.note_checklist_item(99, "x"));
1292 assert_eq!(r.open_checklist_items().len(), 1);
1293 }
1294
1295 #[test]
1296 fn a_note_records_without_closing() {
1297 let mut r = checklist();
1298 let id = r
1299 .add_checklist_item("blocked thing".to_string(), 2)
1300 .unwrap();
1301 assert!(r.note_checklist_item(id, "waiting on the manual"));
1302 let item = &r.checklist_items()[0];
1303 assert!(!item.done, "a note is not a completion");
1304 assert_eq!(item.note.as_deref(), Some("waiting on the manual"));
1305 }
1306
1307 #[test]
1310 fn the_render_puts_open_items_first() {
1311 let mut r = checklist();
1312 let done = r
1313 .add_checklist_item("already finished".to_string(), 2)
1314 .unwrap();
1315 r.add_checklist_item("still to do".to_string(), 2).unwrap();
1316 r.complete_checklist_item(done);
1317
1318 let rendered = r.render_checklist();
1319 let open_at = rendered.find("still to do").expect("open item rendered");
1320 let done_at = rendered
1321 .find("already finished")
1322 .expect("done item rendered");
1323 assert!(open_at < done_at, "open before done:\n{rendered}");
1324 assert!(rendered.contains("1 open, 1 done"), "{rendered}");
1325 assert!(
1326 rendered.contains("[x]") && rendered.contains("[ ]"),
1327 "{rendered}"
1328 );
1329 }
1330
1331 #[test]
1332 fn an_empty_checklist_renders_nothing() {
1333 assert!(checklist().render_checklist().is_empty());
1335 }
1336
1337 #[test]
1340 fn ids_do_not_get_reused_after_a_drop() {
1341 let mut r = checklist();
1342 r.add_checklist_item("one".to_string(), 2).unwrap();
1343 let second = r.add_checklist_item("two".to_string(), 2).unwrap();
1344 r.content.remove(0);
1345 let third = r.add_checklist_item("three".to_string(), 2).unwrap();
1346 assert!(third > second, "a reused id would tick off the wrong item");
1347 }
1348
1349 #[test]
1350 fn test_region_creation() {
1351 let region = Region::new("test".to_string(), RegionKind::Pinned, 1000);
1352 assert_eq!(region.name, "test");
1353 assert_eq!(region.max_tokens, 1000);
1354 assert_eq!(region.current_tokens, 0);
1355 }
1356
1357 #[test]
1358 fn test_sliding_window_config() {
1359 let kind = RegionKind::SlidingWindow {
1360 max_items: 10,
1361 eviction_strategy: EvictionStrategy::PerItem,
1362 };
1363 let region = Region::new("history".to_string(), kind.clone(), 5000);
1364 assert_eq!(region.kind, kind);
1365 }
1366
1367 #[test]
1368 fn test_region_kind_equality() {
1369 assert_eq!(RegionKind::Clearable, RegionKind::Clearable);
1370 assert_eq!(
1371 RegionKind::Compacting {
1372 threshold_tokens: 500
1373 },
1374 RegionKind::Compacting {
1375 threshold_tokens: 500
1376 }
1377 );
1378 assert_eq!(
1379 RegionKind::CompactHistory {
1380 source_region: "conv".to_string()
1381 },
1382 RegionKind::CompactHistory {
1383 source_region: "conv".to_string()
1384 }
1385 );
1386 assert_ne!(RegionKind::Pinned, RegionKind::Temporary);
1387 }
1388
1389 #[test]
1390 fn custom_kind_equality_compares_script_and_persistent() {
1391 let a = RegionKind::Custom {
1392 script: "conv.rhai".to_string(),
1393 persistent: false,
1394 };
1395 assert_eq!(a, a.clone());
1396 assert_ne!(
1397 a,
1398 RegionKind::Custom {
1399 script: "other.rhai".to_string(),
1400 persistent: false,
1401 }
1402 );
1403 assert_ne!(
1404 a,
1405 RegionKind::Custom {
1406 script: "conv.rhai".to_string(),
1407 persistent: true,
1408 }
1409 );
1410 assert_ne!(a, RegionKind::Temporary);
1411 }
1412
1413 #[test]
1414 fn custom_kind_serde_round_trips() {
1415 let kind = RegionKind::Custom {
1416 script: "hooks/conv.rhai".to_string(),
1417 persistent: true,
1418 };
1419 let json = serde_json::to_string(&kind).unwrap();
1420 let back: RegionKind = serde_json::from_str(&json).unwrap();
1421 assert_eq!(kind, back);
1422 let old: RegionKind = serde_json::from_str("\"Pinned\"").unwrap();
1424 assert_eq!(old, RegionKind::Pinned);
1425 }
1426
1427 #[test]
1428 fn custom_kind_cache_hint_follows_persistent() {
1429 assert_eq!(
1430 RegionKind::Custom {
1431 script: "s.rhai".to_string(),
1432 persistent: true,
1433 }
1434 .cache_hint(),
1435 crate::cache::CacheHint::Always
1436 );
1437 assert_eq!(
1438 RegionKind::Custom {
1439 script: "s.rhai".to_string(),
1440 persistent: false,
1441 }
1442 .cache_hint(),
1443 crate::cache::CacheHint::UntilChanged
1444 );
1445 }
1446
1447 #[test]
1448 fn carry_entry_preserves_kind_metadata_key_and_timestamp() {
1449 let mut source = Region::new("conversation".to_string(), RegionKind::Temporary, 10_000);
1450 source
1451 .add_typed_entry(
1452 "result body".to_string(),
1453 10,
1454 EntryKind::ToolResult {
1455 tool_call_id: "call_1".to_string(),
1456 tool_name: "read_file".to_string(),
1457 is_error: false,
1458 },
1459 )
1460 .unwrap();
1461 let mut entry = source.content[0].clone();
1462 entry.metadata = Some(serde_json::json!({"origin": "test"}));
1463 entry.key = Some("k".to_string());
1464 let stamped = entry.timestamp;
1465
1466 let mut dest = Region::new("conversation".to_string(), RegionKind::Temporary, 10_000);
1467 dest.carry_entry(entry).unwrap();
1468
1469 let carried = &dest.content[0];
1470 assert!(matches!(
1471 &carried.kind,
1472 EntryKind::ToolResult { tool_call_id, .. } if tool_call_id == "call_1"
1473 ));
1474 assert_eq!(
1475 carried.metadata,
1476 Some(serde_json::json!({"origin": "test"}))
1477 );
1478 assert_eq!(carried.key.as_deref(), Some("k"));
1479 assert_eq!(carried.timestamp, stamped);
1480 assert_eq!(dest.current_tokens, 10);
1481 }
1482
1483 #[test]
1484 fn carry_entry_rejects_over_budget() {
1485 let mut dest = Region::new("small".to_string(), RegionKind::Temporary, 5);
1486 let mut source = Region::new("src".to_string(), RegionKind::Temporary, 100);
1487 source.add_entry("filler".to_string(), 10).unwrap();
1488 let err = dest.carry_entry(source.content[0].clone()).unwrap_err();
1489 assert_eq!(err.to_string(), "Content exceeds token budget: 10 > 5");
1490 assert!(dest.content.is_empty());
1491 assert_eq!(dest.current_tokens, 0);
1492 }
1493
1494 #[test]
1495 fn carry_entry_enforces_sliding_window_max_items() {
1496 let mut source = Region::new("src".to_string(), RegionKind::Temporary, 10_000);
1497 for i in 0..4 {
1498 source.add_entry(format!("msg{i}"), 10).unwrap();
1499 }
1500 let mut dest = Region::new(
1501 "conv".to_string(),
1502 RegionKind::SlidingWindow {
1503 max_items: 3,
1504 eviction_strategy: EvictionStrategy::PerItem,
1505 },
1506 10_000,
1507 );
1508 for entry in &source.content {
1509 dest.carry_entry(entry.clone()).unwrap();
1510 }
1511 assert_eq!(dest.content.len(), 3);
1512 assert_eq!(dest.content[0].content, "msg1");
1513 }
1514
1515 #[test]
1516 fn test_sliding_window_enforces_max_items() {
1517 let mut region = Region::new(
1518 "conv".to_string(),
1519 RegionKind::SlidingWindow {
1520 max_items: 3,
1521 eviction_strategy: EvictionStrategy::PerItem,
1522 },
1523 50000,
1524 );
1525
1526 region.add_entry("msg1".to_string(), 10).unwrap();
1527 region.add_entry("msg2".to_string(), 20).unwrap();
1528 region.add_entry("msg3".to_string(), 30).unwrap();
1529 assert_eq!(region.entry_count(), 3);
1530 assert_eq!(region.current_tokens, 60);
1531
1532 region.add_entry("msg4".to_string(), 40).unwrap();
1534 assert_eq!(region.entry_count(), 3);
1535 assert_eq!(region.content[0].content, "msg2");
1536 assert_eq!(region.content[2].content, "msg4");
1537 assert_eq!(region.current_tokens, 90); region.add_entry("msg5".to_string(), 50).unwrap();
1541 assert_eq!(region.entry_count(), 3);
1542 assert_eq!(region.content[0].content, "msg3");
1543 assert_eq!(region.current_tokens, 120); }
1545
1546 #[test]
1547 fn test_sliding_window_enforces_max_items_with_metadata() {
1548 let mut region = Region::new(
1549 "conv".to_string(),
1550 RegionKind::SlidingWindow {
1551 max_items: 2,
1552 eviction_strategy: EvictionStrategy::PerItem,
1553 },
1554 50000,
1555 );
1556
1557 region
1558 .add_entry_with_metadata("a".to_string(), 10, serde_json::json!({"idx": 1}))
1559 .unwrap();
1560 region
1561 .add_entry_with_metadata("b".to_string(), 20, serde_json::json!({"idx": 2}))
1562 .unwrap();
1563 region
1564 .add_entry_with_metadata("c".to_string(), 30, serde_json::json!({"idx": 3}))
1565 .unwrap();
1566
1567 assert_eq!(region.entry_count(), 2);
1568 assert_eq!(region.content[0].content, "b");
1569 assert_eq!(region.content[1].content, "c");
1570 assert_eq!(region.current_tokens, 50);
1571 }
1572
1573 #[test]
1574 fn test_cache_hint_pinned() {
1575 let kind = RegionKind::Pinned;
1576 assert_eq!(kind.cache_hint(), crate::cache::CacheHint::Always);
1577 }
1578
1579 #[test]
1580 fn test_cache_hint_compact_history() {
1581 let kind = RegionKind::CompactHistory {
1582 source_region: "conv".to_string(),
1583 };
1584 assert_eq!(kind.cache_hint(), crate::cache::CacheHint::Always);
1585 }
1586
1587 #[test]
1588 fn test_cache_hint_compacting() {
1589 let kind = RegionKind::Compacting {
1590 threshold_tokens: 1000,
1591 };
1592 assert_eq!(kind.cache_hint(), crate::cache::CacheHint::UntilChanged);
1593 }
1594
1595 #[test]
1596 fn test_cache_hint_sliding_window() {
1597 let kind = RegionKind::SlidingWindow {
1598 max_items: 10,
1599 eviction_strategy: EvictionStrategy::PerItem,
1600 };
1601 assert_eq!(
1602 kind.cache_hint(),
1603 crate::cache::CacheHint::SlidingPrefix {
1604 stable_fraction: 0.75
1605 }
1606 );
1607 }
1608
1609 #[test]
1610 fn test_cache_hint_temporary() {
1611 assert_eq!(
1612 RegionKind::Temporary.cache_hint(),
1613 crate::cache::CacheHint::Never
1614 );
1615 }
1616
1617 #[test]
1618 fn test_cache_hint_clearable() {
1619 assert_eq!(
1620 RegionKind::Clearable.cache_hint(),
1621 crate::cache::CacheHint::Never
1622 );
1623 }
1624
1625 #[test]
1628 fn test_with_schema_attaches_schema() {
1629 let schema = RegionSchema::new(ContentFormat::Json);
1630 let region =
1631 Region::new("data".to_string(), RegionKind::Temporary, 1000).with_schema(schema);
1632 assert!(region.schema.is_some());
1633 }
1634
1635 #[test]
1636 fn test_add_entry_rejects_content_failing_schema() {
1637 let schema = RegionSchema::new(ContentFormat::Json);
1638 let mut region =
1639 Region::new("data".to_string(), RegionKind::Temporary, 1000).with_schema(schema);
1640 let result = region.add_entry("not json".to_string(), 10);
1641 assert!(result.is_err());
1642 assert_eq!(region.entry_count(), 0);
1643 }
1644
1645 #[test]
1646 fn test_add_entry_accepts_content_passing_schema() {
1647 let schema = RegionSchema::new(ContentFormat::Json);
1648 let mut region =
1649 Region::new("data".to_string(), RegionKind::Temporary, 1000).with_schema(schema);
1650 let result = region.add_entry("{\"a\":1}".to_string(), 10);
1651 assert!(result.is_ok());
1652 assert_eq!(region.entry_count(), 1);
1653 }
1654
1655 #[test]
1656 fn test_add_entry_rejects_over_budget() {
1657 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 10);
1658 let result = region.add_entry("too much".to_string(), 20);
1659 assert_eq!(
1660 result.unwrap_err().to_string(),
1661 "Content exceeds token budget: 20 > 10"
1662 );
1663 assert_eq!(region.entry_count(), 0);
1664 }
1665
1666 #[test]
1667 fn test_add_entry_with_metadata_rejects_content_failing_schema() {
1668 let schema = RegionSchema::new(ContentFormat::Json);
1669 let mut region =
1670 Region::new("data".to_string(), RegionKind::Temporary, 1000).with_schema(schema);
1671 let result =
1672 region.add_entry_with_metadata("not json".to_string(), 10, serde_json::json!({}));
1673 assert!(result.is_err());
1674 }
1675
1676 #[test]
1677 fn test_add_entry_with_metadata_rejects_over_budget() {
1678 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 10);
1679 let result =
1680 region.add_entry_with_metadata("too much".to_string(), 20, serde_json::json!({}));
1681 assert_eq!(
1682 result.unwrap_err().to_string(),
1683 "Content exceeds token budget: 20 > 10"
1684 );
1685 }
1686
1687 #[test]
1688 fn test_add_entry_with_metadata_stores_metadata() {
1689 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1690 region
1691 .add_entry_with_metadata("hello".to_string(), 5, serde_json::json!({"k": "v"}))
1692 .unwrap();
1693 assert_eq!(
1694 region.content[0].metadata,
1695 Some(serde_json::json!({"k": "v"}))
1696 );
1697 }
1698
1699 #[test]
1702 fn test_clear_removes_all_content_and_resets_tokens() {
1703 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1704 region.add_entry("a".to_string(), 10).unwrap();
1705 region.add_entry("b".to_string(), 20).unwrap();
1706 assert_eq!(region.entry_count(), 2);
1707
1708 region.clear();
1709 assert_eq!(region.entry_count(), 0);
1710 assert_eq!(region.current_tokens, 0);
1711 }
1712
1713 #[test]
1714 fn test_remove_oldest_returns_and_removes_first_entry() {
1715 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1716 region.add_entry("first".to_string(), 10).unwrap();
1717 region.add_entry("second".to_string(), 20).unwrap();
1718
1719 let removed = region.remove_oldest().unwrap();
1720 assert_eq!(removed.content, "first");
1721 assert_eq!(region.entry_count(), 1);
1722 assert_eq!(region.current_tokens, 20);
1723 }
1724
1725 #[test]
1726 fn test_remove_oldest_returns_none_when_empty() {
1727 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1728 assert!(region.remove_oldest().is_none());
1729 }
1730
1731 #[test]
1732 fn test_needs_compaction_true_when_over_threshold() {
1733 let mut region = Region::new(
1734 "impl".to_string(),
1735 RegionKind::Compacting {
1736 threshold_tokens: 10,
1737 },
1738 1000,
1739 );
1740 region.add_entry("x".to_string(), 20).unwrap();
1741 assert!(region.needs_compaction());
1742 }
1743
1744 #[test]
1745 fn test_needs_compaction_false_when_under_threshold() {
1746 let mut region = Region::new(
1747 "impl".to_string(),
1748 RegionKind::Compacting {
1749 threshold_tokens: 100,
1750 },
1751 1000,
1752 );
1753 region.add_entry("x".to_string(), 20).unwrap();
1754 assert!(!region.needs_compaction());
1755 }
1756
1757 #[test]
1758 fn test_needs_compaction_false_for_non_compacting_kind() {
1759 let region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1760 assert!(!region.needs_compaction());
1761 }
1762
1763 #[test]
1766 fn test_region_schema_with_custom_script() {
1767 let schema = RegionSchema::new(ContentFormat::Custom {
1768 format_name: "special".to_string(),
1769 })
1770 .with_custom_script("validate_special()".to_string());
1771 assert_eq!(schema.custom_script.as_deref(), Some("validate_special()"));
1772 }
1773
1774 #[test]
1777 fn test_validate_json_valid() {
1778 let schema = RegionSchema::new(ContentFormat::Json);
1779 assert!(schema.validate("{\"a\": 1}").is_ok());
1780 }
1781
1782 #[test]
1783 fn test_validate_json_invalid() {
1784 let schema = RegionSchema::new(ContentFormat::Json);
1785 let err = schema.validate("not json").unwrap_err();
1786 assert!(err.to_string().starts_with("Region validation failed:"));
1787 }
1788
1789 #[test]
1790 fn test_validate_mermaid_valid() {
1791 let schema = RegionSchema::new(ContentFormat::Mermaid);
1792 assert!(schema.validate("graph TD\nA-->B").is_ok());
1793 }
1794
1795 #[test]
1796 fn test_validate_mermaid_all_recognized_diagram_types() {
1797 let schema = RegionSchema::new(ContentFormat::Mermaid);
1798 for kind in [
1799 "graph",
1800 "sequenceDiagram",
1801 "classDiagram",
1802 "stateDiagram",
1803 "erDiagram",
1804 "journey",
1805 "gantt",
1806 "pie",
1807 "flowchart",
1808 ] {
1809 assert!(schema.validate(&format!("{} content", kind)).is_ok());
1810 }
1811 }
1812
1813 #[test]
1814 fn test_validate_mermaid_invalid() {
1815 let schema = RegionSchema::new(ContentFormat::Mermaid);
1816 let err = schema.validate("just some text").unwrap_err();
1817 assert!(err.to_string().starts_with("Region validation failed:"));
1818 }
1819
1820 #[test]
1821 fn test_validate_code_non_empty_is_ok() {
1822 let schema = RegionSchema::new(ContentFormat::Code {
1823 language: "rust".to_string(),
1824 });
1825 assert!(schema.validate("fn main() {}").is_ok());
1826 }
1827
1828 #[test]
1829 fn test_validate_code_empty_is_error() {
1830 let schema = RegionSchema::new(ContentFormat::Code {
1831 language: "rust".to_string(),
1832 });
1833 let err = schema.validate(" ").unwrap_err();
1834 assert!(err.to_string().starts_with("Region validation failed:"));
1835 }
1836
1837 #[test]
1838 fn test_validate_markdown_non_empty_is_ok() {
1839 let schema = RegionSchema::new(ContentFormat::Markdown);
1840 assert!(schema.validate("# Heading").is_ok());
1841 }
1842
1843 #[test]
1844 fn test_validate_markdown_empty_is_error() {
1845 let schema = RegionSchema::new(ContentFormat::Markdown);
1846 let err = schema.validate("").unwrap_err();
1847 assert!(err.to_string().starts_with("Region validation failed:"));
1848 }
1849
1850 #[test]
1851 fn test_validate_text_has_no_restrictions() {
1852 let schema = RegionSchema::new(ContentFormat::Text);
1853 assert!(schema.validate("").is_ok());
1854 assert!(schema.validate("anything at all").is_ok());
1855 }
1856
1857 #[test]
1858 fn test_validate_custom_has_no_restrictions_here() {
1859 let schema = RegionSchema::new(ContentFormat::Custom {
1860 format_name: "special".to_string(),
1861 });
1862 assert!(schema.validate("").is_ok());
1865 assert!(schema.validate("whatever").is_ok());
1866 }
1867
1868 #[test]
1871 fn test_region_schema_clone_preserves_fields() {
1872 let schema = RegionSchema::new(ContentFormat::Text).with_custom_script("s".to_string());
1873 let cloned = schema.clone();
1874 assert_eq!(cloned.custom_script.as_deref(), Some("s"));
1875 assert_eq!(cloned.format, ContentFormat::Text);
1876 }
1877
1878 #[test]
1881 fn test_region_with_taint_tracking() {
1882 let region =
1883 Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
1884 assert!(region.taint.is_some());
1885 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
1886 }
1887
1888 #[test]
1889 fn test_region_without_taint_tracking() {
1890 let region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
1891 assert!(region.taint.is_none());
1892 assert_eq!(region.taint_level(), None);
1893 }
1894
1895 #[test]
1896 fn test_enable_taint_tracking() {
1897 let mut region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
1898 assert!(region.taint.is_none());
1899 region.enable_taint_tracking();
1900 assert!(region.taint.is_some());
1901 region.enable_taint_tracking();
1903 assert!(region.taint.is_some());
1904 }
1905
1906 #[test]
1907 fn test_add_tainted_entry() {
1908 let mut region =
1909 Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
1910 region
1911 .add_tainted_entry(
1912 "secret data".to_string(),
1913 10,
1914 crate::taint::TaintLevel::Private,
1915 )
1916 .unwrap();
1917 assert_eq!(
1918 region.taint_level(),
1919 Some(crate::taint::TaintLevel::Private)
1920 );
1921 assert_eq!(region.entry_count(), 1);
1922 }
1923
1924 #[test]
1925 fn test_add_tainted_entry_validates_schema() {
1926 let mut region = Region::new("test".to_string(), RegionKind::Temporary, 1000)
1927 .with_taint_tracking()
1928 .with_schema(RegionSchema::new(ContentFormat::Json));
1929 let result = region.add_tainted_entry(
1930 "not json".to_string(),
1931 10,
1932 crate::taint::TaintLevel::Internal,
1933 );
1934 assert!(result.is_err());
1935 assert_eq!(region.entry_count(), 0);
1936 }
1937
1938 #[test]
1939 fn test_add_tainted_entry_checks_budget() {
1940 let mut region =
1941 Region::new("test".to_string(), RegionKind::Temporary, 10).with_taint_tracking();
1942 let result = region.add_tainted_entry(
1943 "too much".to_string(),
1944 20,
1945 crate::taint::TaintLevel::Internal,
1946 );
1947 assert!(result.is_err());
1948 }
1949
1950 #[test]
1951 fn test_add_entry_tracks_taint_as_public() {
1952 let mut region =
1953 Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
1954 region.add_entry("public data".to_string(), 10).unwrap();
1955 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
1956 }
1957
1958 #[test]
1959 fn test_taint_recovery_on_remove_oldest() {
1960 let mut region =
1961 Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
1962 region
1963 .add_tainted_entry("private".to_string(), 10, crate::taint::TaintLevel::Private)
1964 .unwrap();
1965 region
1966 .add_tainted_entry("public".to_string(), 10, crate::taint::TaintLevel::Public)
1967 .unwrap();
1968 assert_eq!(
1969 region.taint_level(),
1970 Some(crate::taint::TaintLevel::Private)
1971 );
1972
1973 region.remove_oldest(); assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
1975 }
1976
1977 #[test]
1978 fn test_taint_recovery_on_clear() {
1979 let mut region =
1980 Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
1981 region
1982 .add_tainted_entry("private".to_string(), 10, crate::taint::TaintLevel::Private)
1983 .unwrap();
1984 region.clear();
1985 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
1986 }
1987
1988 #[test]
1989 fn test_taint_recovery_on_sliding_window_eviction() {
1990 let mut region = Region::new(
1991 "conv".to_string(),
1992 RegionKind::SlidingWindow {
1993 max_items: 2,
1994 eviction_strategy: EvictionStrategy::PerItem,
1995 },
1996 50000,
1997 )
1998 .with_taint_tracking();
1999
2000 region
2001 .add_tainted_entry("private".to_string(), 10, crate::taint::TaintLevel::Private)
2002 .unwrap();
2003 region
2004 .add_tainted_entry("public1".to_string(), 10, crate::taint::TaintLevel::Public)
2005 .unwrap();
2006 assert_eq!(
2007 region.taint_level(),
2008 Some(crate::taint::TaintLevel::Private)
2009 );
2010
2011 region
2013 .add_tainted_entry("public2".to_string(), 10, crate::taint::TaintLevel::Public)
2014 .unwrap();
2015 assert_eq!(region.entry_count(), 2);
2016 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2017 }
2018
2019 #[test]
2020 fn test_taint_field_not_serialized_when_none() {
2021 let region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
2022 let json = serde_json::to_string(®ion).unwrap();
2023 assert!(!json.contains("taint"));
2024 }
2025
2026 #[test]
2027 fn test_taint_field_deserialized_as_none_when_missing() {
2028 let json = r#"{"name":"test","kind":"Temporary","content":[],"max_tokens":1000,"current_tokens":0,"schema":null}"#;
2029 let region: Region = serde_json::from_str(json).unwrap();
2030 assert!(region.taint.is_none());
2031 }
2032
2033 #[test]
2034 fn test_add_typed_tainted_entry() {
2035 let mut region = Region::new(
2036 "conversation".to_string(),
2037 RegionKind::SlidingWindow {
2038 max_items: 100,
2039 eviction_strategy: EvictionStrategy::PerItem,
2040 },
2041 1000,
2042 )
2043 .with_taint_tracking();
2044
2045 region
2046 .add_typed_tainted_entry(
2047 "secret data".to_string(),
2048 10,
2049 EntryKind::ToolResult {
2050 tool_call_id: "tc_1".to_string(),
2051 tool_name: "calendar".to_string(),
2052 is_error: false,
2053 },
2054 crate::taint::TaintLevel::Private,
2055 )
2056 .unwrap();
2057
2058 assert_eq!(region.content.len(), 1);
2059 assert_eq!(
2060 region.content[0].kind,
2061 EntryKind::ToolResult {
2062 tool_call_id: "tc_1".to_string(),
2063 tool_name: "calendar".to_string(),
2064 is_error: false,
2065 }
2066 );
2067 assert_eq!(
2068 region.taint_level(),
2069 Some(crate::taint::TaintLevel::Private)
2070 );
2071 }
2072
2073 #[test]
2077 fn serialized_tool_call_round_trips_thought_signature_and_reads_old_json() {
2078 let with = SerializedToolCall {
2079 id: "c1".into(),
2080 name: "shell".into(),
2081 arguments: serde_json::json!({"command": "ls"}),
2082 thought_signature: Some("sig".into()),
2083 };
2084 let json = serde_json::to_string(&with).unwrap();
2085 let back: SerializedToolCall = serde_json::from_str(&json).unwrap();
2086 assert_eq!(back.thought_signature.as_deref(), Some("sig"));
2087
2088 let old = r#"{"id":"c2","name":"shell","arguments":{}}"#;
2090 let back: SerializedToolCall = serde_json::from_str(old).unwrap();
2091 assert_eq!(back.thought_signature, None);
2092
2093 let without = SerializedToolCall {
2096 id: "c3".into(),
2097 name: "shell".into(),
2098 arguments: serde_json::json!({}),
2099 thought_signature: None,
2100 };
2101 assert!(
2102 !serde_json::to_string(&without)
2103 .unwrap()
2104 .contains("thought_signature")
2105 );
2106 }
2107
2108 #[test]
2109 fn test_add_typed_tainted_entry_checks_budget() {
2110 let mut region = Region::new(
2111 "conversation".to_string(),
2112 RegionKind::SlidingWindow {
2113 max_items: 100,
2114 eviction_strategy: EvictionStrategy::PerItem,
2115 },
2116 5,
2117 )
2118 .with_taint_tracking();
2119
2120 let result = region.add_typed_tainted_entry(
2121 "too large".to_string(),
2122 100,
2123 EntryKind::ToolResult {
2124 tool_call_id: "tc_1".to_string(),
2125 tool_name: "tool".to_string(),
2126 is_error: false,
2127 },
2128 crate::taint::TaintLevel::Internal,
2129 );
2130 assert!(result.is_err());
2131 }
2132
2133 #[test]
2134 fn test_add_typed_tainted_entry_validates_schema() {
2135 let mut region = Region::new("test".to_string(), RegionKind::Pinned, 1000)
2136 .with_taint_tracking()
2137 .with_schema(RegionSchema::new(ContentFormat::Json));
2138
2139 let result = region.add_typed_tainted_entry(
2141 "not json".to_string(),
2142 5,
2143 EntryKind::Text,
2144 crate::taint::TaintLevel::Public,
2145 );
2146 assert!(result.is_err());
2147 }
2148
2149 #[test]
2150 fn test_add_typed_tainted_entry_without_taint_tracking() {
2151 let mut region = Region::new(
2154 "conversation".to_string(),
2155 RegionKind::SlidingWindow {
2156 max_items: 100,
2157 eviction_strategy: EvictionStrategy::PerItem,
2158 },
2159 1000,
2160 );
2161 region
2164 .add_typed_tainted_entry(
2165 "data".to_string(),
2166 10,
2167 EntryKind::Text,
2168 crate::taint::TaintLevel::Private,
2169 )
2170 .unwrap();
2171
2172 assert_eq!(region.content.len(), 1);
2173 assert_eq!(region.taint_level(), None); }
2175
2176 #[test]
2179 fn test_turn_group_size_at_assistant_with_tool_results() {
2180 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
2181 region
2182 .add_typed_entry(
2183 "assistant response".to_string(),
2184 10,
2185 EntryKind::AssistantTurn {
2186 tool_calls: vec![
2187 SerializedToolCall {
2188 id: "tc_1".to_string(),
2189 name: "read_file".to_string(),
2190 arguments: serde_json::json!({}),
2191 thought_signature: None,
2192 },
2193 SerializedToolCall {
2194 id: "tc_2".to_string(),
2195 name: "write_file".to_string(),
2196 arguments: serde_json::json!({}),
2197 thought_signature: None,
2198 },
2199 ],
2200 },
2201 )
2202 .unwrap();
2203 region
2204 .add_typed_entry(
2205 "result 1".to_string(),
2206 5,
2207 EntryKind::ToolResult {
2208 tool_call_id: "tc_1".to_string(),
2209 tool_name: "read_file".to_string(),
2210 is_error: false,
2211 },
2212 )
2213 .unwrap();
2214 region
2215 .add_typed_entry(
2216 "result 2".to_string(),
2217 5,
2218 EntryKind::ToolResult {
2219 tool_call_id: "tc_2".to_string(),
2220 tool_name: "write_file".to_string(),
2221 is_error: false,
2222 },
2223 )
2224 .unwrap();
2225
2226 assert_eq!(region.turn_group_size_at(0), 3);
2227 }
2228
2229 #[test]
2230 fn test_turn_group_size_at_assistant_at_end() {
2231 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
2232 region
2233 .add_typed_entry(
2234 "assistant with no tools".to_string(),
2235 10,
2236 EntryKind::AssistantTurn { tool_calls: vec![] },
2237 )
2238 .unwrap();
2239
2240 assert_eq!(region.turn_group_size_at(0), 1);
2241 }
2242
2243 #[test]
2244 fn test_turn_group_size_at_out_of_bounds() {
2245 let region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
2246 assert_eq!(region.turn_group_size_at(0), 0);
2247 assert_eq!(region.turn_group_size_at(99), 0);
2248 }
2249
2250 #[test]
2251 fn test_turn_group_size_at_non_assistant_entries() {
2252 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
2253 region
2254 .add_typed_entry("hello".to_string(), 5, EntryKind::Text)
2255 .unwrap();
2256 region
2257 .add_typed_entry("hi".to_string(), 5, EntryKind::UserMessage)
2258 .unwrap();
2259 region
2260 .add_typed_entry(
2261 "orphan result".to_string(),
2262 5,
2263 EntryKind::ToolResult {
2264 tool_call_id: "tc_x".to_string(),
2265 tool_name: "tool".to_string(),
2266 is_error: false,
2267 },
2268 )
2269 .unwrap();
2270
2271 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); }
2275
2276 #[test]
2279 fn test_remove_oldest_evicts_entire_turn_group() {
2280 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
2281 region
2283 .add_typed_entry(
2284 "assistant".to_string(),
2285 100,
2286 EntryKind::AssistantTurn {
2287 tool_calls: vec![
2288 SerializedToolCall {
2289 id: "tc_1".to_string(),
2290 name: "read_file".to_string(),
2291 arguments: serde_json::json!({}),
2292 thought_signature: None,
2293 },
2294 SerializedToolCall {
2295 id: "tc_2".to_string(),
2296 name: "list_dir".to_string(),
2297 arguments: serde_json::json!({}),
2298 thought_signature: None,
2299 },
2300 ],
2301 },
2302 )
2303 .unwrap();
2304 region
2305 .add_typed_entry(
2306 "result 1".to_string(),
2307 30,
2308 EntryKind::ToolResult {
2309 tool_call_id: "tc_1".to_string(),
2310 tool_name: "read_file".to_string(),
2311 is_error: false,
2312 },
2313 )
2314 .unwrap();
2315 region
2316 .add_typed_entry(
2317 "result 2".to_string(),
2318 20,
2319 EntryKind::ToolResult {
2320 tool_call_id: "tc_2".to_string(),
2321 tool_name: "list_dir".to_string(),
2322 is_error: false,
2323 },
2324 )
2325 .unwrap();
2326 region
2328 .add_typed_entry("user msg".to_string(), 10, EntryKind::UserMessage)
2329 .unwrap();
2330
2331 assert_eq!(region.entry_count(), 4);
2332 assert_eq!(region.current_tokens, 160);
2333
2334 let removed = region.remove_oldest().unwrap();
2335 assert_eq!(removed.content, "assistant");
2338 assert_eq!(removed.tokens, 100 + 30 + 20); assert_eq!(region.entry_count(), 1);
2341 assert_eq!(region.content[0].content, "user msg");
2342 assert_eq!(region.current_tokens, 10);
2343 }
2344
2345 #[test]
2348 fn test_remove_oldest_turn_group_calls_taint_remove_for_each_entry() {
2349 let mut region =
2350 Region::new("conv".to_string(), RegionKind::Temporary, 50000).with_taint_tracking();
2351
2352 region
2354 .add_typed_tainted_entry(
2355 "assistant".to_string(),
2356 10,
2357 EntryKind::AssistantTurn {
2358 tool_calls: vec![SerializedToolCall {
2359 id: "tc_1".to_string(),
2360 name: "tool".to_string(),
2361 arguments: serde_json::json!({}),
2362 thought_signature: None,
2363 }],
2364 },
2365 crate::taint::TaintLevel::Private,
2366 )
2367 .unwrap();
2368 region
2369 .add_typed_tainted_entry(
2370 "result".to_string(),
2371 5,
2372 EntryKind::ToolResult {
2373 tool_call_id: "tc_1".to_string(),
2374 tool_name: "tool".to_string(),
2375 is_error: false,
2376 },
2377 crate::taint::TaintLevel::Internal,
2378 )
2379 .unwrap();
2380 region
2381 .add_tainted_entry(
2382 "public stuff".to_string(),
2383 5,
2384 crate::taint::TaintLevel::Public,
2385 )
2386 .unwrap();
2387
2388 assert_eq!(
2389 region.taint_level(),
2390 Some(crate::taint::TaintLevel::Private)
2391 );
2392 assert_eq!(region.taint.as_ref().unwrap().entry_count(), 3);
2393
2394 let removed = region.remove_oldest().unwrap();
2396 assert_eq!(removed.content, "assistant");
2397 assert_eq!(region.entry_count(), 1);
2398 assert_eq!(region.taint.as_ref().unwrap().entry_count(), 1);
2401 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2402 }
2403
2404 #[test]
2407 fn test_sliding_window_evicts_entire_turn_group() {
2408 let mut region = Region::new(
2409 "conv".to_string(),
2410 RegionKind::SlidingWindow {
2411 max_items: 3,
2412 eviction_strategy: EvictionStrategy::PerItem,
2413 },
2414 50000,
2415 );
2416
2417 region
2419 .add_typed_entry(
2420 "assistant".to_string(),
2421 10,
2422 EntryKind::AssistantTurn {
2423 tool_calls: vec![
2424 SerializedToolCall {
2425 id: "tc_1".to_string(),
2426 name: "t1".to_string(),
2427 arguments: serde_json::json!({}),
2428 thought_signature: None,
2429 },
2430 SerializedToolCall {
2431 id: "tc_2".to_string(),
2432 name: "t2".to_string(),
2433 arguments: serde_json::json!({}),
2434 thought_signature: None,
2435 },
2436 ],
2437 },
2438 )
2439 .unwrap();
2440 region
2441 .add_typed_entry(
2442 "r1".to_string(),
2443 5,
2444 EntryKind::ToolResult {
2445 tool_call_id: "tc_1".to_string(),
2446 tool_name: "t1".to_string(),
2447 is_error: false,
2448 },
2449 )
2450 .unwrap();
2451 region
2452 .add_typed_entry(
2453 "r2".to_string(),
2454 5,
2455 EntryKind::ToolResult {
2456 tool_call_id: "tc_2".to_string(),
2457 tool_name: "t2".to_string(),
2458 is_error: false,
2459 },
2460 )
2461 .unwrap();
2462
2463 assert_eq!(region.entry_count(), 3);
2464
2465 region
2468 .add_typed_entry("user msg".to_string(), 15, EntryKind::UserMessage)
2469 .unwrap();
2470
2471 assert_eq!(region.entry_count(), 1);
2473 assert_eq!(region.content[0].content, "user msg");
2474 assert_eq!(region.current_tokens, 15);
2475 }
2476
2477 #[test]
2480 fn test_add_entry_with_metadata_tracks_taint_as_public() {
2481 let mut region =
2482 Region::new("data".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
2483
2484 region
2485 .add_entry_with_metadata("content".to_string(), 10, serde_json::json!({"key": "val"}))
2486 .unwrap();
2487
2488 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2489 assert_eq!(region.taint.as_ref().unwrap().entry_count(), 1);
2490 assert_eq!(
2491 region.taint.as_ref().unwrap().entry_taint(0),
2492 Some(crate::taint::TaintLevel::Public)
2493 );
2494 }
2495
2496 #[test]
2499 fn test_add_typed_entry_tracks_taint_as_public() {
2500 let mut region =
2501 Region::new("conv".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
2502
2503 region
2504 .add_typed_entry(
2505 "assistant response".to_string(),
2506 10,
2507 EntryKind::AssistantTurn { tool_calls: vec![] },
2508 )
2509 .unwrap();
2510
2511 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2512 assert_eq!(region.taint.as_ref().unwrap().entry_count(), 1);
2513 assert_eq!(
2514 region.taint.as_ref().unwrap().entry_taint(0),
2515 Some(crate::taint::TaintLevel::Public)
2516 );
2517 }
2518
2519 #[test]
2522 fn test_per_item_strategy_evicts_one_at_a_time() {
2523 let mut region = Region::new(
2524 "conv".to_string(),
2525 RegionKind::SlidingWindow {
2526 max_items: 3,
2527 eviction_strategy: EvictionStrategy::PerItem,
2528 },
2529 50000,
2530 );
2531 for i in 0..5 {
2532 region.add_entry(format!("msg{}", i), 10).unwrap();
2533 }
2534 assert_eq!(region.entry_count(), 3);
2535 assert_eq!(region.content[0].content, "msg2");
2536 assert_eq!(region.content[1].content, "msg3");
2537 assert_eq!(region.content[2].content, "msg4");
2538 }
2539
2540 #[test]
2541 fn test_bulk_eviction_triggers_on_overflow() {
2542 let mut region = Region::new(
2543 "conv".to_string(),
2544 RegionKind::SlidingWindow {
2545 max_items: 5,
2546 eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2547 },
2548 50000,
2549 );
2550 for i in 0..8 {
2553 region.add_entry(format!("msg{}", i), 10).unwrap();
2554 }
2555 assert_eq!(region.entry_count(), 8);
2556
2557 region.add_entry("msg8".to_string(), 10).unwrap();
2559 assert_eq!(region.entry_count(), 5);
2560 assert_eq!(region.content[0].content, "msg4");
2561 }
2562
2563 #[test]
2564 fn test_bulk_eviction_respects_turn_groups() {
2565 let mut region = Region::new(
2566 "conv".to_string(),
2567 RegionKind::SlidingWindow {
2568 max_items: 3,
2569 eviction_strategy: EvictionStrategy::Bulk { overflow: 2 },
2570 },
2571 50000,
2572 );
2573 region
2575 .add_typed_entry(
2576 "assistant".to_string(),
2577 10,
2578 EntryKind::AssistantTurn {
2579 tool_calls: vec![SerializedToolCall {
2580 id: "tc1".to_string(),
2581 name: "tool".to_string(),
2582 arguments: serde_json::json!({}),
2583 thought_signature: None,
2584 }],
2585 },
2586 )
2587 .unwrap();
2588 region
2589 .add_typed_entry(
2590 "result".to_string(),
2591 5,
2592 EntryKind::ToolResult {
2593 tool_call_id: "tc1".to_string(),
2594 tool_name: "tool".to_string(),
2595 is_error: false,
2596 },
2597 )
2598 .unwrap();
2599 region.add_entry("msg2".to_string(), 10).unwrap();
2601 region.add_entry("msg3".to_string(), 10).unwrap();
2602 region.add_entry("msg4".to_string(), 10).unwrap();
2603 assert_eq!(region.entry_count(), 5);
2605
2606 region.add_entry("msg5".to_string(), 10).unwrap();
2608 assert_eq!(region.entry_count(), 3);
2611 assert_eq!(region.content[0].content, "msg3");
2612 }
2613
2614 #[test]
2615 fn test_bulk_eviction_under_overflow_no_eviction() {
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..7 {
2626 region.add_entry(format!("msg{}", i), 10).unwrap();
2627 }
2628 assert_eq!(region.entry_count(), 7);
2630 }
2631
2632 #[test]
2633 fn test_compact_sets_needs_message_compaction_flag() {
2634 let mut region = Region::new(
2635 "conv".to_string(),
2636 RegionKind::SlidingWindow {
2637 max_items: 5,
2638 eviction_strategy: EvictionStrategy::Compact { compact_count: 3 },
2639 },
2640 50000,
2641 );
2642 assert!(!region.needs_message_compaction);
2643
2644 for i in 0..9 {
2646 region.add_entry(format!("msg{}", i), 10).unwrap();
2647 }
2648 assert!(region.needs_message_compaction);
2649 assert_eq!(region.entry_count(), 9);
2651 }
2652
2653 #[test]
2654 fn test_compact_fallback_to_bulk_eviction() {
2655 let mut region = Region::new(
2656 "conv".to_string(),
2657 RegionKind::SlidingWindow {
2658 max_items: 5,
2659 eviction_strategy: EvictionStrategy::Compact { compact_count: 3 },
2660 },
2661 50000,
2662 );
2663 for i in 0..12 {
2666 region.add_entry(format!("msg{}", i), 10).unwrap();
2667 }
2668 assert_eq!(region.entry_count(), 5);
2670 assert_eq!(region.content[0].content, "msg7");
2671 assert!(!region.needs_message_compaction);
2673 }
2674
2675 #[test]
2676 fn test_eviction_strategy_default_is_per_item() {
2677 assert_eq!(EvictionStrategy::default(), EvictionStrategy::PerItem);
2678 }
2679
2680 #[test]
2681 fn test_remove_entries_by_prefix() {
2682 let mut region = Region::new("system".to_string(), RegionKind::Pinned, 50000);
2683 region
2684 .add_entry("[Stage instructions: Be terse.]".to_string(), 10)
2685 .unwrap();
2686 region
2687 .add_entry("Core identity block".to_string(), 20)
2688 .unwrap();
2689 region
2690 .add_entry("[Stage instructions: Be verbose.]".to_string(), 15)
2691 .unwrap();
2692
2693 assert_eq!(region.entry_count(), 3);
2694 region.remove_entries_by_prefix("[Stage instructions:");
2695 assert_eq!(region.entry_count(), 1);
2696 assert_eq!(region.content[0].content, "Core identity block");
2697 assert_eq!(region.current_tokens, 20);
2698 }
2699
2700 #[test]
2701 fn test_remove_entries_by_prefix_with_taint_tracking() {
2702 let mut region =
2703 Region::new("system".to_string(), RegionKind::Pinned, 50000).with_taint_tracking();
2704 region
2705 .add_tainted_entry(
2706 "[Stage instructions: Be terse.]".to_string(),
2707 10,
2708 crate::taint::TaintLevel::Private,
2709 )
2710 .unwrap();
2711 region
2712 .add_tainted_entry(
2713 "Core identity block".to_string(),
2714 20,
2715 crate::taint::TaintLevel::Public,
2716 )
2717 .unwrap();
2718 region
2719 .add_tainted_entry(
2720 "[Stage instructions: Be verbose.]".to_string(),
2721 15,
2722 crate::taint::TaintLevel::Internal,
2723 )
2724 .unwrap();
2725
2726 assert_eq!(region.entry_count(), 3);
2727 assert_eq!(
2728 region.taint_level(),
2729 Some(crate::taint::TaintLevel::Private)
2730 );
2731
2732 region.remove_entries_by_prefix("[Stage instructions:");
2733 assert_eq!(region.entry_count(), 1);
2734 assert_eq!(region.content[0].content, "Core identity block");
2735 assert_eq!(region.current_tokens, 20);
2736 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2738 assert_eq!(region.taint.as_ref().unwrap().entry_count(), 1);
2739 }
2740
2741 #[test]
2742 fn test_compact_below_threshold_no_flag() {
2743 let mut region = Region::new(
2745 "conv".to_string(),
2746 RegionKind::SlidingWindow {
2747 max_items: 5,
2748 eviction_strategy: EvictionStrategy::Compact { compact_count: 3 },
2749 },
2750 50000,
2751 );
2752 for i in 0..8 {
2753 region.add_entry(format!("msg{}", i), 10).unwrap();
2754 }
2755 assert!(!region.needs_message_compaction);
2757 assert_eq!(region.entry_count(), 8);
2758 }
2759
2760 #[test]
2761 fn test_bulk_eviction_with_taint_tracking() {
2762 let mut region = Region::new(
2763 "conv".to_string(),
2764 RegionKind::SlidingWindow {
2765 max_items: 3,
2766 eviction_strategy: EvictionStrategy::Bulk { overflow: 2 },
2767 },
2768 50000,
2769 )
2770 .with_taint_tracking();
2771
2772 region
2774 .add_tainted_entry("private".to_string(), 10, crate::taint::TaintLevel::Private)
2775 .unwrap();
2776 for i in 1..5 {
2777 region
2778 .add_tainted_entry(format!("pub{}", i), 10, crate::taint::TaintLevel::Public)
2779 .unwrap();
2780 }
2781 assert_eq!(region.entry_count(), 5);
2782
2783 region
2785 .add_tainted_entry("pub5".to_string(), 10, crate::taint::TaintLevel::Public)
2786 .unwrap();
2787 assert_eq!(region.entry_count(), 3);
2788 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2790 }
2791
2792 #[test]
2793 fn test_eviction_strategy_serde_roundtrip() {
2794 let bulk = EvictionStrategy::Bulk { overflow: 5 };
2795 let json = serde_json::to_string(&bulk).unwrap();
2796 let parsed: EvictionStrategy = serde_json::from_str(&json).unwrap();
2797 assert_eq!(parsed, bulk);
2798
2799 let compact = EvictionStrategy::Compact { compact_count: 10 };
2800 let json = serde_json::to_string(&compact).unwrap();
2801 let parsed: EvictionStrategy = serde_json::from_str(&json).unwrap();
2802 assert_eq!(parsed, compact);
2803
2804 let per_item = EvictionStrategy::PerItem;
2805 let json = serde_json::to_string(&per_item).unwrap();
2806 let parsed: EvictionStrategy = serde_json::from_str(&json).unwrap();
2807 assert_eq!(parsed, per_item);
2808 }
2809
2810 #[test]
2811 fn test_sliding_window_kind_equality_with_eviction_strategy() {
2812 assert_eq!(
2813 RegionKind::SlidingWindow {
2814 max_items: 10,
2815 eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2816 },
2817 RegionKind::SlidingWindow {
2818 max_items: 10,
2819 eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2820 }
2821 );
2822 assert_ne!(
2823 RegionKind::SlidingWindow {
2824 max_items: 10,
2825 eviction_strategy: EvictionStrategy::PerItem,
2826 },
2827 RegionKind::SlidingWindow {
2828 max_items: 10,
2829 eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2830 }
2831 );
2832 }
2833
2834 #[test]
2835 fn test_needs_message_compaction_default_false() {
2836 let region = Region::new("conv".to_string(), RegionKind::Temporary, 1000);
2837 assert!(!region.needs_message_compaction);
2838 }
2839
2840 #[test]
2843 fn test_add_typed_entry_validates_schema() {
2844 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000)
2845 .with_schema(RegionSchema::new(ContentFormat::Json));
2846 let result = region.add_typed_entry("not json".to_string(), 5, EntryKind::Text);
2847 assert!(result.is_err());
2848 assert_eq!(region.entry_count(), 0);
2849 }
2850
2851 #[test]
2852 fn test_add_typed_entry_checks_budget() {
2853 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 10);
2854 let result = region.add_typed_entry("too big".to_string(), 20, EntryKind::UserMessage);
2855 assert!(result.is_err());
2856 assert_eq!(region.entry_count(), 0);
2857 }
2858
2859 #[test]
2860 fn test_add_tainted_entry_without_taint_tracking() {
2861 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
2863 region
2864 .add_tainted_entry("data".to_string(), 10, crate::taint::TaintLevel::Private)
2865 .unwrap();
2866 assert_eq!(region.entry_count(), 1);
2867 assert_eq!(region.taint_level(), None);
2868 }
2869
2870 #[test]
2871 fn test_remove_entries_by_prefix_no_match() {
2872 let mut region = Region::new("system".to_string(), RegionKind::Pinned, 50000);
2873 region.add_entry("Keep this".to_string(), 10).unwrap();
2874 region.add_entry("And this".to_string(), 20).unwrap();
2875 region.remove_entries_by_prefix("[Stage instructions:");
2876 assert_eq!(region.entry_count(), 2);
2877 assert_eq!(region.current_tokens, 30);
2878 }
2879
2880 #[test]
2883 fn test_hashmap_region_upsert_and_get() {
2884 let mut region = Region::new(
2885 "files".to_string(),
2886 RegionKind::HashMap { max_entries: None },
2887 10000,
2888 );
2889 region
2890 .upsert_by_key("src/main.rs", "fn main() {}".to_string(), 10)
2891 .unwrap();
2892 region
2893 .upsert_by_key("src/lib.rs", "pub mod foo;".to_string(), 8)
2894 .unwrap();
2895
2896 assert_eq!(region.entry_count(), 2);
2897 assert_eq!(region.current_tokens, 18);
2898
2899 let entry = region.get_by_key("src/main.rs").unwrap();
2900 assert_eq!(entry.content, "fn main() {}");
2901 assert_eq!(entry.key.as_deref(), Some("src/main.rs"));
2902 }
2903
2904 #[test]
2905 fn test_hashmap_region_upsert_replaces_existing() {
2906 let mut region = Region::new(
2907 "files".to_string(),
2908 RegionKind::HashMap { max_entries: None },
2909 10000,
2910 );
2911 region
2912 .upsert_by_key("file.rs", "version 1".to_string(), 10)
2913 .unwrap();
2914 assert_eq!(region.current_tokens, 10);
2915
2916 region
2917 .upsert_by_key("file.rs", "version 2".to_string(), 15)
2918 .unwrap();
2919 assert_eq!(region.entry_count(), 1);
2920 assert_eq!(region.current_tokens, 15);
2921 assert_eq!(region.get_by_key("file.rs").unwrap().content, "version 2");
2922 }
2923
2924 #[test]
2925 fn test_hashmap_region_remove_by_key() {
2926 let mut region = Region::new(
2927 "files".to_string(),
2928 RegionKind::HashMap { max_entries: None },
2929 10000,
2930 );
2931 region.upsert_by_key("a.rs", "aaa".to_string(), 10).unwrap();
2932 region.upsert_by_key("b.rs", "bbb".to_string(), 20).unwrap();
2933
2934 assert!(region.remove_by_key("a.rs"));
2935 assert_eq!(region.entry_count(), 1);
2936 assert_eq!(region.current_tokens, 20);
2937 assert!(region.get_by_key("a.rs").is_none());
2938 assert!(!region.remove_by_key("nonexistent"));
2939 }
2940
2941 #[test]
2942 fn test_hashmap_region_keys() {
2943 let mut region = Region::new(
2944 "files".to_string(),
2945 RegionKind::HashMap { max_entries: None },
2946 10000,
2947 );
2948 region.upsert_by_key("x.rs", "x".to_string(), 5).unwrap();
2949 region.upsert_by_key("y.rs", "y".to_string(), 5).unwrap();
2950
2951 let keys = region.keys();
2952 assert_eq!(keys.len(), 2);
2953 assert!(keys.contains(&"x.rs"));
2954 assert!(keys.contains(&"y.rs"));
2955 }
2956
2957 #[test]
2958 fn test_hashmap_region_lru_eviction_on_max_tokens() {
2959 let mut region = Region::new(
2960 "files".to_string(),
2961 RegionKind::HashMap { max_entries: None },
2962 30, );
2964 region.upsert_by_key("a.rs", "aaa".to_string(), 10).unwrap();
2965 region.content[0].timestamp -= 100;
2967 region.upsert_by_key("b.rs", "bbb".to_string(), 10).unwrap();
2968 region.upsert_by_key("c.rs", "ccc".to_string(), 10).unwrap();
2969 assert_eq!(region.entry_count(), 3);
2970 assert_eq!(region.current_tokens, 30);
2971
2972 region.upsert_by_key("d.rs", "ddd".to_string(), 10).unwrap();
2974 assert_eq!(region.entry_count(), 3);
2975 assert!(region.get_by_key("a.rs").is_none());
2976 assert!(region.get_by_key("d.rs").is_some());
2977 }
2978
2979 #[test]
2980 fn test_hashmap_region_max_entries_eviction() {
2981 let mut region = Region::new(
2982 "files".to_string(),
2983 RegionKind::HashMap {
2984 max_entries: Some(2),
2985 },
2986 10000,
2987 );
2988 region.upsert_by_key("a.rs", "aaa".to_string(), 10).unwrap();
2989 region.content[0].timestamp -= 100; region.upsert_by_key("b.rs", "bbb".to_string(), 10).unwrap();
2991 assert_eq!(region.entry_count(), 2);
2992
2993 region.upsert_by_key("c.rs", "ccc".to_string(), 10).unwrap();
2995 assert_eq!(region.entry_count(), 2);
2996 assert!(region.get_by_key("a.rs").is_none());
2997 assert!(region.get_by_key("c.rs").is_some());
2998 }
2999
3000 #[test]
3001 fn test_hashmap_region_upsert_too_large_for_budget() {
3002 let mut region = Region::new(
3003 "files".to_string(),
3004 RegionKind::HashMap { max_entries: None },
3005 5, );
3007 let result = region.upsert_by_key("big.rs", "huge content".to_string(), 100);
3008 assert!(result.is_err());
3009 }
3010
3011 #[test]
3012 fn test_hashmap_region_kind_equality() {
3013 assert_eq!(
3014 RegionKind::HashMap {
3015 max_entries: Some(10)
3016 },
3017 RegionKind::HashMap {
3018 max_entries: Some(10)
3019 }
3020 );
3021 assert_ne!(
3022 RegionKind::HashMap {
3023 max_entries: Some(10)
3024 },
3025 RegionKind::HashMap {
3026 max_entries: Some(20)
3027 }
3028 );
3029 assert_ne!(
3030 RegionKind::HashMap { max_entries: None },
3031 RegionKind::Pinned
3032 );
3033 }
3034
3035 #[test]
3036 fn test_hashmap_cache_hint() {
3037 let kind = RegionKind::HashMap { max_entries: None };
3038 assert_eq!(kind.cache_hint(), crate::cache::CacheHint::UntilChanged);
3039 }
3040
3041 #[test]
3042 fn test_region_entry_key_default_none() {
3043 let mut region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
3044 region.add_entry("content".to_string(), 10).unwrap();
3045 assert!(region.content[0].key.is_none());
3046 }
3047
3048 #[test]
3049 fn test_region_entry_key_serde_skip_when_none() {
3050 let entry = RegionEntry {
3051 content: "test".to_string(),
3052 tokens: 5,
3053 timestamp: 0,
3054 metadata: None,
3055 kind: EntryKind::default(),
3056 key: None,
3057 reasoning: None,
3058 };
3059 let json = serde_json::to_string(&entry).unwrap();
3060 assert!(!json.contains("key"));
3061 }
3062
3063 #[test]
3064 fn test_region_entry_key_serde_roundtrip() {
3065 let entry = RegionEntry {
3066 content: "test".to_string(),
3067 tokens: 5,
3068 timestamp: 0,
3069 metadata: None,
3070 kind: EntryKind::default(),
3071 key: Some("mykey".to_string()),
3072 reasoning: None,
3073 };
3074 let json = serde_json::to_string(&entry).unwrap();
3075 assert!(json.contains("mykey"));
3076 let back: RegionEntry = serde_json::from_str(&json).unwrap();
3077 assert_eq!(back.key.as_deref(), Some("mykey"));
3078 }
3079
3080 #[test]
3083 fn test_hashmap_region_creation_and_basic_properties() {
3084 let region = Region::new(
3085 "lookup".to_string(),
3086 RegionKind::HashMap {
3087 max_entries: Some(5),
3088 },
3089 2000,
3090 );
3091 assert_eq!(region.name, "lookup");
3092 assert_eq!(
3093 region.kind,
3094 RegionKind::HashMap {
3095 max_entries: Some(5)
3096 }
3097 );
3098 assert_eq!(region.max_tokens, 2000);
3099 assert_eq!(region.current_tokens, 0);
3100 assert_eq!(region.entry_count(), 0);
3101 assert!(region.content.is_empty());
3102 }
3103
3104 #[test]
3105 fn test_hashmap_upsert_insert_new_entry() {
3106 let mut region = Region::new(
3107 "store".to_string(),
3108 RegionKind::HashMap {
3109 max_entries: Some(5),
3110 },
3111 5000,
3112 );
3113 region
3114 .upsert_by_key("config.toml", "[package]\nname = \"foo\"".to_string(), 12)
3115 .unwrap();
3116
3117 assert_eq!(region.entry_count(), 1);
3118 assert_eq!(region.current_tokens, 12);
3119
3120 let entry = region.get_by_key("config.toml").unwrap();
3121 assert_eq!(entry.content, "[package]\nname = \"foo\"");
3122 assert_eq!(entry.tokens, 12);
3123 assert_eq!(entry.key.as_deref(), Some("config.toml"));
3124 }
3125
3126 #[test]
3127 fn test_hashmap_upsert_update_existing_entry() {
3128 let mut region = Region::new(
3129 "store".to_string(),
3130 RegionKind::HashMap { max_entries: None },
3131 5000,
3132 );
3133 region
3134 .upsert_by_key("readme.md", "# Old".to_string(), 20)
3135 .unwrap();
3136 assert_eq!(region.current_tokens, 20);
3137
3138 region
3139 .upsert_by_key("readme.md", "# New and improved".to_string(), 35)
3140 .unwrap();
3141 assert_eq!(region.entry_count(), 1);
3142 assert_eq!(region.current_tokens, 35);
3143
3144 let entry = region.get_by_key("readme.md").unwrap();
3145 assert_eq!(entry.content, "# New and improved");
3146 assert_eq!(entry.tokens, 35);
3147 }
3148
3149 #[test]
3150 fn test_hashmap_upsert_lru_eviction_on_max_tokens() {
3151 let mut region = Region::new(
3152 "files".to_string(),
3153 RegionKind::HashMap { max_entries: None },
3154 100, );
3156
3157 region
3159 .upsert_by_key("first.rs", "first content".to_string(), 40)
3160 .unwrap();
3161 region.content[0].timestamp -= 200; region
3164 .upsert_by_key("second.rs", "second content".to_string(), 40)
3165 .unwrap();
3166 region.content[1].timestamp -= 100; region
3169 .upsert_by_key("third.rs", "third content".to_string(), 20)
3170 .unwrap();
3171 region
3175 .upsert_by_key("fourth.rs", "fourth content".to_string(), 30)
3176 .unwrap();
3177
3178 assert!(region.get_by_key("first.rs").is_none());
3180 assert!(region.get_by_key("fourth.rs").is_some());
3181 assert!(region.current_tokens <= 100);
3183 }
3184
3185 #[test]
3186 fn test_hashmap_upsert_max_entries_enforcement() {
3187 let mut region = Region::new(
3188 "cache".to_string(),
3189 RegionKind::HashMap {
3190 max_entries: Some(2),
3191 },
3192 50000,
3193 );
3194
3195 region
3196 .upsert_by_key("alpha", "aaa".to_string(), 10)
3197 .unwrap();
3198 region.content[0].timestamp -= 200; region.upsert_by_key("beta", "bbb".to_string(), 10).unwrap();
3201 region.content[1].timestamp -= 100;
3202
3203 region
3204 .upsert_by_key("gamma", "ccc".to_string(), 10)
3205 .unwrap();
3206
3207 assert_eq!(region.entry_count(), 2);
3209 assert!(region.get_by_key("alpha").is_none());
3210 assert!(region.get_by_key("beta").is_some());
3211 assert!(region.get_by_key("gamma").is_some());
3212 }
3213
3214 #[test]
3215 fn test_hashmap_get_by_key_found_and_not_found() {
3216 let mut region = Region::new(
3217 "data".to_string(),
3218 RegionKind::HashMap { max_entries: None },
3219 5000,
3220 );
3221 region
3222 .upsert_by_key("exists", "hello".to_string(), 5)
3223 .unwrap();
3224
3225 let found = region.get_by_key("exists");
3227 assert!(found.is_some());
3228 assert_eq!(found.unwrap().content, "hello");
3229
3230 let missing = region.get_by_key("does_not_exist");
3232 assert!(missing.is_none());
3233 }
3234
3235 #[test]
3236 fn test_hashmap_remove_by_key_exists() {
3237 let mut region = Region::new(
3238 "data".to_string(),
3239 RegionKind::HashMap { max_entries: None },
3240 5000,
3241 );
3242 region
3243 .upsert_by_key("target", "remove me".to_string(), 25)
3244 .unwrap();
3245 assert_eq!(region.current_tokens, 25);
3246
3247 let removed = region.remove_by_key("target");
3248 assert!(removed);
3249 assert_eq!(region.entry_count(), 0);
3250 assert_eq!(region.current_tokens, 0);
3251 assert!(region.get_by_key("target").is_none());
3252 }
3253
3254 #[test]
3255 fn test_hashmap_remove_by_key_does_not_exist() {
3256 let mut region = Region::new(
3257 "data".to_string(),
3258 RegionKind::HashMap { max_entries: None },
3259 5000,
3260 );
3261 let removed = region.remove_by_key("ghost");
3262 assert!(!removed);
3263 }
3264
3265 #[test]
3266 fn test_hashmap_keys_empty_populated_after_removal() {
3267 let mut region = Region::new(
3268 "data".to_string(),
3269 RegionKind::HashMap { max_entries: None },
3270 5000,
3271 );
3272
3273 assert!(region.keys().is_empty());
3275
3276 region.upsert_by_key("one", "1".to_string(), 5).unwrap();
3278 region.upsert_by_key("two", "2".to_string(), 5).unwrap();
3279 region.upsert_by_key("three", "3".to_string(), 5).unwrap();
3280
3281 let keys = region.keys();
3282 assert_eq!(keys.len(), 3);
3283 assert!(keys.contains(&"one"));
3284 assert!(keys.contains(&"two"));
3285 assert!(keys.contains(&"three"));
3286
3287 region.remove_by_key("two");
3289 let keys = region.keys();
3290 assert_eq!(keys.len(), 2);
3291 assert!(keys.contains(&"one"));
3292 assert!(!keys.contains(&"two"));
3293 assert!(keys.contains(&"three"));
3294 }
3295
3296 #[test]
3297 fn test_region_entry_serialization_with_key_field() {
3298 let entry_with_key = RegionEntry {
3300 content: "some data".to_string(),
3301 tokens: 10,
3302 timestamp: 1234567890,
3303 metadata: None,
3304 kind: EntryKind::default(),
3305 key: Some("mykey".to_string()),
3306 reasoning: None,
3307 };
3308 let json = serde_json::to_string(&entry_with_key).unwrap();
3309 let deserialized: RegionEntry = serde_json::from_str(&json).unwrap();
3310 assert_eq!(deserialized.key.as_deref(), Some("mykey"));
3311 assert_eq!(deserialized.content, "some data");
3312 assert_eq!(deserialized.tokens, 10);
3313
3314 let entry_no_key = RegionEntry {
3316 content: "no key data".to_string(),
3317 tokens: 7,
3318 timestamp: 1234567890,
3319 metadata: None,
3320 kind: EntryKind::default(),
3321 key: None,
3322 reasoning: None,
3323 };
3324 let json = serde_json::to_string(&entry_no_key).unwrap();
3325 assert!(!json.contains("\"key\""));
3326 let deserialized: RegionEntry = serde_json::from_str(&json).unwrap();
3327 assert!(deserialized.key.is_none());
3328 assert_eq!(deserialized.content, "no key data");
3329 }
3330
3331 #[test]
3332 fn test_hashmap_partial_eq() {
3333 let a = RegionKind::HashMap {
3334 max_entries: Some(5),
3335 };
3336 let b = RegionKind::HashMap {
3337 max_entries: Some(5),
3338 };
3339 let c = RegionKind::HashMap {
3340 max_entries: Some(10),
3341 };
3342 let d = RegionKind::HashMap { max_entries: None };
3343
3344 assert_eq!(a, b);
3345 assert_ne!(a, c);
3346 assert_ne!(a, d);
3347 assert_ne!(c, d);
3348 assert_ne!(a, RegionKind::Pinned);
3349 assert_ne!(a, RegionKind::Temporary);
3350 }
3351
3352 #[test]
3353 fn test_hashmap_cache_hint_returns_until_changed() {
3354 let kind = RegionKind::HashMap { max_entries: None };
3355 assert_eq!(kind.cache_hint(), crate::cache::CacheHint::UntilChanged);
3356
3357 let kind_with_max = RegionKind::HashMap {
3358 max_entries: Some(10),
3359 };
3360 assert_eq!(
3361 kind_with_max.cache_hint(),
3362 crate::cache::CacheHint::UntilChanged
3363 );
3364 }
3365
3366 #[test]
3369 fn test_remove_by_key_recomputes_taint_when_tracking_enabled() {
3370 let mut region = Region::new(
3373 "kv".to_string(),
3374 RegionKind::HashMap { max_entries: None },
3375 10_000,
3376 )
3377 .with_taint_tracking();
3378 region
3379 .upsert_by_key("k1", "value one".to_string(), 10)
3380 .unwrap();
3381 region
3382 .upsert_by_key("k2", "value two".to_string(), 10)
3383 .unwrap();
3384
3385 assert!(region.remove_by_key("k1"));
3386 assert!(!region.remove_by_key("missing"));
3387 assert_eq!(region.entry_count(), 1);
3388 assert_eq!(region.current_tokens, 10);
3389 }
3390
3391 #[test]
3392 fn test_evict_lru_entry_runs_taint_fixup() {
3393 let mut region = Region::new(
3397 "kv".to_string(),
3398 RegionKind::HashMap {
3399 max_entries: Some(1),
3400 },
3401 10_000,
3402 )
3403 .with_taint_tracking();
3404 region
3405 .upsert_by_key("first", "aaa".to_string(), 10)
3406 .unwrap();
3407 region
3408 .upsert_by_key("second", "bbb".to_string(), 10)
3409 .unwrap();
3410
3411 assert_eq!(region.entry_count(), 1);
3413 assert!(region.get_by_key("second").is_some());
3414 assert!(region.get_by_key("first").is_none());
3415 }
3416
3417 #[test]
3418 fn test_evict_lru_entry_on_empty_region_is_noop() {
3419 let mut region = Region::new(
3423 "kv".to_string(),
3424 RegionKind::HashMap {
3425 max_entries: Some(4),
3426 },
3427 1000,
3428 );
3429 assert_eq!(region.entry_count(), 0);
3430 region.evict_lru_entry();
3431 assert_eq!(region.entry_count(), 0);
3432 assert_eq!(region.current_tokens, 0);
3433 }
3434
3435 #[test]
3439 fn a_keyed_entry_can_be_added_to_any_region_kind_and_found_again() {
3440 for kind in [
3441 RegionKind::Temporary,
3442 RegionKind::Clearable,
3443 RegionKind::Pinned,
3444 ] {
3445 let mut region = Region::new("r".to_string(), kind.clone(), 1000);
3446 region
3447 .add_keyed_entry("doc", "body".to_string(), 10)
3448 .unwrap();
3449 assert_eq!(
3450 region.get_by_key("doc").map(|e| e.content.as_str()),
3451 Some("body"),
3452 "{kind:?}"
3453 );
3454 assert!(region.remove_by_key("doc"), "{kind:?}");
3455 assert_eq!(region.current_tokens, 0, "{kind:?}");
3456 }
3457 }
3458
3459 #[test]
3463 fn appending_under_one_key_twice_keeps_both_entries() {
3464 let mut region = Region::new("r".to_string(), RegionKind::Temporary, 1000);
3465 region
3466 .add_keyed_entry("doc", "first".to_string(), 5)
3467 .unwrap();
3468 region
3469 .add_keyed_entry("doc", "second".to_string(), 5)
3470 .unwrap();
3471 assert_eq!(region.content.len(), 2);
3472 assert_eq!(region.current_tokens, 10);
3473 }
3474
3475 #[test]
3478 fn a_refused_keyed_write_adds_nothing() {
3479 let mut region = Region::new("r".to_string(), RegionKind::Temporary, 10);
3480 assert!(
3481 region
3482 .add_keyed_entry("doc", "too big".to_string(), 99)
3483 .is_err()
3484 );
3485 assert!(region.content.is_empty());
3486 assert_eq!(region.current_tokens, 0);
3487 }
3488
3489 #[test]
3492 fn remove_at_releases_by_position_and_reports_a_miss() {
3493 let mut region = Region::new("r".to_string(), RegionKind::Temporary, 1000);
3494 for text in ["a", "b", "c"] {
3495 region.add_entry(text.to_string(), 5).unwrap();
3496 }
3497 assert!(region.remove_at(1));
3498 assert_eq!(region.current_tokens, 10);
3499 let left: Vec<_> = region.content.iter().map(|e| e.content.as_str()).collect();
3500 assert_eq!(left, vec!["a", "c"]);
3501
3502 assert!(!region.remove_at(9), "nothing at that position");
3503 assert_eq!(region.content.len(), 2, "a miss changes nothing");
3504 }
3505
3506 #[test]
3509 fn release_oldest_takes_what_it_can_and_says_how_much() {
3510 let mut region = Region::new("r".to_string(), RegionKind::Temporary, 1000);
3511 for text in ["a", "b", "c"] {
3512 region.add_entry(text.to_string(), 5).unwrap();
3513 }
3514 assert_eq!(region.release_oldest(2), 2);
3515 assert_eq!(
3516 region.content.first().map(|e| e.content.as_str()),
3517 Some("c"),
3518 "the oldest two went"
3519 );
3520 assert_eq!(region.release_oldest(10), 1, "only one was left");
3521 assert_eq!(region.release_oldest(3), 0, "and now none");
3522 assert_eq!(region.current_tokens, 0);
3523 }
3524
3525 #[test]
3529 fn a_reject_region_distinguishes_being_full_from_an_oversized_write() {
3530 let mut region = Region::new("r".to_string(), RegionKind::Temporary, 100);
3531 region.admission = Admission::Reject;
3532
3533 let err = region
3539 .add_entry("huge".to_string(), 500)
3540 .unwrap_err()
3541 .to_string();
3542 assert!(err.contains("exceeds token budget"), "{err}");
3543
3544 region.add_entry("fits".to_string(), 90).unwrap();
3545 let err = region
3546 .add_entry("more".to_string(), 50)
3547 .unwrap_err()
3548 .to_string();
3549 assert!(err.contains("Region 'r' is full"), "{err}");
3550 assert!(err.contains("90/100 tokens"), "{err}");
3551 assert!(err.contains("release an entry"), "says what to do: {err}");
3552 }
3553
3554 #[test]
3559 fn a_reject_sliding_window_refuses_rather_than_rolling_off() {
3560 let mut region = Region::new(
3561 "r".to_string(),
3562 RegionKind::SlidingWindow {
3563 max_items: 2,
3564 eviction_strategy: EvictionStrategy::PerItem,
3565 },
3566 1000,
3567 );
3568 region.admission = Admission::Reject;
3569 region.add_entry("one".to_string(), 5).unwrap();
3570 region.add_entry("two".to_string(), 5).unwrap();
3571
3572 let err = region
3573 .add_entry("three".to_string(), 5)
3574 .unwrap_err()
3575 .to_string();
3576 assert!(err.contains("is full"), "{err}");
3577 assert_eq!(region.content.len(), 2);
3578 assert_eq!(
3579 region.content.first().map(|e| e.content.as_str()),
3580 Some("one"),
3581 "the oldest survived"
3582 );
3583
3584 let mut evicting = Region::new(
3587 "r".to_string(),
3588 RegionKind::SlidingWindow {
3589 max_items: 2,
3590 eviction_strategy: EvictionStrategy::PerItem,
3591 },
3592 1000,
3593 );
3594 for text in ["one", "two", "three"] {
3595 evicting.add_entry(text.to_string(), 5).unwrap();
3596 }
3597 assert_eq!(evicting.content.len(), 2);
3598 assert_eq!(
3599 evicting.content.first().map(|e| e.content.as_str()),
3600 Some("two"),
3601 "the oldest rolled off as it always did"
3602 );
3603 }
3604
3605 #[test]
3609 fn a_saturated_window_does_not_abort_on_its_first_write() {
3610 let mut region = Region::new(
3611 "w".to_string(),
3612 RegionKind::SlidingWindow {
3613 max_items: usize::MAX,
3614 eviction_strategy: EvictionStrategy::Bulk { overflow: 10 },
3615 },
3616 100,
3617 );
3618 region.add_entry("x".to_string(), 1).unwrap();
3619 let mut region = Region::new(
3620 "w".to_string(),
3621 RegionKind::SlidingWindow {
3622 max_items: usize::MAX,
3623 eviction_strategy: EvictionStrategy::Compact { compact_count: 10 },
3624 },
3625 100,
3626 );
3627 region.add_entry("x".to_string(), 1).unwrap();
3628 }
3629}