1use serde::{Deserialize, Serialize};
8
9pub mod parts;
10pub mod policy;
11
12pub use parts::EntryContent;
13pub use policy::{Admission, EvictionStrategy, Volatility};
14
15#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
22#[serde(tag = "type")]
23pub enum EntryKind {
24 #[default]
26 Text,
27 UserMessage,
29 AssistantTurn {
31 tool_calls: Vec<SerializedToolCall>,
35 },
36 ToolResult {
38 tool_call_id: String,
41 tool_name: String,
43 is_error: bool,
46 },
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct SerializedToolCall {
52 pub id: String,
55 pub name: String,
57 pub arguments: serde_json::Value,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub thought_signature: Option<String>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
72pub enum RegionKind {
73 Pinned,
80
81 SlidingWindow {
88 max_items: usize,
90 eviction_strategy: EvictionStrategy,
92 },
93
94 Temporary,
100
101 Compacting {
107 threshold_tokens: usize,
109 },
110
111 Clearable,
118
119 CompactHistory {
126 source_region: String,
128 },
129
130 HashMap {
134 max_entries: Option<usize>,
136 },
137
138 Checklist,
148
149 Custom {
165 script: String,
167 #[serde(alias = "persistent")]
172 pinned: bool,
173 },
174}
175
176impl PartialEq for RegionKind {
177 #[inline(never)]
178 fn eq(&self, other: &Self) -> bool {
179 match (self, other) {
180 (Self::Pinned, Self::Pinned)
181 | (Self::Temporary, Self::Temporary)
182 | (Self::Clearable, Self::Clearable) => true,
183 (
184 Self::SlidingWindow {
185 max_items: a,
186 eviction_strategy: sa,
187 },
188 Self::SlidingWindow {
189 max_items: b,
190 eviction_strategy: sb,
191 },
192 ) => a == b && sa == sb,
193 (
194 Self::Compacting {
195 threshold_tokens: a,
196 },
197 Self::Compacting {
198 threshold_tokens: b,
199 },
200 ) => a == b,
201 (
202 Self::CompactHistory { source_region: a },
203 Self::CompactHistory { source_region: b },
204 ) => a == b,
205 (Self::HashMap { max_entries: a }, Self::HashMap { max_entries: b }) => a == b,
206 (Self::Checklist, Self::Checklist) => true,
207 (
208 Self::Custom {
209 script: a,
210 pinned: pa,
211 },
212 Self::Custom {
213 script: b,
214 pinned: pb,
215 },
216 ) => a == b && pa == pb,
217 _ => false,
218 }
219 }
220}
221impl Eq for RegionKind {}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct ChecklistItem {
231 pub id: usize,
233 pub text: String,
235 pub done: bool,
237 pub note: Option<String>,
239}
240
241const ITEM_ID: &str = "checklist_id";
243const ITEM_DONE: &str = "checklist_done";
245const ITEM_NOTE: &str = "checklist_note";
247
248impl RegionEntry {
249 pub fn as_checklist_item(&self) -> Option<ChecklistItem> {
251 let meta = self.metadata.as_ref()?;
252 Some(ChecklistItem {
253 id: meta.get(ITEM_ID)?.as_u64()? as usize,
254 text: self.content.to_string(),
255 done: meta
256 .get(ITEM_DONE)
257 .and_then(|v| v.as_bool())
258 .unwrap_or(false),
259 note: meta
260 .get(ITEM_NOTE)
261 .and_then(|v| v.as_str())
262 .map(str::to_string),
263 })
264 }
265}
266
267mod evict;
268
269impl Region {
270 pub fn checklist_items(&self) -> Vec<ChecklistItem> {
272 self.content
273 .iter()
274 .filter_map(RegionEntry::as_checklist_item)
275 .collect()
276 }
277
278 pub fn open_checklist_items(&self) -> Vec<ChecklistItem> {
280 self.checklist_items()
281 .into_iter()
282 .filter(|i| !i.done)
283 .collect()
284 }
285
286 pub fn add_checklist_item(
293 &mut self,
294 text: String,
295 tokens: usize,
296 ) -> crate::error::Result<usize> {
297 let id = self
298 .checklist_items()
299 .iter()
300 .map(|i| i.id)
301 .max()
302 .unwrap_or(0)
303 + 1;
304 self.add_entry_with_metadata(
305 text,
306 tokens,
307 serde_json::json!({ ITEM_ID: id, ITEM_DONE: false }),
308 )?;
309 Ok(id)
310 }
311
312 pub fn complete_checklist_item(&mut self, id: usize) -> bool {
314 self.set_item_field(id, ITEM_DONE, serde_json::Value::Bool(true))
315 }
316
317 pub fn note_checklist_item(&mut self, id: usize, note: &str) -> bool {
319 self.set_item_field(id, ITEM_NOTE, serde_json::Value::String(note.to_string()))
320 }
321
322 fn set_item_field(&mut self, id: usize, key: &str, value: serde_json::Value) -> bool {
324 for entry in &mut self.content {
325 let is_target = entry
326 .metadata
327 .as_ref()
328 .and_then(|m| m.get(ITEM_ID))
329 .and_then(serde_json::Value::as_u64)
330 .is_some_and(|found| found as usize == id);
331 if is_target && let Some(serde_json::Value::Object(meta)) = entry.metadata.as_mut() {
332 meta.insert(key.to_string(), value);
333 return true;
334 }
335 }
336 false
337 }
338
339 pub fn render_checklist(&self) -> String {
345 let items = self.checklist_items();
346 if items.is_empty() {
347 return String::new();
348 }
349 let (open, done): (Vec<_>, Vec<_>) = items.into_iter().partition(|i| !i.done);
350 let mut out = String::new();
351 for item in open.iter().chain(done.iter()) {
352 let box_ = match item.done {
353 true => "[x]",
354 false => "[ ]",
355 };
356 out.push_str(&format!("{box_} {} {}", item.id, item.text));
357 if let Some(note) = &item.note {
358 out.push_str(&format!("\n note: {note}"));
359 }
360 out.push('\n');
361 }
362 format!(
363 "Checklist ({} open, {} done):\n{}",
364 open.len(),
365 done.len(),
366 out.trim_end()
367 )
368 }
369}
370
371impl RegionKind {
372 pub fn cache_hint(&self) -> crate::cache::CacheHint {
374 match self {
375 RegionKind::Pinned | RegionKind::CompactHistory { .. } => {
376 crate::cache::CacheHint::Always
377 }
378 RegionKind::Compacting { .. } => crate::cache::CacheHint::UntilChanged,
379 RegionKind::SlidingWindow { .. } => crate::cache::CacheHint::SlidingPrefix {
380 stable_fraction: 0.75,
381 },
382 RegionKind::HashMap { .. } => crate::cache::CacheHint::UntilChanged,
383 RegionKind::Checklist => crate::cache::CacheHint::UntilChanged,
386 RegionKind::Temporary | RegionKind::Clearable => crate::cache::CacheHint::Never,
387 RegionKind::Custom { pinned, .. } => {
391 if *pinned {
392 crate::cache::CacheHint::Always
393 } else {
394 crate::cache::CacheHint::UntilChanged
395 }
396 }
397 }
398 }
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize)]
406pub struct Region {
407 pub name: String,
409
410 pub kind: RegionKind,
412
413 pub content: Vec<RegionEntry>,
415
416 pub max_tokens: usize,
418
419 pub current_tokens: usize,
421
422 pub schema: Option<RegionSchema>,
424
425 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub taint: Option<crate::taint::RegionTaint>,
428
429 #[serde(default)]
433 pub needs_message_compaction: bool,
434
435 #[serde(default = "crate::default_true")]
441 pub summarizable: bool,
442
443 #[serde(default)]
445 pub admission: Admission,
446
447 #[serde(default)]
451 pub volatility: Volatility,
452
453 #[serde(default, skip_serializing_if = "Vec::is_empty")]
457 pub accepts: Vec<String>,
458
459 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub description: Option<String>,
467
468 #[serde(default)]
478 pub describe_in_prompt: bool,
479}
480
481mod schema;
482
483pub use schema::{ContentFormat, RegionSchema, Validator};
484
485impl Region {
486 pub fn new(name: String, kind: RegionKind, max_tokens: usize) -> Self {
488 Self {
489 name,
490 kind,
491 content: Vec::new(),
492 max_tokens,
493 current_tokens: 0,
494 schema: None,
495 taint: None,
496 needs_message_compaction: false,
497 summarizable: true,
498 admission: Admission::default(),
499 volatility: Volatility::default(),
500 accepts: Vec::new(),
501 description: None,
502 describe_in_prompt: false,
503 }
504 }
505
506 pub fn accepts_content(&self, content: &EntryContent) -> Result<(), crate::mime::MimeType> {
509 if self.accepts.is_empty() {
510 return Ok(());
511 }
512 match content
521 .parts()
522 .iter()
523 .filter(|p| !p.mime_type.matches("text/plain"))
524 .find(|p| !p.mime_type.matches_any(&self.accepts))
525 {
526 Some(p) => Err(p.mime_type.clone()),
527 None => Ok(()),
528 }
529 }
530
531 pub fn stored_count(&self) -> usize {
533 self.content.iter().map(|e| e.content.stored_count()).sum()
534 }
535
536 pub fn with_taint_tracking(mut self) -> Self {
538 self.taint = Some(crate::taint::RegionTaint::new());
539 self
540 }
541
542 pub fn enable_taint_tracking(&mut self) {
544 if self.taint.is_none() {
545 self.taint = Some(crate::taint::RegionTaint::new());
546 }
547 }
548
549 pub fn taint_level(&self) -> Option<crate::taint::TaintLevel> {
551 self.taint.as_ref().map(|t| t.level())
552 }
553
554 fn push_entry(
567 &mut self,
568 content: EntryContent,
569 tokens: usize,
570 metadata: Option<serde_json::Value>,
571 kind: EntryKind,
572 taint_level: crate::taint::TaintLevel,
573 key: Option<&str>,
574 ) -> crate::error::Result<()> {
575 if let Some(schema) = &self.schema {
576 if content.has_stored() {
579 return Err(crate::error::Error::ValidationFailed(format!(
580 "region '{}' validates its entries and cannot hold a stored part",
581 self.name
582 )));
583 }
584 schema.validate(&content)?;
585 }
586 if let Err(mime_type) = self.accepts_content(&content) {
587 return Err(crate::error::Error::RegionRefusedWrite {
588 region: self.name.clone(),
589 reason: format!(
590 "it takes {} and this write carries {mime_type}",
591 self.accepts.join(", ")
592 ),
593 });
594 }
595 if self.current_tokens + tokens > self.max_tokens {
596 if self.admission == Admission::Reject && !self.content.is_empty() {
602 return Err(crate::error::Error::RegionFull {
603 region: self.name.clone(),
604 used: self.current_tokens,
605 max: self.max_tokens,
606 });
607 }
608 if self.admission == Admission::Evict && self.kind.rolls_off_oldest() {
614 self.make_room(tokens);
615 }
616 }
617 if self.current_tokens + tokens > self.max_tokens {
621 return Err(crate::error::Error::TokenBudgetExceeded {
622 used: self.current_tokens + tokens,
623 max: self.max_tokens,
624 });
625 }
626 if self.admission == Admission::Reject && self.would_roll_off() {
630 return Err(crate::error::Error::RegionFull {
631 region: self.name.clone(),
632 used: self.current_tokens,
633 max: self.max_tokens,
634 });
635 }
636
637 self.content.push(RegionEntry {
638 content,
639 tokens,
640 timestamp: chrono::Utc::now().timestamp(),
641 metadata,
642 kind,
643 key: key.map(str::to_string),
644 reasoning: None,
645 });
646 self.current_tokens += tokens;
647
648 if let Some(taint) = &mut self.taint {
652 taint.add_entry(taint_level);
653 }
654
655 self.enforce_sliding_window();
656
657 Ok(())
658 }
659
660 pub fn add_keyed_entry(
666 &mut self,
667 key: &str,
668 content: impl Into<EntryContent>,
669 tokens: usize,
670 ) -> crate::error::Result<()> {
671 self.push_entry(
672 content.into(),
673 tokens,
674 None,
675 EntryKind::default(),
676 crate::taint::TaintLevel::Public,
677 Some(key),
678 )
679 }
680
681 pub fn remove_at(&mut self, index: usize) -> bool {
687 if index >= self.content.len() {
688 return false;
689 }
690 let entry = self.content.remove(index);
691 self.current_tokens = self.current_tokens.saturating_sub(entry.tokens);
692 true
693 }
694
695 pub fn add_tainted_entry(
697 &mut self,
698 content: impl Into<EntryContent>,
699 tokens: usize,
700 taint_level: crate::taint::TaintLevel,
701 ) -> crate::error::Result<()> {
702 self.push_entry(
703 content.into(),
704 tokens,
705 None,
706 EntryKind::default(),
707 taint_level,
708 None,
709 )
710 }
711
712 pub fn add_typed_tainted_entry(
721 &mut self,
722 content: impl Into<EntryContent>,
723 tokens: usize,
724 kind: EntryKind,
725 taint_level: crate::taint::TaintLevel,
726 ) -> crate::error::Result<()> {
727 self.push_entry(content.into(), tokens, None, kind, taint_level, None)
728 }
729
730 pub fn with_schema(mut self, schema: RegionSchema) -> Self {
732 self.schema = Some(schema);
733 self
734 }
735
736 pub fn add_entry(
741 &mut self,
742 content: impl Into<EntryContent>,
743 tokens: usize,
744 ) -> crate::error::Result<()> {
745 self.push_entry(
746 content.into(),
747 tokens,
748 None,
749 EntryKind::default(),
750 crate::taint::TaintLevel::Public,
751 None,
752 )
753 }
754
755 pub fn add_entry_with_metadata(
757 &mut self,
758 content: impl Into<EntryContent>,
759 tokens: usize,
760 metadata: serde_json::Value,
761 ) -> crate::error::Result<()> {
762 self.push_entry(
763 content.into(),
764 tokens,
765 Some(metadata),
766 EntryKind::default(),
767 crate::taint::TaintLevel::Public,
768 None,
769 )
770 }
771
772 pub fn add_typed_entry(
778 &mut self,
779 content: impl Into<EntryContent>,
780 tokens: usize,
781 kind: EntryKind,
782 ) -> crate::error::Result<()> {
783 self.add_typed_entry_with_reasoning(content, tokens, kind, None)
784 }
785
786 pub fn add_typed_entry_with_reasoning(
793 &mut self,
794 content: impl Into<EntryContent>,
795 tokens: usize,
796 kind: EntryKind,
797 reasoning: Option<String>,
798 ) -> crate::error::Result<()> {
799 self.push_entry(
800 content.into(),
801 tokens,
802 None,
803 kind,
804 crate::taint::TaintLevel::Public,
805 None,
806 )?;
807 if reasoning.is_some()
810 && let Some(entry) = self.content.last_mut()
811 {
812 entry.reasoning = reasoning;
813 }
814 Ok(())
815 }
816
817 pub fn carry_entry(&mut self, entry: RegionEntry) -> crate::error::Result<()> {
831 if self.current_tokens + entry.tokens > self.max_tokens {
833 return Err(crate::error::Error::TokenBudgetExceeded {
834 used: self.current_tokens + entry.tokens,
835 max: self.max_tokens,
836 });
837 }
838
839 self.current_tokens += entry.tokens;
840 self.content.push(entry);
841
842 self.enforce_sliding_window();
844
845 Ok(())
846 }
847
848 pub fn upsert_by_key(
851 &mut self,
852 key: &str,
853 content: impl Into<EntryContent>,
854 tokens: usize,
855 ) -> Result<(), String> {
856 self.upsert_by_key_content(key, content.into(), tokens)
857 }
858
859 fn upsert_by_key_content(
863 &mut self,
864 key: &str,
865 content: EntryContent,
866 tokens: usize,
867 ) -> Result<(), String> {
868 if let Some(pos) = self
870 .content
871 .iter()
872 .position(|e| e.key.as_deref() == Some(key))
873 {
874 let old_tokens = self.content[pos].tokens;
875 self.current_tokens -= old_tokens;
876 self.content[pos].content = content;
877 self.content[pos].tokens = tokens;
878 self.content[pos].timestamp = chrono::Utc::now().timestamp();
879 self.current_tokens += tokens;
880 return Ok(());
881 }
882
883 let max_entries = if let RegionKind::HashMap {
885 max_entries: Some(max),
886 } = &self.kind
887 {
888 Some(*max)
889 } else {
890 None
891 };
892 if let Some(max) = max_entries {
893 while self.content.len() >= max {
894 self.evict_lru_entry();
895 }
896 }
897
898 while self.current_tokens + tokens > self.max_tokens && !self.content.is_empty() {
900 self.evict_lru_entry();
901 }
902
903 if self.current_tokens + tokens > self.max_tokens {
904 return Err(format!(
905 "Entry ({} tokens) exceeds region budget ({} max)",
906 tokens, self.max_tokens
907 ));
908 }
909
910 self.content.push(RegionEntry {
911 content,
912 tokens,
913 timestamp: chrono::Utc::now().timestamp(),
914 metadata: None,
915 kind: EntryKind::default(),
916 key: Some(key.to_string()),
917 reasoning: None,
918 });
919 self.current_tokens += tokens;
920 Ok(())
921 }
922
923 pub fn get_by_key(&self, key: &str) -> Option<&RegionEntry> {
925 self.content.iter().find(|e| e.key.as_deref() == Some(key))
926 }
927
928 pub fn remove_by_key(&mut self, key: &str) -> bool {
930 if let Some(pos) = self
931 .content
932 .iter()
933 .position(|e| e.key.as_deref() == Some(key))
934 {
935 let tokens = self.content[pos].tokens;
936 self.content.remove(pos);
937 self.current_tokens -= tokens;
938 if let Some(taint) = &mut self.taint {
939 taint.remove_at(pos);
940 }
941 true
942 } else {
943 false
944 }
945 }
946
947 pub fn keys(&self) -> Vec<&str> {
949 self.content
950 .iter()
951 .filter_map(|e| e.key.as_deref())
952 .collect()
953 }
954
955 pub fn clear(&mut self) {
957 self.content.clear();
958 self.current_tokens = 0;
959 if let Some(taint) = &mut self.taint {
960 taint.clear();
961 }
962 }
963
964 pub fn remove_entries_by_prefix(&mut self, prefix: &str) {
970 let mut i = 0;
971 while i < self.content.len() {
972 if self.content[i].content.starts_with(prefix) {
973 let tokens = self.content[i].tokens;
974 self.content.remove(i);
975 self.current_tokens -= tokens;
976 if let Some(taint) = &mut self.taint {
977 taint.remove_at(i);
978 }
979 } else {
980 i += 1;
981 }
982 }
983 }
984
985 pub fn entry_count(&self) -> usize {
987 self.content.len()
988 }
989
990 pub fn needs_compaction(&self) -> bool {
992 if let RegionKind::Compacting { threshold_tokens } = self.kind {
993 self.current_tokens > threshold_tokens
994 } else {
995 false
996 }
997 }
998}
999
1000#[derive(Debug, Clone, Serialize, Deserialize)]
1004pub struct RegionEntry {
1005 pub content: EntryContent,
1008
1009 pub tokens: usize,
1011
1012 pub timestamp: i64,
1014
1015 pub metadata: Option<serde_json::Value>,
1017
1018 #[serde(default)]
1022 pub kind: EntryKind,
1023
1024 #[serde(default, skip_serializing_if = "Option::is_none")]
1026 pub key: Option<String>,
1027
1028 #[serde(default, skip_serializing_if = "Option::is_none")]
1046 pub reasoning: Option<String>,
1047}
1048
1049#[cfg(test)]
1052mod tests {
1053
1054 #[test]
1064 fn an_opaque_reasoning_token_rides_along_with_the_entry_it_belongs_to() {
1065 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 100);
1066 region
1067 .add_typed_entry_with_reasoning(
1068 "the answer".to_string(),
1069 10,
1070 EntryKind::AssistantTurn { tool_calls: vec![] },
1071 Some("sealed-blob".to_string()),
1072 )
1073 .unwrap();
1074 assert_eq!(region.content[0].reasoning.as_deref(), Some("sealed-blob"));
1075 }
1076
1077 #[test]
1078 fn an_entry_written_without_one_carries_none() {
1079 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 100);
1080 region.add_entry("plain".to_string(), 10).unwrap();
1081 assert_eq!(region.content[0].reasoning, None);
1082 }
1083
1084 #[test]
1085 fn a_rejected_write_attaches_nothing() {
1086 let mut region = Region::new("conv".to_string(), RegionKind::Pinned, 10);
1089 region.add_entry("first".to_string(), 10).unwrap();
1090 let refused = region.add_typed_entry_with_reasoning(
1091 "second".to_string(),
1092 10,
1093 EntryKind::AssistantTurn { tool_calls: vec![] },
1094 Some("sealed-blob".to_string()),
1095 );
1096 assert!(refused.is_err(), "the region had no room");
1097 assert!(region.content.iter().all(|e| e.reasoning.is_none()));
1098 }
1099
1100 #[test]
1101 fn a_reasoning_token_survives_a_serde_round_trip() {
1102 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 100);
1105 region
1106 .add_typed_entry_with_reasoning(
1107 "x".to_string(),
1108 1,
1109 EntryKind::AssistantTurn { tool_calls: vec![] },
1110 Some("sealed-blob".to_string()),
1111 )
1112 .unwrap();
1113 let json = serde_json::to_string(®ion.content[0]).unwrap();
1114 let back: RegionEntry = serde_json::from_str(&json).unwrap();
1115 assert_eq!(back.reasoning.as_deref(), Some("sealed-blob"));
1116
1117 let older: RegionEntry =
1119 serde_json::from_str(r#"{"content":"x","tokens":1,"timestamp":0,"metadata":null}"#)
1120 .unwrap();
1121 assert_eq!(older.reasoning, None);
1122 }
1123
1124 #[test]
1125 fn an_evicting_region_rolls_the_oldest_off_to_admit_a_write() {
1126 let mut region = Region::new("findings".to_string(), RegionKind::Temporary, 100);
1127 region.add_entry("oldest".to_string(), 40).unwrap();
1128 region.add_entry("middle".to_string(), 40).unwrap();
1129 assert_eq!(region.current_tokens, 80);
1130
1131 region.add_entry("newest".to_string(), 40).unwrap();
1133
1134 assert_eq!(region.current_tokens, 80);
1135 let held: Vec<&str> = region.content.iter().map(|e| e.content.as_str()).collect();
1136 assert_eq!(held, ["middle", "newest"]);
1137 }
1138
1139 #[test]
1141 fn eviction_stops_as_soon_as_the_write_fits() {
1142 let mut region = Region::new("findings".to_string(), RegionKind::Temporary, 100);
1143 for i in 0..5 {
1144 region.add_entry(format!("entry-{i}"), 20).unwrap();
1145 }
1146 region.add_entry("newest".to_string(), 20).unwrap();
1147 let held: Vec<&str> = region.content.iter().map(|e| e.content.as_str()).collect();
1148 assert_eq!(held, ["entry-1", "entry-2", "entry-3", "entry-4", "newest"]);
1149 }
1150
1151 #[test]
1154 fn a_rejecting_region_still_refuses_rather_than_dropping_anything() {
1155 let mut region = Region::new("sources".to_string(), RegionKind::Temporary, 100);
1156 region.admission = Admission::Reject;
1157 region.add_entry("curated".to_string(), 80).unwrap();
1158
1159 let err = region.add_entry("newest".to_string(), 40).unwrap_err();
1160 assert_eq!(
1161 err.to_string(),
1162 "Region 'sources' is full (80/100 tokens) and does not evict automatically - release an entry before adding another"
1163 );
1164 assert_eq!(region.content.len(), 1);
1165 assert_eq!(region.current_tokens, 80);
1166 }
1167
1168 #[test]
1173 fn an_entry_larger_than_the_region_does_not_empty_it() {
1174 let mut region = Region::new("findings".to_string(), RegionKind::Temporary, 100);
1175 region.add_entry("kept".to_string(), 50).unwrap();
1176
1177 let err = region.add_entry("enormous".to_string(), 500).unwrap_err();
1178 assert_eq!(err.to_string(), "Content exceeds token budget: 550 > 100");
1179 assert_eq!(region.content.len(), 1, "the region was not emptied for it");
1180 }
1181
1182 #[test]
1186 fn eviction_never_strands_a_tool_result_without_its_call() {
1187 let mut region = Region::new(
1188 "conversation".to_string(),
1189 RegionKind::SlidingWindow {
1190 max_items: 100,
1191 eviction_strategy: EvictionStrategy::PerItem,
1192 },
1193 100,
1194 );
1195 region
1196 .add_typed_entry(
1197 "call it".to_string(),
1198 30,
1199 EntryKind::AssistantTurn {
1200 tool_calls: vec![crate::SerializedToolCall {
1201 id: "t1".to_string(),
1202 name: "read_file".to_string(),
1203 arguments: serde_json::json!({}),
1204 thought_signature: None,
1205 }],
1206 },
1207 )
1208 .unwrap();
1209 region
1210 .add_typed_entry(
1211 "the answer".to_string(),
1212 30,
1213 EntryKind::ToolResult {
1214 tool_call_id: "t1".to_string(),
1215 tool_name: "read_file".to_string(),
1216 is_error: false,
1217 },
1218 )
1219 .unwrap();
1220
1221 region.add_entry("next turn".to_string(), 60).unwrap();
1223
1224 assert!(
1225 !region
1226 .content
1227 .iter()
1228 .any(|e| matches!(e.kind, EntryKind::ToolResult { .. })),
1229 "the result outlived the call that produced it"
1230 );
1231 }
1232
1233 #[test]
1237 fn kinds_that_own_their_retention_do_not_roll_off() {
1238 assert!(RegionKind::Temporary.rolls_off_oldest());
1239 assert!(RegionKind::Clearable.rolls_off_oldest());
1240 assert!(!RegionKind::Pinned.rolls_off_oldest());
1241 assert!(
1242 !RegionKind::Custom {
1243 script: "r.rhai".to_string(),
1244 pinned: false,
1245 }
1246 .rolls_off_oldest()
1247 );
1248 assert!(!RegionKind::HashMap { max_entries: None }.rolls_off_oldest());
1249
1250 let mut pinned = Region::new("query".to_string(), RegionKind::Pinned, 100);
1252 pinned.add_entry("the task".to_string(), 80).unwrap();
1253 assert!(pinned.add_entry("more".to_string(), 40).is_err());
1254 assert_eq!(pinned.content.len(), 1, "a pinned region kept its content");
1255 }
1256
1257 use super::*;
1258
1259 fn checklist() -> Region {
1262 Region::new("todos".to_string(), RegionKind::Checklist, 10_000)
1263 }
1264
1265 #[test]
1271 fn a_malformed_entry_is_not_an_item() {
1272 let mut r = checklist();
1273 r.add_entry("a plain note".to_string(), 3).unwrap();
1275 r.add_entry_with_metadata(
1277 "something else".to_string(),
1278 3,
1279 serde_json::json!({ "unrelated": true }),
1280 )
1281 .unwrap();
1282 r.add_entry_with_metadata(
1284 "bad id".to_string(),
1285 3,
1286 serde_json::json!({ "checklist_id": "one" }),
1287 )
1288 .unwrap();
1289
1290 assert!(r.checklist_items().is_empty(), "none of those are items");
1291 assert!(r.open_checklist_items().is_empty());
1292 assert!(
1293 r.render_checklist().is_empty(),
1294 "and they do not render as a checklist"
1295 );
1296 }
1297
1298 #[test]
1301 fn a_checklist_caches_until_it_changes() {
1302 assert_eq!(
1303 RegionKind::Checklist.cache_hint(),
1304 crate::cache::CacheHint::UntilChanged
1305 );
1306 }
1307
1308 #[test]
1309 fn a_note_appears_in_the_render() {
1310 let mut r = checklist();
1311 let id = r.add_checklist_item("blocked".to_string(), 2).unwrap();
1312 r.note_checklist_item(id, "waiting on the manual");
1313 let rendered = r.render_checklist();
1314 assert!(
1315 rendered.contains("note: waiting on the manual"),
1316 "{rendered}"
1317 );
1318 }
1319
1320 #[test]
1323 fn an_item_over_budget_is_refused() {
1324 let mut r = Region::new("todos".to_string(), RegionKind::Checklist, 4);
1325 assert!(r.add_checklist_item("x".to_string(), 99).is_err());
1326 assert!(r.checklist_items().is_empty());
1327 }
1328
1329 #[test]
1330 fn an_added_item_starts_open_and_gets_an_id() {
1331 let mut r = checklist();
1332 let first = r
1333 .add_checklist_item("compute the fee table".to_string(), 5)
1334 .unwrap();
1335 let second = r
1336 .add_checklist_item("check the manual".to_string(), 5)
1337 .unwrap();
1338 assert_eq!((first, second), (1, 2), "ids are stable and sequential");
1339 assert_eq!(r.open_checklist_items().len(), 2);
1340 }
1341
1342 #[test]
1343 fn completing_an_item_closes_it_and_nothing_else() {
1344 let mut r = checklist();
1345 let id = r.add_checklist_item("one".to_string(), 2).unwrap();
1346 r.add_checklist_item("two".to_string(), 2).unwrap();
1347
1348 assert!(r.complete_checklist_item(id));
1349 let open = r.open_checklist_items();
1350 assert_eq!(open.len(), 1);
1351 assert_eq!(open[0].text, "two");
1352 assert_eq!(
1353 r.checklist_items().len(),
1354 2,
1355 "done items are kept, not deleted"
1356 );
1357 }
1358
1359 #[test]
1360 fn an_unknown_id_reports_failure_rather_than_ticking_something_else() {
1361 let mut r = checklist();
1364 r.add_checklist_item("one".to_string(), 2).unwrap();
1365 assert!(!r.complete_checklist_item(99));
1366 assert!(!r.note_checklist_item(99, "x"));
1367 assert_eq!(r.open_checklist_items().len(), 1);
1368 }
1369
1370 #[test]
1371 fn a_note_records_without_closing() {
1372 let mut r = checklist();
1373 let id = r
1374 .add_checklist_item("blocked thing".to_string(), 2)
1375 .unwrap();
1376 assert!(r.note_checklist_item(id, "waiting on the manual"));
1377 let item = &r.checklist_items()[0];
1378 assert!(!item.done, "a note is not a completion");
1379 assert_eq!(item.note.as_deref(), Some("waiting on the manual"));
1380 }
1381
1382 #[test]
1385 fn the_render_puts_open_items_first() {
1386 let mut r = checklist();
1387 let done = r
1388 .add_checklist_item("already finished".to_string(), 2)
1389 .unwrap();
1390 r.add_checklist_item("still to do".to_string(), 2).unwrap();
1391 r.complete_checklist_item(done);
1392
1393 let rendered = r.render_checklist();
1394 let open_at = rendered.find("still to do").expect("open item rendered");
1395 let done_at = rendered
1396 .find("already finished")
1397 .expect("done item rendered");
1398 assert!(open_at < done_at, "open before done:\n{rendered}");
1399 assert!(rendered.contains("1 open, 1 done"), "{rendered}");
1400 assert!(
1401 rendered.contains("[x]") && rendered.contains("[ ]"),
1402 "{rendered}"
1403 );
1404 }
1405
1406 #[test]
1407 fn an_empty_checklist_renders_nothing() {
1408 assert!(checklist().render_checklist().is_empty());
1410 }
1411
1412 #[test]
1415 fn ids_do_not_get_reused_after_a_drop() {
1416 let mut r = checklist();
1417 r.add_checklist_item("one".to_string(), 2).unwrap();
1418 let second = r.add_checklist_item("two".to_string(), 2).unwrap();
1419 r.content.remove(0);
1420 let third = r.add_checklist_item("three".to_string(), 2).unwrap();
1421 assert!(third > second, "a reused id would tick off the wrong item");
1422 }
1423
1424 #[test]
1425 fn test_region_creation() {
1426 let region = Region::new("test".to_string(), RegionKind::Pinned, 1000);
1427 assert_eq!(region.name, "test");
1428 assert_eq!(region.max_tokens, 1000);
1429 assert_eq!(region.current_tokens, 0);
1430 }
1431
1432 #[test]
1433 fn test_sliding_window_config() {
1434 let kind = RegionKind::SlidingWindow {
1435 max_items: 10,
1436 eviction_strategy: EvictionStrategy::PerItem,
1437 };
1438 let region = Region::new("history".to_string(), kind.clone(), 5000);
1439 assert_eq!(region.kind, kind);
1440 }
1441
1442 #[test]
1443 fn test_region_kind_equality() {
1444 assert_eq!(RegionKind::Clearable, RegionKind::Clearable);
1445 assert_eq!(
1446 RegionKind::Compacting {
1447 threshold_tokens: 500
1448 },
1449 RegionKind::Compacting {
1450 threshold_tokens: 500
1451 }
1452 );
1453 assert_eq!(
1454 RegionKind::CompactHistory {
1455 source_region: "conv".to_string()
1456 },
1457 RegionKind::CompactHistory {
1458 source_region: "conv".to_string()
1459 }
1460 );
1461 assert_ne!(RegionKind::Pinned, RegionKind::Temporary);
1462 }
1463
1464 #[test]
1465 fn custom_kind_equality_compares_script_and_persistent() {
1466 let a = RegionKind::Custom {
1467 script: "conv.rhai".to_string(),
1468 pinned: false,
1469 };
1470 assert_eq!(a, a.clone());
1471 assert_ne!(
1472 a,
1473 RegionKind::Custom {
1474 script: "other.rhai".to_string(),
1475 pinned: false,
1476 }
1477 );
1478 assert_ne!(
1479 a,
1480 RegionKind::Custom {
1481 script: "conv.rhai".to_string(),
1482 pinned: true,
1483 }
1484 );
1485 assert_ne!(a, RegionKind::Temporary);
1486 }
1487
1488 #[test]
1489 fn custom_kind_serde_round_trips() {
1490 let kind = RegionKind::Custom {
1491 script: "hooks/conv.rhai".to_string(),
1492 pinned: true,
1493 };
1494 let json = serde_json::to_string(&kind).unwrap();
1495 let back: RegionKind = serde_json::from_str(&json).unwrap();
1496 assert_eq!(kind, back);
1497 let old: RegionKind = serde_json::from_str("\"Pinned\"").unwrap();
1499 assert_eq!(old, RegionKind::Pinned);
1500 }
1501
1502 #[test]
1503 fn custom_kind_cache_hint_follows_persistent() {
1504 assert_eq!(
1505 RegionKind::Custom {
1506 script: "s.rhai".to_string(),
1507 pinned: true,
1508 }
1509 .cache_hint(),
1510 crate::cache::CacheHint::Always
1511 );
1512 assert_eq!(
1513 RegionKind::Custom {
1514 script: "s.rhai".to_string(),
1515 pinned: false,
1516 }
1517 .cache_hint(),
1518 crate::cache::CacheHint::UntilChanged
1519 );
1520 }
1521
1522 #[test]
1523 fn carry_entry_preserves_kind_metadata_key_and_timestamp() {
1524 let mut source = Region::new("conversation".to_string(), RegionKind::Temporary, 10_000);
1525 source
1526 .add_typed_entry(
1527 "result body".to_string(),
1528 10,
1529 EntryKind::ToolResult {
1530 tool_call_id: "call_1".to_string(),
1531 tool_name: "read_file".to_string(),
1532 is_error: false,
1533 },
1534 )
1535 .unwrap();
1536 let mut entry = source.content[0].clone();
1537 entry.metadata = Some(serde_json::json!({"origin": "test"}));
1538 entry.key = Some("k".to_string());
1539 let stamped = entry.timestamp;
1540
1541 let mut dest = Region::new("conversation".to_string(), RegionKind::Temporary, 10_000);
1542 dest.carry_entry(entry).unwrap();
1543
1544 let carried = &dest.content[0];
1545 assert!(matches!(
1546 &carried.kind,
1547 EntryKind::ToolResult { tool_call_id, .. } if tool_call_id == "call_1"
1548 ));
1549 assert_eq!(
1550 carried.metadata,
1551 Some(serde_json::json!({"origin": "test"}))
1552 );
1553 assert_eq!(carried.key.as_deref(), Some("k"));
1554 assert_eq!(carried.timestamp, stamped);
1555 assert_eq!(dest.current_tokens, 10);
1556 }
1557
1558 #[test]
1559 fn carry_entry_rejects_over_budget() {
1560 let mut dest = Region::new("small".to_string(), RegionKind::Temporary, 5);
1561 let mut source = Region::new("src".to_string(), RegionKind::Temporary, 100);
1562 source.add_entry("filler".to_string(), 10).unwrap();
1563 let err = dest.carry_entry(source.content[0].clone()).unwrap_err();
1564 assert_eq!(err.to_string(), "Content exceeds token budget: 10 > 5");
1565 assert!(dest.content.is_empty());
1566 assert_eq!(dest.current_tokens, 0);
1567 }
1568
1569 #[test]
1570 fn carry_entry_enforces_sliding_window_max_items() {
1571 let mut source = Region::new("src".to_string(), RegionKind::Temporary, 10_000);
1572 for i in 0..4 {
1573 source.add_entry(format!("msg{i}"), 10).unwrap();
1574 }
1575 let mut dest = Region::new(
1576 "conv".to_string(),
1577 RegionKind::SlidingWindow {
1578 max_items: 3,
1579 eviction_strategy: EvictionStrategy::PerItem,
1580 },
1581 10_000,
1582 );
1583 for entry in &source.content {
1584 dest.carry_entry(entry.clone()).unwrap();
1585 }
1586 assert_eq!(dest.content.len(), 3);
1587 assert_eq!(dest.content[0].content, "msg1");
1588 }
1589
1590 #[test]
1591 fn test_sliding_window_enforces_max_items() {
1592 let mut region = Region::new(
1593 "conv".to_string(),
1594 RegionKind::SlidingWindow {
1595 max_items: 3,
1596 eviction_strategy: EvictionStrategy::PerItem,
1597 },
1598 50000,
1599 );
1600
1601 region.add_entry("msg1".to_string(), 10).unwrap();
1602 region.add_entry("msg2".to_string(), 20).unwrap();
1603 region.add_entry("msg3".to_string(), 30).unwrap();
1604 assert_eq!(region.entry_count(), 3);
1605 assert_eq!(region.current_tokens, 60);
1606
1607 region.add_entry("msg4".to_string(), 40).unwrap();
1609 assert_eq!(region.entry_count(), 3);
1610 assert_eq!(region.content[0].content, "msg2");
1611 assert_eq!(region.content[2].content, "msg4");
1612 assert_eq!(region.current_tokens, 90); region.add_entry("msg5".to_string(), 50).unwrap();
1616 assert_eq!(region.entry_count(), 3);
1617 assert_eq!(region.content[0].content, "msg3");
1618 assert_eq!(region.current_tokens, 120); }
1620
1621 #[test]
1622 fn test_sliding_window_enforces_max_items_with_metadata() {
1623 let mut region = Region::new(
1624 "conv".to_string(),
1625 RegionKind::SlidingWindow {
1626 max_items: 2,
1627 eviction_strategy: EvictionStrategy::PerItem,
1628 },
1629 50000,
1630 );
1631
1632 region
1633 .add_entry_with_metadata("a".to_string(), 10, serde_json::json!({"idx": 1}))
1634 .unwrap();
1635 region
1636 .add_entry_with_metadata("b".to_string(), 20, serde_json::json!({"idx": 2}))
1637 .unwrap();
1638 region
1639 .add_entry_with_metadata("c".to_string(), 30, serde_json::json!({"idx": 3}))
1640 .unwrap();
1641
1642 assert_eq!(region.entry_count(), 2);
1643 assert_eq!(region.content[0].content, "b");
1644 assert_eq!(region.content[1].content, "c");
1645 assert_eq!(region.current_tokens, 50);
1646 }
1647
1648 #[test]
1649 fn test_cache_hint_pinned() {
1650 let kind = RegionKind::Pinned;
1651 assert_eq!(kind.cache_hint(), crate::cache::CacheHint::Always);
1652 }
1653
1654 #[test]
1655 fn test_cache_hint_compact_history() {
1656 let kind = RegionKind::CompactHistory {
1657 source_region: "conv".to_string(),
1658 };
1659 assert_eq!(kind.cache_hint(), crate::cache::CacheHint::Always);
1660 }
1661
1662 #[test]
1663 fn test_cache_hint_compacting() {
1664 let kind = RegionKind::Compacting {
1665 threshold_tokens: 1000,
1666 };
1667 assert_eq!(kind.cache_hint(), crate::cache::CacheHint::UntilChanged);
1668 }
1669
1670 #[test]
1671 fn test_cache_hint_sliding_window() {
1672 let kind = RegionKind::SlidingWindow {
1673 max_items: 10,
1674 eviction_strategy: EvictionStrategy::PerItem,
1675 };
1676 assert_eq!(
1677 kind.cache_hint(),
1678 crate::cache::CacheHint::SlidingPrefix {
1679 stable_fraction: 0.75
1680 }
1681 );
1682 }
1683
1684 #[test]
1685 fn test_cache_hint_temporary() {
1686 assert_eq!(
1687 RegionKind::Temporary.cache_hint(),
1688 crate::cache::CacheHint::Never
1689 );
1690 }
1691
1692 #[test]
1693 fn test_cache_hint_clearable() {
1694 assert_eq!(
1695 RegionKind::Clearable.cache_hint(),
1696 crate::cache::CacheHint::Never
1697 );
1698 }
1699
1700 #[test]
1703 fn test_with_schema_attaches_schema() {
1704 let schema = RegionSchema::new(ContentFormat::Json);
1705 let region =
1706 Region::new("data".to_string(), RegionKind::Temporary, 1000).with_schema(schema);
1707 assert!(region.schema.is_some());
1708 }
1709
1710 #[test]
1711 fn test_add_entry_rejects_content_failing_schema() {
1712 let schema = RegionSchema::new(ContentFormat::Json);
1713 let mut region =
1714 Region::new("data".to_string(), RegionKind::Temporary, 1000).with_schema(schema);
1715 let result = region.add_entry("not json".to_string(), 10);
1716 assert!(result.is_err());
1717 assert_eq!(region.entry_count(), 0);
1718 }
1719
1720 #[test]
1721 fn test_add_entry_accepts_content_passing_schema() {
1722 let schema = RegionSchema::new(ContentFormat::Json);
1723 let mut region =
1724 Region::new("data".to_string(), RegionKind::Temporary, 1000).with_schema(schema);
1725 let result = region.add_entry("{\"a\":1}".to_string(), 10);
1726 assert!(result.is_ok());
1727 assert_eq!(region.entry_count(), 1);
1728 }
1729
1730 #[test]
1731 fn accepts_content_allows_a_caption_beside_media_but_gates_the_payload() {
1732 use crate::mime::{MimeType, Part};
1733 let mut region = Region::new("art".to_string(), RegionKind::Pinned, 1000);
1734 region.accepts = vec!["image/*".to_string()];
1735 let png = || Part::inline(MimeType::parse("image/png").unwrap(), "x");
1736 let wav = || Part::inline(MimeType::parse("audio/wav").unwrap(), "x");
1737
1738 let ok = EntryContent::from_parts(vec![Part::text("a caption"), png()]);
1741 assert!(region.accepts_content(&ok).is_ok());
1742
1743 let bad = EntryContent::from_parts(vec![Part::text("a caption"), wav()]);
1746 assert_eq!(
1747 region.accepts_content(&bad).unwrap_err(),
1748 MimeType::parse("audio/wav").unwrap()
1749 );
1750
1751 let open = Region::new("open".to_string(), RegionKind::Pinned, 1000);
1753 assert!(open.accepts_content(&bad).is_ok());
1754 }
1755
1756 #[test]
1757 fn test_add_entry_rejects_over_budget() {
1758 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 10);
1759 let result = region.add_entry("too much".to_string(), 20);
1760 assert_eq!(
1761 result.unwrap_err().to_string(),
1762 "Content exceeds token budget: 20 > 10"
1763 );
1764 assert_eq!(region.entry_count(), 0);
1765 }
1766
1767 #[test]
1768 fn test_add_entry_with_metadata_rejects_content_failing_schema() {
1769 let schema = RegionSchema::new(ContentFormat::Json);
1770 let mut region =
1771 Region::new("data".to_string(), RegionKind::Temporary, 1000).with_schema(schema);
1772 let result =
1773 region.add_entry_with_metadata("not json".to_string(), 10, serde_json::json!({}));
1774 assert!(result.is_err());
1775 }
1776
1777 #[test]
1778 fn test_add_entry_with_metadata_rejects_over_budget() {
1779 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 10);
1780 let result =
1781 region.add_entry_with_metadata("too much".to_string(), 20, serde_json::json!({}));
1782 assert_eq!(
1783 result.unwrap_err().to_string(),
1784 "Content exceeds token budget: 20 > 10"
1785 );
1786 }
1787
1788 #[test]
1789 fn test_add_entry_with_metadata_stores_metadata() {
1790 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1791 region
1792 .add_entry_with_metadata("hello".to_string(), 5, serde_json::json!({"k": "v"}))
1793 .unwrap();
1794 assert_eq!(
1795 region.content[0].metadata,
1796 Some(serde_json::json!({"k": "v"}))
1797 );
1798 }
1799
1800 #[test]
1803 fn test_clear_removes_all_content_and_resets_tokens() {
1804 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1805 region.add_entry("a".to_string(), 10).unwrap();
1806 region.add_entry("b".to_string(), 20).unwrap();
1807 assert_eq!(region.entry_count(), 2);
1808
1809 region.clear();
1810 assert_eq!(region.entry_count(), 0);
1811 assert_eq!(region.current_tokens, 0);
1812 }
1813
1814 #[test]
1815 fn test_remove_oldest_returns_and_removes_first_entry() {
1816 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1817 region.add_entry("first".to_string(), 10).unwrap();
1818 region.add_entry("second".to_string(), 20).unwrap();
1819
1820 let removed = region.remove_oldest().unwrap();
1821 assert_eq!(removed.content, "first");
1822 assert_eq!(region.entry_count(), 1);
1823 assert_eq!(region.current_tokens, 20);
1824 }
1825
1826 #[test]
1827 fn test_remove_oldest_returns_none_when_empty() {
1828 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1829 assert!(region.remove_oldest().is_none());
1830 }
1831
1832 #[test]
1833 fn test_needs_compaction_true_when_over_threshold() {
1834 let mut region = Region::new(
1835 "impl".to_string(),
1836 RegionKind::Compacting {
1837 threshold_tokens: 10,
1838 },
1839 1000,
1840 );
1841 region.add_entry("x".to_string(), 20).unwrap();
1842 assert!(region.needs_compaction());
1843 }
1844
1845 #[test]
1846 fn test_needs_compaction_false_when_under_threshold() {
1847 let mut region = Region::new(
1848 "impl".to_string(),
1849 RegionKind::Compacting {
1850 threshold_tokens: 100,
1851 },
1852 1000,
1853 );
1854 region.add_entry("x".to_string(), 20).unwrap();
1855 assert!(!region.needs_compaction());
1856 }
1857
1858 #[test]
1859 fn test_needs_compaction_false_for_non_compacting_kind() {
1860 let region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1861 assert!(!region.needs_compaction());
1862 }
1863
1864 #[test]
1867 fn test_region_schema_with_custom_script() {
1868 let schema = RegionSchema::new(ContentFormat::Custom {
1869 format_name: "special".to_string(),
1870 })
1871 .with_custom_script("validate_special()".to_string());
1872 assert_eq!(schema.custom_script.as_deref(), Some("validate_special()"));
1873 }
1874
1875 #[test]
1878 fn test_validate_json_valid() {
1879 let schema = RegionSchema::new(ContentFormat::Json);
1880 assert!(schema.validate("{\"a\": 1}").is_ok());
1881 }
1882
1883 #[test]
1884 fn test_validate_json_invalid() {
1885 let schema = RegionSchema::new(ContentFormat::Json);
1886 let err = schema.validate("not json").unwrap_err();
1887 assert!(err.to_string().starts_with("Region validation failed:"));
1888 }
1889
1890 #[test]
1891 fn test_validate_mermaid_valid() {
1892 let schema = RegionSchema::new(ContentFormat::Mermaid);
1893 assert!(schema.validate("graph TD\nA-->B").is_ok());
1894 }
1895
1896 #[test]
1897 fn test_validate_mermaid_all_recognized_diagram_types() {
1898 let schema = RegionSchema::new(ContentFormat::Mermaid);
1899 for kind in [
1900 "graph",
1901 "sequenceDiagram",
1902 "classDiagram",
1903 "stateDiagram",
1904 "erDiagram",
1905 "journey",
1906 "gantt",
1907 "pie",
1908 "flowchart",
1909 ] {
1910 assert!(schema.validate(&format!("{} content", kind)).is_ok());
1911 }
1912 }
1913
1914 #[test]
1915 fn test_validate_mermaid_invalid() {
1916 let schema = RegionSchema::new(ContentFormat::Mermaid);
1917 let err = schema.validate("just some text").unwrap_err();
1918 assert!(err.to_string().starts_with("Region validation failed:"));
1919 }
1920
1921 #[test]
1922 fn test_validate_code_non_empty_is_ok() {
1923 let schema = RegionSchema::new(ContentFormat::Code {
1924 language: "rust".to_string(),
1925 });
1926 assert!(schema.validate("fn main() {}").is_ok());
1927 }
1928
1929 #[test]
1930 fn test_validate_code_empty_is_error() {
1931 let schema = RegionSchema::new(ContentFormat::Code {
1932 language: "rust".to_string(),
1933 });
1934 let err = schema.validate(" ").unwrap_err();
1935 assert!(err.to_string().starts_with("Region validation failed:"));
1936 }
1937
1938 #[test]
1939 fn test_validate_markdown_non_empty_is_ok() {
1940 let schema = RegionSchema::new(ContentFormat::Markdown);
1941 assert!(schema.validate("# Heading").is_ok());
1942 }
1943
1944 #[test]
1945 fn test_validate_markdown_empty_is_error() {
1946 let schema = RegionSchema::new(ContentFormat::Markdown);
1947 let err = schema.validate("").unwrap_err();
1948 assert!(err.to_string().starts_with("Region validation failed:"));
1949 }
1950
1951 #[test]
1952 fn test_validate_text_has_no_restrictions() {
1953 let schema = RegionSchema::new(ContentFormat::Text);
1954 assert!(schema.validate("").is_ok());
1955 assert!(schema.validate("anything at all").is_ok());
1956 }
1957
1958 #[test]
1959 fn test_validate_custom_has_no_restrictions_here() {
1960 let schema = RegionSchema::new(ContentFormat::Custom {
1961 format_name: "special".to_string(),
1962 });
1963 assert!(schema.validate("").is_ok());
1966 assert!(schema.validate("whatever").is_ok());
1967 }
1968
1969 #[test]
1972 fn test_region_schema_clone_preserves_fields() {
1973 let schema = RegionSchema::new(ContentFormat::Text).with_custom_script("s".to_string());
1974 let cloned = schema.clone();
1975 assert_eq!(cloned.custom_script.as_deref(), Some("s"));
1976 assert_eq!(cloned.format, ContentFormat::Text);
1977 }
1978
1979 #[test]
1982 fn test_region_with_taint_tracking() {
1983 let region =
1984 Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
1985 assert!(region.taint.is_some());
1986 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
1987 }
1988
1989 #[test]
1990 fn test_region_without_taint_tracking() {
1991 let region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
1992 assert!(region.taint.is_none());
1993 assert_eq!(region.taint_level(), None);
1994 }
1995
1996 #[test]
1997 fn test_enable_taint_tracking() {
1998 let mut region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
1999 assert!(region.taint.is_none());
2000 region.enable_taint_tracking();
2001 assert!(region.taint.is_some());
2002 region.enable_taint_tracking();
2004 assert!(region.taint.is_some());
2005 }
2006
2007 #[test]
2008 fn test_add_tainted_entry() {
2009 let mut region =
2010 Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
2011 region
2012 .add_tainted_entry(
2013 "secret data".to_string(),
2014 10,
2015 crate::taint::TaintLevel::Private,
2016 )
2017 .unwrap();
2018 assert_eq!(
2019 region.taint_level(),
2020 Some(crate::taint::TaintLevel::Private)
2021 );
2022 assert_eq!(region.entry_count(), 1);
2023 }
2024
2025 #[test]
2026 fn test_add_tainted_entry_validates_schema() {
2027 let mut region = Region::new("test".to_string(), RegionKind::Temporary, 1000)
2028 .with_taint_tracking()
2029 .with_schema(RegionSchema::new(ContentFormat::Json));
2030 let result = region.add_tainted_entry(
2031 "not json".to_string(),
2032 10,
2033 crate::taint::TaintLevel::Internal,
2034 );
2035 assert!(result.is_err());
2036 assert_eq!(region.entry_count(), 0);
2037 }
2038
2039 #[test]
2040 fn test_add_tainted_entry_checks_budget() {
2041 let mut region =
2042 Region::new("test".to_string(), RegionKind::Temporary, 10).with_taint_tracking();
2043 let result = region.add_tainted_entry(
2044 "too much".to_string(),
2045 20,
2046 crate::taint::TaintLevel::Internal,
2047 );
2048 assert!(result.is_err());
2049 }
2050
2051 #[test]
2052 fn test_add_entry_tracks_taint_as_public() {
2053 let mut region =
2054 Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
2055 region.add_entry("public data".to_string(), 10).unwrap();
2056 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2057 }
2058
2059 #[test]
2060 fn test_taint_recovery_on_remove_oldest() {
2061 let mut region =
2062 Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
2063 region
2064 .add_tainted_entry("private".to_string(), 10, crate::taint::TaintLevel::Private)
2065 .unwrap();
2066 region
2067 .add_tainted_entry("public".to_string(), 10, crate::taint::TaintLevel::Public)
2068 .unwrap();
2069 assert_eq!(
2070 region.taint_level(),
2071 Some(crate::taint::TaintLevel::Private)
2072 );
2073
2074 region.remove_oldest(); assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2076 }
2077
2078 #[test]
2079 fn test_taint_recovery_on_clear() {
2080 let mut region =
2081 Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
2082 region
2083 .add_tainted_entry("private".to_string(), 10, crate::taint::TaintLevel::Private)
2084 .unwrap();
2085 region.clear();
2086 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2087 }
2088
2089 #[test]
2090 fn test_taint_recovery_on_sliding_window_eviction() {
2091 let mut region = Region::new(
2092 "conv".to_string(),
2093 RegionKind::SlidingWindow {
2094 max_items: 2,
2095 eviction_strategy: EvictionStrategy::PerItem,
2096 },
2097 50000,
2098 )
2099 .with_taint_tracking();
2100
2101 region
2102 .add_tainted_entry("private".to_string(), 10, crate::taint::TaintLevel::Private)
2103 .unwrap();
2104 region
2105 .add_tainted_entry("public1".to_string(), 10, crate::taint::TaintLevel::Public)
2106 .unwrap();
2107 assert_eq!(
2108 region.taint_level(),
2109 Some(crate::taint::TaintLevel::Private)
2110 );
2111
2112 region
2114 .add_tainted_entry("public2".to_string(), 10, crate::taint::TaintLevel::Public)
2115 .unwrap();
2116 assert_eq!(region.entry_count(), 2);
2117 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2118 }
2119
2120 #[test]
2121 fn test_taint_field_not_serialized_when_none() {
2122 let region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
2123 let json = serde_json::to_string(®ion).unwrap();
2124 assert!(!json.contains("taint"));
2125 }
2126
2127 #[test]
2128 fn test_taint_field_deserialized_as_none_when_missing() {
2129 let json = r#"{"name":"test","kind":"Temporary","content":[],"max_tokens":1000,"current_tokens":0,"schema":null}"#;
2130 let region: Region = serde_json::from_str(json).unwrap();
2131 assert!(region.taint.is_none());
2132 }
2133
2134 #[test]
2135 fn test_add_typed_tainted_entry() {
2136 let mut region = Region::new(
2137 "conversation".to_string(),
2138 RegionKind::SlidingWindow {
2139 max_items: 100,
2140 eviction_strategy: EvictionStrategy::PerItem,
2141 },
2142 1000,
2143 )
2144 .with_taint_tracking();
2145
2146 region
2147 .add_typed_tainted_entry(
2148 "secret data".to_string(),
2149 10,
2150 EntryKind::ToolResult {
2151 tool_call_id: "tc_1".to_string(),
2152 tool_name: "calendar".to_string(),
2153 is_error: false,
2154 },
2155 crate::taint::TaintLevel::Private,
2156 )
2157 .unwrap();
2158
2159 assert_eq!(region.content.len(), 1);
2160 assert_eq!(
2161 region.content[0].kind,
2162 EntryKind::ToolResult {
2163 tool_call_id: "tc_1".to_string(),
2164 tool_name: "calendar".to_string(),
2165 is_error: false,
2166 }
2167 );
2168 assert_eq!(
2169 region.taint_level(),
2170 Some(crate::taint::TaintLevel::Private)
2171 );
2172 }
2173
2174 #[test]
2178 fn serialized_tool_call_round_trips_thought_signature_and_reads_old_json() {
2179 let with = SerializedToolCall {
2180 id: "c1".into(),
2181 name: "shell".into(),
2182 arguments: serde_json::json!({"command": "ls"}),
2183 thought_signature: Some("sig".into()),
2184 };
2185 let json = serde_json::to_string(&with).unwrap();
2186 let back: SerializedToolCall = serde_json::from_str(&json).unwrap();
2187 assert_eq!(back.thought_signature.as_deref(), Some("sig"));
2188
2189 let old = r#"{"id":"c2","name":"shell","arguments":{}}"#;
2191 let back: SerializedToolCall = serde_json::from_str(old).unwrap();
2192 assert_eq!(back.thought_signature, None);
2193
2194 let without = SerializedToolCall {
2197 id: "c3".into(),
2198 name: "shell".into(),
2199 arguments: serde_json::json!({}),
2200 thought_signature: None,
2201 };
2202 assert!(
2203 !serde_json::to_string(&without)
2204 .unwrap()
2205 .contains("thought_signature")
2206 );
2207 }
2208
2209 #[test]
2210 fn test_add_typed_tainted_entry_checks_budget() {
2211 let mut region = Region::new(
2212 "conversation".to_string(),
2213 RegionKind::SlidingWindow {
2214 max_items: 100,
2215 eviction_strategy: EvictionStrategy::PerItem,
2216 },
2217 5,
2218 )
2219 .with_taint_tracking();
2220
2221 let result = region.add_typed_tainted_entry(
2222 "too large".to_string(),
2223 100,
2224 EntryKind::ToolResult {
2225 tool_call_id: "tc_1".to_string(),
2226 tool_name: "tool".to_string(),
2227 is_error: false,
2228 },
2229 crate::taint::TaintLevel::Internal,
2230 );
2231 assert!(result.is_err());
2232 }
2233
2234 #[test]
2235 fn test_add_typed_tainted_entry_validates_schema() {
2236 let mut region = Region::new("test".to_string(), RegionKind::Pinned, 1000)
2237 .with_taint_tracking()
2238 .with_schema(RegionSchema::new(ContentFormat::Json));
2239
2240 let result = region.add_typed_tainted_entry(
2242 "not json".to_string(),
2243 5,
2244 EntryKind::Text,
2245 crate::taint::TaintLevel::Public,
2246 );
2247 assert!(result.is_err());
2248 }
2249
2250 #[test]
2251 fn test_add_typed_tainted_entry_without_taint_tracking() {
2252 let mut region = Region::new(
2255 "conversation".to_string(),
2256 RegionKind::SlidingWindow {
2257 max_items: 100,
2258 eviction_strategy: EvictionStrategy::PerItem,
2259 },
2260 1000,
2261 );
2262 region
2265 .add_typed_tainted_entry(
2266 "data".to_string(),
2267 10,
2268 EntryKind::Text,
2269 crate::taint::TaintLevel::Private,
2270 )
2271 .unwrap();
2272
2273 assert_eq!(region.content.len(), 1);
2274 assert_eq!(region.taint_level(), None); }
2276
2277 #[test]
2280 fn test_turn_group_size_at_assistant_with_tool_results() {
2281 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
2282 region
2283 .add_typed_entry(
2284 "assistant response".to_string(),
2285 10,
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: "write_file".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 5,
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 5,
2319 EntryKind::ToolResult {
2320 tool_call_id: "tc_2".to_string(),
2321 tool_name: "write_file".to_string(),
2322 is_error: false,
2323 },
2324 )
2325 .unwrap();
2326
2327 assert_eq!(region.turn_group_size_at(0), 3);
2328 }
2329
2330 #[test]
2331 fn test_turn_group_size_at_assistant_at_end() {
2332 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
2333 region
2334 .add_typed_entry(
2335 "assistant with no tools".to_string(),
2336 10,
2337 EntryKind::AssistantTurn { tool_calls: vec![] },
2338 )
2339 .unwrap();
2340
2341 assert_eq!(region.turn_group_size_at(0), 1);
2342 }
2343
2344 #[test]
2345 fn test_turn_group_size_at_out_of_bounds() {
2346 let region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
2347 assert_eq!(region.turn_group_size_at(0), 0);
2348 assert_eq!(region.turn_group_size_at(99), 0);
2349 }
2350
2351 #[test]
2352 fn test_turn_group_size_at_non_assistant_entries() {
2353 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
2354 region
2355 .add_typed_entry("hello".to_string(), 5, EntryKind::Text)
2356 .unwrap();
2357 region
2358 .add_typed_entry("hi".to_string(), 5, EntryKind::UserMessage)
2359 .unwrap();
2360 region
2361 .add_typed_entry(
2362 "orphan result".to_string(),
2363 5,
2364 EntryKind::ToolResult {
2365 tool_call_id: "tc_x".to_string(),
2366 tool_name: "tool".to_string(),
2367 is_error: false,
2368 },
2369 )
2370 .unwrap();
2371
2372 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); }
2376
2377 #[test]
2380 fn test_remove_oldest_evicts_entire_turn_group() {
2381 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
2382 region
2384 .add_typed_entry(
2385 "assistant".to_string(),
2386 100,
2387 EntryKind::AssistantTurn {
2388 tool_calls: vec![
2389 SerializedToolCall {
2390 id: "tc_1".to_string(),
2391 name: "read_file".to_string(),
2392 arguments: serde_json::json!({}),
2393 thought_signature: None,
2394 },
2395 SerializedToolCall {
2396 id: "tc_2".to_string(),
2397 name: "list_dir".to_string(),
2398 arguments: serde_json::json!({}),
2399 thought_signature: None,
2400 },
2401 ],
2402 },
2403 )
2404 .unwrap();
2405 region
2406 .add_typed_entry(
2407 "result 1".to_string(),
2408 30,
2409 EntryKind::ToolResult {
2410 tool_call_id: "tc_1".to_string(),
2411 tool_name: "read_file".to_string(),
2412 is_error: false,
2413 },
2414 )
2415 .unwrap();
2416 region
2417 .add_typed_entry(
2418 "result 2".to_string(),
2419 20,
2420 EntryKind::ToolResult {
2421 tool_call_id: "tc_2".to_string(),
2422 tool_name: "list_dir".to_string(),
2423 is_error: false,
2424 },
2425 )
2426 .unwrap();
2427 region
2429 .add_typed_entry("user msg".to_string(), 10, EntryKind::UserMessage)
2430 .unwrap();
2431
2432 assert_eq!(region.entry_count(), 4);
2433 assert_eq!(region.current_tokens, 160);
2434
2435 let removed = region.remove_oldest().unwrap();
2436 assert_eq!(removed.content, "assistant");
2439 assert_eq!(removed.tokens, 100 + 30 + 20); assert_eq!(region.entry_count(), 1);
2442 assert_eq!(region.content[0].content, "user msg");
2443 assert_eq!(region.current_tokens, 10);
2444 }
2445
2446 #[test]
2449 fn test_remove_oldest_turn_group_calls_taint_remove_for_each_entry() {
2450 let mut region =
2451 Region::new("conv".to_string(), RegionKind::Temporary, 50000).with_taint_tracking();
2452
2453 region
2455 .add_typed_tainted_entry(
2456 "assistant".to_string(),
2457 10,
2458 EntryKind::AssistantTurn {
2459 tool_calls: vec![SerializedToolCall {
2460 id: "tc_1".to_string(),
2461 name: "tool".to_string(),
2462 arguments: serde_json::json!({}),
2463 thought_signature: None,
2464 }],
2465 },
2466 crate::taint::TaintLevel::Private,
2467 )
2468 .unwrap();
2469 region
2470 .add_typed_tainted_entry(
2471 "result".to_string(),
2472 5,
2473 EntryKind::ToolResult {
2474 tool_call_id: "tc_1".to_string(),
2475 tool_name: "tool".to_string(),
2476 is_error: false,
2477 },
2478 crate::taint::TaintLevel::Internal,
2479 )
2480 .unwrap();
2481 region
2482 .add_tainted_entry(
2483 "public stuff".to_string(),
2484 5,
2485 crate::taint::TaintLevel::Public,
2486 )
2487 .unwrap();
2488
2489 assert_eq!(
2490 region.taint_level(),
2491 Some(crate::taint::TaintLevel::Private)
2492 );
2493 assert_eq!(region.taint.as_ref().unwrap().entry_count(), 3);
2494
2495 let removed = region.remove_oldest().unwrap();
2497 assert_eq!(removed.content, "assistant");
2498 assert_eq!(region.entry_count(), 1);
2499 assert_eq!(region.taint.as_ref().unwrap().entry_count(), 1);
2502 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2503 }
2504
2505 #[test]
2508 fn test_sliding_window_evicts_entire_turn_group() {
2509 let mut region = Region::new(
2510 "conv".to_string(),
2511 RegionKind::SlidingWindow {
2512 max_items: 3,
2513 eviction_strategy: EvictionStrategy::PerItem,
2514 },
2515 50000,
2516 );
2517
2518 region
2520 .add_typed_entry(
2521 "assistant".to_string(),
2522 10,
2523 EntryKind::AssistantTurn {
2524 tool_calls: vec![
2525 SerializedToolCall {
2526 id: "tc_1".to_string(),
2527 name: "t1".to_string(),
2528 arguments: serde_json::json!({}),
2529 thought_signature: None,
2530 },
2531 SerializedToolCall {
2532 id: "tc_2".to_string(),
2533 name: "t2".to_string(),
2534 arguments: serde_json::json!({}),
2535 thought_signature: None,
2536 },
2537 ],
2538 },
2539 )
2540 .unwrap();
2541 region
2542 .add_typed_entry(
2543 "r1".to_string(),
2544 5,
2545 EntryKind::ToolResult {
2546 tool_call_id: "tc_1".to_string(),
2547 tool_name: "t1".to_string(),
2548 is_error: false,
2549 },
2550 )
2551 .unwrap();
2552 region
2553 .add_typed_entry(
2554 "r2".to_string(),
2555 5,
2556 EntryKind::ToolResult {
2557 tool_call_id: "tc_2".to_string(),
2558 tool_name: "t2".to_string(),
2559 is_error: false,
2560 },
2561 )
2562 .unwrap();
2563
2564 assert_eq!(region.entry_count(), 3);
2565
2566 region
2569 .add_typed_entry("user msg".to_string(), 15, EntryKind::UserMessage)
2570 .unwrap();
2571
2572 assert_eq!(region.entry_count(), 1);
2574 assert_eq!(region.content[0].content, "user msg");
2575 assert_eq!(region.current_tokens, 15);
2576 }
2577
2578 #[test]
2581 fn test_add_entry_with_metadata_tracks_taint_as_public() {
2582 let mut region =
2583 Region::new("data".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
2584
2585 region
2586 .add_entry_with_metadata("content".to_string(), 10, serde_json::json!({"key": "val"}))
2587 .unwrap();
2588
2589 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2590 assert_eq!(region.taint.as_ref().unwrap().entry_count(), 1);
2591 assert_eq!(
2592 region.taint.as_ref().unwrap().entry_taint(0),
2593 Some(crate::taint::TaintLevel::Public)
2594 );
2595 }
2596
2597 #[test]
2600 fn test_add_typed_entry_tracks_taint_as_public() {
2601 let mut region =
2602 Region::new("conv".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
2603
2604 region
2605 .add_typed_entry(
2606 "assistant response".to_string(),
2607 10,
2608 EntryKind::AssistantTurn { tool_calls: vec![] },
2609 )
2610 .unwrap();
2611
2612 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2613 assert_eq!(region.taint.as_ref().unwrap().entry_count(), 1);
2614 assert_eq!(
2615 region.taint.as_ref().unwrap().entry_taint(0),
2616 Some(crate::taint::TaintLevel::Public)
2617 );
2618 }
2619
2620 #[test]
2623 fn test_per_item_strategy_evicts_one_at_a_time() {
2624 let mut region = Region::new(
2625 "conv".to_string(),
2626 RegionKind::SlidingWindow {
2627 max_items: 3,
2628 eviction_strategy: EvictionStrategy::PerItem,
2629 },
2630 50000,
2631 );
2632 for i in 0..5 {
2633 region.add_entry(format!("msg{}", i), 10).unwrap();
2634 }
2635 assert_eq!(region.entry_count(), 3);
2636 assert_eq!(region.content[0].content, "msg2");
2637 assert_eq!(region.content[1].content, "msg3");
2638 assert_eq!(region.content[2].content, "msg4");
2639 }
2640
2641 #[test]
2642 fn test_bulk_eviction_triggers_on_overflow() {
2643 let mut region = Region::new(
2644 "conv".to_string(),
2645 RegionKind::SlidingWindow {
2646 max_items: 5,
2647 eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2648 },
2649 50000,
2650 );
2651 for i in 0..8 {
2654 region.add_entry(format!("msg{}", i), 10).unwrap();
2655 }
2656 assert_eq!(region.entry_count(), 8);
2657
2658 region.add_entry("msg8".to_string(), 10).unwrap();
2660 assert_eq!(region.entry_count(), 5);
2661 assert_eq!(region.content[0].content, "msg4");
2662 }
2663
2664 #[test]
2665 fn test_bulk_eviction_respects_turn_groups() {
2666 let mut region = Region::new(
2667 "conv".to_string(),
2668 RegionKind::SlidingWindow {
2669 max_items: 3,
2670 eviction_strategy: EvictionStrategy::Bulk { overflow: 2 },
2671 },
2672 50000,
2673 );
2674 region
2676 .add_typed_entry(
2677 "assistant".to_string(),
2678 10,
2679 EntryKind::AssistantTurn {
2680 tool_calls: vec![SerializedToolCall {
2681 id: "tc1".to_string(),
2682 name: "tool".to_string(),
2683 arguments: serde_json::json!({}),
2684 thought_signature: None,
2685 }],
2686 },
2687 )
2688 .unwrap();
2689 region
2690 .add_typed_entry(
2691 "result".to_string(),
2692 5,
2693 EntryKind::ToolResult {
2694 tool_call_id: "tc1".to_string(),
2695 tool_name: "tool".to_string(),
2696 is_error: false,
2697 },
2698 )
2699 .unwrap();
2700 region.add_entry("msg2".to_string(), 10).unwrap();
2702 region.add_entry("msg3".to_string(), 10).unwrap();
2703 region.add_entry("msg4".to_string(), 10).unwrap();
2704 assert_eq!(region.entry_count(), 5);
2706
2707 region.add_entry("msg5".to_string(), 10).unwrap();
2709 assert_eq!(region.entry_count(), 3);
2712 assert_eq!(region.content[0].content, "msg3");
2713 }
2714
2715 #[test]
2716 fn test_bulk_eviction_under_overflow_no_eviction() {
2717 let mut region = Region::new(
2718 "conv".to_string(),
2719 RegionKind::SlidingWindow {
2720 max_items: 5,
2721 eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2722 },
2723 50000,
2724 );
2725 for i in 0..7 {
2727 region.add_entry(format!("msg{}", i), 10).unwrap();
2728 }
2729 assert_eq!(region.entry_count(), 7);
2731 }
2732
2733 #[test]
2734 fn test_compact_sets_needs_message_compaction_flag() {
2735 let mut region = Region::new(
2736 "conv".to_string(),
2737 RegionKind::SlidingWindow {
2738 max_items: 5,
2739 eviction_strategy: EvictionStrategy::Compact { compact_count: 3 },
2740 },
2741 50000,
2742 );
2743 assert!(!region.needs_message_compaction);
2744
2745 for i in 0..9 {
2747 region.add_entry(format!("msg{}", i), 10).unwrap();
2748 }
2749 assert!(region.needs_message_compaction);
2750 assert_eq!(region.entry_count(), 9);
2752 }
2753
2754 #[test]
2755 fn test_compact_fallback_to_bulk_eviction() {
2756 let mut region = Region::new(
2757 "conv".to_string(),
2758 RegionKind::SlidingWindow {
2759 max_items: 5,
2760 eviction_strategy: EvictionStrategy::Compact { compact_count: 3 },
2761 },
2762 50000,
2763 );
2764 for i in 0..12 {
2767 region.add_entry(format!("msg{}", i), 10).unwrap();
2768 }
2769 assert_eq!(region.entry_count(), 5);
2771 assert_eq!(region.content[0].content, "msg7");
2772 assert!(!region.needs_message_compaction);
2774 }
2775
2776 #[test]
2777 fn test_eviction_strategy_default_is_per_item() {
2778 assert_eq!(EvictionStrategy::default(), EvictionStrategy::PerItem);
2779 }
2780
2781 #[test]
2782 fn test_remove_entries_by_prefix() {
2783 let mut region = Region::new("system".to_string(), RegionKind::Pinned, 50000);
2784 region
2785 .add_entry("[Stage instructions: Be terse.]".to_string(), 10)
2786 .unwrap();
2787 region
2788 .add_entry("Core identity block".to_string(), 20)
2789 .unwrap();
2790 region
2791 .add_entry("[Stage instructions: Be verbose.]".to_string(), 15)
2792 .unwrap();
2793
2794 assert_eq!(region.entry_count(), 3);
2795 region.remove_entries_by_prefix("[Stage instructions:");
2796 assert_eq!(region.entry_count(), 1);
2797 assert_eq!(region.content[0].content, "Core identity block");
2798 assert_eq!(region.current_tokens, 20);
2799 }
2800
2801 #[test]
2802 fn test_remove_entries_by_prefix_with_taint_tracking() {
2803 let mut region =
2804 Region::new("system".to_string(), RegionKind::Pinned, 50000).with_taint_tracking();
2805 region
2806 .add_tainted_entry(
2807 "[Stage instructions: Be terse.]".to_string(),
2808 10,
2809 crate::taint::TaintLevel::Private,
2810 )
2811 .unwrap();
2812 region
2813 .add_tainted_entry(
2814 "Core identity block".to_string(),
2815 20,
2816 crate::taint::TaintLevel::Public,
2817 )
2818 .unwrap();
2819 region
2820 .add_tainted_entry(
2821 "[Stage instructions: Be verbose.]".to_string(),
2822 15,
2823 crate::taint::TaintLevel::Internal,
2824 )
2825 .unwrap();
2826
2827 assert_eq!(region.entry_count(), 3);
2828 assert_eq!(
2829 region.taint_level(),
2830 Some(crate::taint::TaintLevel::Private)
2831 );
2832
2833 region.remove_entries_by_prefix("[Stage instructions:");
2834 assert_eq!(region.entry_count(), 1);
2835 assert_eq!(region.content[0].content, "Core identity block");
2836 assert_eq!(region.current_tokens, 20);
2837 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2839 assert_eq!(region.taint.as_ref().unwrap().entry_count(), 1);
2840 }
2841
2842 #[test]
2843 fn test_compact_below_threshold_no_flag() {
2844 let mut region = Region::new(
2846 "conv".to_string(),
2847 RegionKind::SlidingWindow {
2848 max_items: 5,
2849 eviction_strategy: EvictionStrategy::Compact { compact_count: 3 },
2850 },
2851 50000,
2852 );
2853 for i in 0..8 {
2854 region.add_entry(format!("msg{}", i), 10).unwrap();
2855 }
2856 assert!(!region.needs_message_compaction);
2858 assert_eq!(region.entry_count(), 8);
2859 }
2860
2861 #[test]
2862 fn test_bulk_eviction_with_taint_tracking() {
2863 let mut region = Region::new(
2864 "conv".to_string(),
2865 RegionKind::SlidingWindow {
2866 max_items: 3,
2867 eviction_strategy: EvictionStrategy::Bulk { overflow: 2 },
2868 },
2869 50000,
2870 )
2871 .with_taint_tracking();
2872
2873 region
2875 .add_tainted_entry("private".to_string(), 10, crate::taint::TaintLevel::Private)
2876 .unwrap();
2877 for i in 1..5 {
2878 region
2879 .add_tainted_entry(format!("pub{}", i), 10, crate::taint::TaintLevel::Public)
2880 .unwrap();
2881 }
2882 assert_eq!(region.entry_count(), 5);
2883
2884 region
2886 .add_tainted_entry("pub5".to_string(), 10, crate::taint::TaintLevel::Public)
2887 .unwrap();
2888 assert_eq!(region.entry_count(), 3);
2889 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2891 }
2892
2893 #[test]
2894 fn test_eviction_strategy_serde_roundtrip() {
2895 let bulk = EvictionStrategy::Bulk { overflow: 5 };
2896 let json = serde_json::to_string(&bulk).unwrap();
2897 let parsed: EvictionStrategy = serde_json::from_str(&json).unwrap();
2898 assert_eq!(parsed, bulk);
2899
2900 let compact = EvictionStrategy::Compact { compact_count: 10 };
2901 let json = serde_json::to_string(&compact).unwrap();
2902 let parsed: EvictionStrategy = serde_json::from_str(&json).unwrap();
2903 assert_eq!(parsed, compact);
2904
2905 let per_item = EvictionStrategy::PerItem;
2906 let json = serde_json::to_string(&per_item).unwrap();
2907 let parsed: EvictionStrategy = serde_json::from_str(&json).unwrap();
2908 assert_eq!(parsed, per_item);
2909 }
2910
2911 #[test]
2912 fn test_sliding_window_kind_equality_with_eviction_strategy() {
2913 assert_eq!(
2914 RegionKind::SlidingWindow {
2915 max_items: 10,
2916 eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2917 },
2918 RegionKind::SlidingWindow {
2919 max_items: 10,
2920 eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2921 }
2922 );
2923 assert_ne!(
2924 RegionKind::SlidingWindow {
2925 max_items: 10,
2926 eviction_strategy: EvictionStrategy::PerItem,
2927 },
2928 RegionKind::SlidingWindow {
2929 max_items: 10,
2930 eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2931 }
2932 );
2933 }
2934
2935 #[test]
2936 fn test_needs_message_compaction_default_false() {
2937 let region = Region::new("conv".to_string(), RegionKind::Temporary, 1000);
2938 assert!(!region.needs_message_compaction);
2939 }
2940
2941 #[test]
2944 fn test_add_typed_entry_validates_schema() {
2945 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000)
2946 .with_schema(RegionSchema::new(ContentFormat::Json));
2947 let result = region.add_typed_entry("not json".to_string(), 5, EntryKind::Text);
2948 assert!(result.is_err());
2949 assert_eq!(region.entry_count(), 0);
2950 }
2951
2952 #[test]
2953 fn test_add_typed_entry_checks_budget() {
2954 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 10);
2955 let result = region.add_typed_entry("too big".to_string(), 20, EntryKind::UserMessage);
2956 assert!(result.is_err());
2957 assert_eq!(region.entry_count(), 0);
2958 }
2959
2960 #[test]
2961 fn test_add_tainted_entry_without_taint_tracking() {
2962 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
2964 region
2965 .add_tainted_entry("data".to_string(), 10, crate::taint::TaintLevel::Private)
2966 .unwrap();
2967 assert_eq!(region.entry_count(), 1);
2968 assert_eq!(region.taint_level(), None);
2969 }
2970
2971 #[test]
2972 fn test_remove_entries_by_prefix_no_match() {
2973 let mut region = Region::new("system".to_string(), RegionKind::Pinned, 50000);
2974 region.add_entry("Keep this".to_string(), 10).unwrap();
2975 region.add_entry("And this".to_string(), 20).unwrap();
2976 region.remove_entries_by_prefix("[Stage instructions:");
2977 assert_eq!(region.entry_count(), 2);
2978 assert_eq!(region.current_tokens, 30);
2979 }
2980
2981 #[test]
2984 fn test_hashmap_region_upsert_and_get() {
2985 let mut region = Region::new(
2986 "files".to_string(),
2987 RegionKind::HashMap { max_entries: None },
2988 10000,
2989 );
2990 region
2991 .upsert_by_key("src/main.rs", "fn main() {}".to_string(), 10)
2992 .unwrap();
2993 region
2994 .upsert_by_key("src/lib.rs", "pub mod foo;".to_string(), 8)
2995 .unwrap();
2996
2997 assert_eq!(region.entry_count(), 2);
2998 assert_eq!(region.current_tokens, 18);
2999
3000 let entry = region.get_by_key("src/main.rs").unwrap();
3001 assert_eq!(entry.content, "fn main() {}");
3002 assert_eq!(entry.key.as_deref(), Some("src/main.rs"));
3003 }
3004
3005 #[test]
3006 fn test_hashmap_region_upsert_replaces_existing() {
3007 let mut region = Region::new(
3008 "files".to_string(),
3009 RegionKind::HashMap { max_entries: None },
3010 10000,
3011 );
3012 region
3013 .upsert_by_key("file.rs", "version 1".to_string(), 10)
3014 .unwrap();
3015 assert_eq!(region.current_tokens, 10);
3016
3017 region
3018 .upsert_by_key("file.rs", "version 2".to_string(), 15)
3019 .unwrap();
3020 assert_eq!(region.entry_count(), 1);
3021 assert_eq!(region.current_tokens, 15);
3022 assert_eq!(region.get_by_key("file.rs").unwrap().content, "version 2");
3023 }
3024
3025 #[test]
3026 fn test_hashmap_region_remove_by_key() {
3027 let mut region = Region::new(
3028 "files".to_string(),
3029 RegionKind::HashMap { max_entries: None },
3030 10000,
3031 );
3032 region.upsert_by_key("a.rs", "aaa".to_string(), 10).unwrap();
3033 region.upsert_by_key("b.rs", "bbb".to_string(), 20).unwrap();
3034
3035 assert!(region.remove_by_key("a.rs"));
3036 assert_eq!(region.entry_count(), 1);
3037 assert_eq!(region.current_tokens, 20);
3038 assert!(region.get_by_key("a.rs").is_none());
3039 assert!(!region.remove_by_key("nonexistent"));
3040 }
3041
3042 #[test]
3043 fn test_hashmap_region_keys() {
3044 let mut region = Region::new(
3045 "files".to_string(),
3046 RegionKind::HashMap { max_entries: None },
3047 10000,
3048 );
3049 region.upsert_by_key("x.rs", "x".to_string(), 5).unwrap();
3050 region.upsert_by_key("y.rs", "y".to_string(), 5).unwrap();
3051
3052 let keys = region.keys();
3053 assert_eq!(keys.len(), 2);
3054 assert!(keys.contains(&"x.rs"));
3055 assert!(keys.contains(&"y.rs"));
3056 }
3057
3058 #[test]
3059 fn test_hashmap_region_lru_eviction_on_max_tokens() {
3060 let mut region = Region::new(
3061 "files".to_string(),
3062 RegionKind::HashMap { max_entries: None },
3063 30, );
3065 region.upsert_by_key("a.rs", "aaa".to_string(), 10).unwrap();
3066 region.content[0].timestamp -= 100;
3068 region.upsert_by_key("b.rs", "bbb".to_string(), 10).unwrap();
3069 region.upsert_by_key("c.rs", "ccc".to_string(), 10).unwrap();
3070 assert_eq!(region.entry_count(), 3);
3071 assert_eq!(region.current_tokens, 30);
3072
3073 region.upsert_by_key("d.rs", "ddd".to_string(), 10).unwrap();
3075 assert_eq!(region.entry_count(), 3);
3076 assert!(region.get_by_key("a.rs").is_none());
3077 assert!(region.get_by_key("d.rs").is_some());
3078 }
3079
3080 #[test]
3081 fn test_hashmap_region_max_entries_eviction() {
3082 let mut region = Region::new(
3083 "files".to_string(),
3084 RegionKind::HashMap {
3085 max_entries: Some(2),
3086 },
3087 10000,
3088 );
3089 region.upsert_by_key("a.rs", "aaa".to_string(), 10).unwrap();
3090 region.content[0].timestamp -= 100; region.upsert_by_key("b.rs", "bbb".to_string(), 10).unwrap();
3092 assert_eq!(region.entry_count(), 2);
3093
3094 region.upsert_by_key("c.rs", "ccc".to_string(), 10).unwrap();
3096 assert_eq!(region.entry_count(), 2);
3097 assert!(region.get_by_key("a.rs").is_none());
3098 assert!(region.get_by_key("c.rs").is_some());
3099 }
3100
3101 #[test]
3102 fn test_hashmap_region_upsert_too_large_for_budget() {
3103 let mut region = Region::new(
3104 "files".to_string(),
3105 RegionKind::HashMap { max_entries: None },
3106 5, );
3108 let result = region.upsert_by_key("big.rs", "huge content".to_string(), 100);
3109 assert!(result.is_err());
3110 }
3111
3112 #[test]
3113 fn test_hashmap_region_kind_equality() {
3114 assert_eq!(
3115 RegionKind::HashMap {
3116 max_entries: Some(10)
3117 },
3118 RegionKind::HashMap {
3119 max_entries: Some(10)
3120 }
3121 );
3122 assert_ne!(
3123 RegionKind::HashMap {
3124 max_entries: Some(10)
3125 },
3126 RegionKind::HashMap {
3127 max_entries: Some(20)
3128 }
3129 );
3130 assert_ne!(
3131 RegionKind::HashMap { max_entries: None },
3132 RegionKind::Pinned
3133 );
3134 }
3135
3136 #[test]
3137 fn test_hashmap_cache_hint() {
3138 let kind = RegionKind::HashMap { max_entries: None };
3139 assert_eq!(kind.cache_hint(), crate::cache::CacheHint::UntilChanged);
3140 }
3141
3142 #[test]
3143 fn test_region_entry_key_default_none() {
3144 let mut region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
3145 region.add_entry("content".to_string(), 10).unwrap();
3146 assert!(region.content[0].key.is_none());
3147 }
3148
3149 #[test]
3150 fn test_region_entry_key_serde_skip_when_none() {
3151 let entry = RegionEntry {
3152 content: "test".into(),
3153 tokens: 5,
3154 timestamp: 0,
3155 metadata: None,
3156 kind: EntryKind::default(),
3157 key: None,
3158 reasoning: None,
3159 };
3160 let json = serde_json::to_string(&entry).unwrap();
3161 assert!(!json.contains("key"));
3162 }
3163
3164 #[test]
3165 fn test_region_entry_key_serde_roundtrip() {
3166 let entry = RegionEntry {
3167 content: "test".into(),
3168 tokens: 5,
3169 timestamp: 0,
3170 metadata: None,
3171 kind: EntryKind::default(),
3172 key: Some("mykey".to_string()),
3173 reasoning: None,
3174 };
3175 let json = serde_json::to_string(&entry).unwrap();
3176 assert!(json.contains("mykey"));
3177 let back: RegionEntry = serde_json::from_str(&json).unwrap();
3178 assert_eq!(back.key.as_deref(), Some("mykey"));
3179 }
3180
3181 #[test]
3184 fn test_hashmap_region_creation_and_basic_properties() {
3185 let region = Region::new(
3186 "lookup".to_string(),
3187 RegionKind::HashMap {
3188 max_entries: Some(5),
3189 },
3190 2000,
3191 );
3192 assert_eq!(region.name, "lookup");
3193 assert_eq!(
3194 region.kind,
3195 RegionKind::HashMap {
3196 max_entries: Some(5)
3197 }
3198 );
3199 assert_eq!(region.max_tokens, 2000);
3200 assert_eq!(region.current_tokens, 0);
3201 assert_eq!(region.entry_count(), 0);
3202 assert!(region.content.is_empty());
3203 }
3204
3205 #[test]
3206 fn test_hashmap_upsert_insert_new_entry() {
3207 let mut region = Region::new(
3208 "store".to_string(),
3209 RegionKind::HashMap {
3210 max_entries: Some(5),
3211 },
3212 5000,
3213 );
3214 region
3215 .upsert_by_key("config.toml", "[package]\nname = \"foo\"".to_string(), 12)
3216 .unwrap();
3217
3218 assert_eq!(region.entry_count(), 1);
3219 assert_eq!(region.current_tokens, 12);
3220
3221 let entry = region.get_by_key("config.toml").unwrap();
3222 assert_eq!(entry.content, "[package]\nname = \"foo\"");
3223 assert_eq!(entry.tokens, 12);
3224 assert_eq!(entry.key.as_deref(), Some("config.toml"));
3225 }
3226
3227 #[test]
3228 fn test_hashmap_upsert_update_existing_entry() {
3229 let mut region = Region::new(
3230 "store".to_string(),
3231 RegionKind::HashMap { max_entries: None },
3232 5000,
3233 );
3234 region
3235 .upsert_by_key("readme.md", "# Old".to_string(), 20)
3236 .unwrap();
3237 assert_eq!(region.current_tokens, 20);
3238
3239 region
3240 .upsert_by_key("readme.md", "# New and improved".to_string(), 35)
3241 .unwrap();
3242 assert_eq!(region.entry_count(), 1);
3243 assert_eq!(region.current_tokens, 35);
3244
3245 let entry = region.get_by_key("readme.md").unwrap();
3246 assert_eq!(entry.content, "# New and improved");
3247 assert_eq!(entry.tokens, 35);
3248 }
3249
3250 #[test]
3251 fn test_hashmap_upsert_lru_eviction_on_max_tokens() {
3252 let mut region = Region::new(
3253 "files".to_string(),
3254 RegionKind::HashMap { max_entries: None },
3255 100, );
3257
3258 region
3260 .upsert_by_key("first.rs", "first content".to_string(), 40)
3261 .unwrap();
3262 region.content[0].timestamp -= 200; region
3265 .upsert_by_key("second.rs", "second content".to_string(), 40)
3266 .unwrap();
3267 region.content[1].timestamp -= 100; region
3270 .upsert_by_key("third.rs", "third content".to_string(), 20)
3271 .unwrap();
3272 region
3276 .upsert_by_key("fourth.rs", "fourth content".to_string(), 30)
3277 .unwrap();
3278
3279 assert!(region.get_by_key("first.rs").is_none());
3281 assert!(region.get_by_key("fourth.rs").is_some());
3282 assert!(region.current_tokens <= 100);
3284 }
3285
3286 #[test]
3287 fn test_hashmap_upsert_max_entries_enforcement() {
3288 let mut region = Region::new(
3289 "cache".to_string(),
3290 RegionKind::HashMap {
3291 max_entries: Some(2),
3292 },
3293 50000,
3294 );
3295
3296 region
3297 .upsert_by_key("alpha", "aaa".to_string(), 10)
3298 .unwrap();
3299 region.content[0].timestamp -= 200; region.upsert_by_key("beta", "bbb".to_string(), 10).unwrap();
3302 region.content[1].timestamp -= 100;
3303
3304 region
3305 .upsert_by_key("gamma", "ccc".to_string(), 10)
3306 .unwrap();
3307
3308 assert_eq!(region.entry_count(), 2);
3310 assert!(region.get_by_key("alpha").is_none());
3311 assert!(region.get_by_key("beta").is_some());
3312 assert!(region.get_by_key("gamma").is_some());
3313 }
3314
3315 #[test]
3316 fn test_hashmap_get_by_key_found_and_not_found() {
3317 let mut region = Region::new(
3318 "data".to_string(),
3319 RegionKind::HashMap { max_entries: None },
3320 5000,
3321 );
3322 region
3323 .upsert_by_key("exists", "hello".to_string(), 5)
3324 .unwrap();
3325
3326 let found = region.get_by_key("exists");
3328 assert!(found.is_some());
3329 assert_eq!(found.unwrap().content, "hello");
3330
3331 let missing = region.get_by_key("does_not_exist");
3333 assert!(missing.is_none());
3334 }
3335
3336 #[test]
3337 fn test_hashmap_remove_by_key_exists() {
3338 let mut region = Region::new(
3339 "data".to_string(),
3340 RegionKind::HashMap { max_entries: None },
3341 5000,
3342 );
3343 region
3344 .upsert_by_key("target", "remove me".to_string(), 25)
3345 .unwrap();
3346 assert_eq!(region.current_tokens, 25);
3347
3348 let removed = region.remove_by_key("target");
3349 assert!(removed);
3350 assert_eq!(region.entry_count(), 0);
3351 assert_eq!(region.current_tokens, 0);
3352 assert!(region.get_by_key("target").is_none());
3353 }
3354
3355 #[test]
3356 fn test_hashmap_remove_by_key_does_not_exist() {
3357 let mut region = Region::new(
3358 "data".to_string(),
3359 RegionKind::HashMap { max_entries: None },
3360 5000,
3361 );
3362 let removed = region.remove_by_key("ghost");
3363 assert!(!removed);
3364 }
3365
3366 #[test]
3367 fn test_hashmap_keys_empty_populated_after_removal() {
3368 let mut region = Region::new(
3369 "data".to_string(),
3370 RegionKind::HashMap { max_entries: None },
3371 5000,
3372 );
3373
3374 assert!(region.keys().is_empty());
3376
3377 region.upsert_by_key("one", "1".to_string(), 5).unwrap();
3379 region.upsert_by_key("two", "2".to_string(), 5).unwrap();
3380 region.upsert_by_key("three", "3".to_string(), 5).unwrap();
3381
3382 let keys = region.keys();
3383 assert_eq!(keys.len(), 3);
3384 assert!(keys.contains(&"one"));
3385 assert!(keys.contains(&"two"));
3386 assert!(keys.contains(&"three"));
3387
3388 region.remove_by_key("two");
3390 let keys = region.keys();
3391 assert_eq!(keys.len(), 2);
3392 assert!(keys.contains(&"one"));
3393 assert!(!keys.contains(&"two"));
3394 assert!(keys.contains(&"three"));
3395 }
3396
3397 #[test]
3398 fn test_region_entry_serialization_with_key_field() {
3399 let entry_with_key = RegionEntry {
3401 content: "some data".into(),
3402 tokens: 10,
3403 timestamp: 1234567890,
3404 metadata: None,
3405 kind: EntryKind::default(),
3406 key: Some("mykey".to_string()),
3407 reasoning: None,
3408 };
3409 let json = serde_json::to_string(&entry_with_key).unwrap();
3410 let deserialized: RegionEntry = serde_json::from_str(&json).unwrap();
3411 assert_eq!(deserialized.key.as_deref(), Some("mykey"));
3412 assert_eq!(deserialized.content, "some data");
3413 assert_eq!(deserialized.tokens, 10);
3414
3415 let entry_no_key = RegionEntry {
3417 content: "no key data".into(),
3418 tokens: 7,
3419 timestamp: 1234567890,
3420 metadata: None,
3421 kind: EntryKind::default(),
3422 key: None,
3423 reasoning: None,
3424 };
3425 let json = serde_json::to_string(&entry_no_key).unwrap();
3426 assert!(!json.contains("\"key\""));
3427 let deserialized: RegionEntry = serde_json::from_str(&json).unwrap();
3428 assert!(deserialized.key.is_none());
3429 assert_eq!(deserialized.content, "no key data");
3430 }
3431
3432 #[test]
3433 fn test_hashmap_partial_eq() {
3434 let a = RegionKind::HashMap {
3435 max_entries: Some(5),
3436 };
3437 let b = RegionKind::HashMap {
3438 max_entries: Some(5),
3439 };
3440 let c = RegionKind::HashMap {
3441 max_entries: Some(10),
3442 };
3443 let d = RegionKind::HashMap { max_entries: None };
3444
3445 assert_eq!(a, b);
3446 assert_ne!(a, c);
3447 assert_ne!(a, d);
3448 assert_ne!(c, d);
3449 assert_ne!(a, RegionKind::Pinned);
3450 assert_ne!(a, RegionKind::Temporary);
3451 }
3452
3453 #[test]
3454 fn test_hashmap_cache_hint_returns_until_changed() {
3455 let kind = RegionKind::HashMap { max_entries: None };
3456 assert_eq!(kind.cache_hint(), crate::cache::CacheHint::UntilChanged);
3457
3458 let kind_with_max = RegionKind::HashMap {
3459 max_entries: Some(10),
3460 };
3461 assert_eq!(
3462 kind_with_max.cache_hint(),
3463 crate::cache::CacheHint::UntilChanged
3464 );
3465 }
3466
3467 #[test]
3470 fn test_remove_by_key_recomputes_taint_when_tracking_enabled() {
3471 let mut region = Region::new(
3474 "kv".to_string(),
3475 RegionKind::HashMap { max_entries: None },
3476 10_000,
3477 )
3478 .with_taint_tracking();
3479 region
3480 .upsert_by_key("k1", "value one".to_string(), 10)
3481 .unwrap();
3482 region
3483 .upsert_by_key("k2", "value two".to_string(), 10)
3484 .unwrap();
3485
3486 assert!(region.remove_by_key("k1"));
3487 assert!(!region.remove_by_key("missing"));
3488 assert_eq!(region.entry_count(), 1);
3489 assert_eq!(region.current_tokens, 10);
3490 }
3491
3492 #[test]
3493 fn test_evict_lru_entry_runs_taint_fixup() {
3494 let mut region = Region::new(
3498 "kv".to_string(),
3499 RegionKind::HashMap {
3500 max_entries: Some(1),
3501 },
3502 10_000,
3503 )
3504 .with_taint_tracking();
3505 region
3506 .upsert_by_key("first", "aaa".to_string(), 10)
3507 .unwrap();
3508 region
3509 .upsert_by_key("second", "bbb".to_string(), 10)
3510 .unwrap();
3511
3512 assert_eq!(region.entry_count(), 1);
3514 assert!(region.get_by_key("second").is_some());
3515 assert!(region.get_by_key("first").is_none());
3516 }
3517
3518 #[test]
3519 fn test_evict_lru_entry_on_empty_region_is_noop() {
3520 let mut region = Region::new(
3524 "kv".to_string(),
3525 RegionKind::HashMap {
3526 max_entries: Some(4),
3527 },
3528 1000,
3529 );
3530 assert_eq!(region.entry_count(), 0);
3531 region.evict_lru_entry();
3532 assert_eq!(region.entry_count(), 0);
3533 assert_eq!(region.current_tokens, 0);
3534 }
3535
3536 #[test]
3540 fn a_keyed_entry_can_be_added_to_any_region_kind_and_found_again() {
3541 for kind in [
3542 RegionKind::Temporary,
3543 RegionKind::Clearable,
3544 RegionKind::Pinned,
3545 ] {
3546 let mut region = Region::new("r".to_string(), kind.clone(), 1000);
3547 region
3548 .add_keyed_entry("doc", "body".to_string(), 10)
3549 .unwrap();
3550 assert_eq!(
3551 region.get_by_key("doc").map(|e| e.content.as_str()),
3552 Some("body"),
3553 "{kind:?}"
3554 );
3555 assert!(region.remove_by_key("doc"), "{kind:?}");
3556 assert_eq!(region.current_tokens, 0, "{kind:?}");
3557 }
3558 }
3559
3560 #[test]
3564 fn appending_under_one_key_twice_keeps_both_entries() {
3565 let mut region = Region::new("r".to_string(), RegionKind::Temporary, 1000);
3566 region
3567 .add_keyed_entry("doc", "first".to_string(), 5)
3568 .unwrap();
3569 region
3570 .add_keyed_entry("doc", "second".to_string(), 5)
3571 .unwrap();
3572 assert_eq!(region.content.len(), 2);
3573 assert_eq!(region.current_tokens, 10);
3574 }
3575
3576 #[test]
3579 fn a_refused_keyed_write_adds_nothing() {
3580 let mut region = Region::new("r".to_string(), RegionKind::Temporary, 10);
3581 assert!(
3582 region
3583 .add_keyed_entry("doc", "too big".to_string(), 99)
3584 .is_err()
3585 );
3586 assert!(region.content.is_empty());
3587 assert_eq!(region.current_tokens, 0);
3588 }
3589
3590 #[test]
3593 fn remove_at_releases_by_position_and_reports_a_miss() {
3594 let mut region = Region::new("r".to_string(), RegionKind::Temporary, 1000);
3595 for text in ["a", "b", "c"] {
3596 region.add_entry(text.to_string(), 5).unwrap();
3597 }
3598 assert!(region.remove_at(1));
3599 assert_eq!(region.current_tokens, 10);
3600 let left: Vec<_> = region.content.iter().map(|e| e.content.as_str()).collect();
3601 assert_eq!(left, vec!["a", "c"]);
3602
3603 assert!(!region.remove_at(9), "nothing at that position");
3604 assert_eq!(region.content.len(), 2, "a miss changes nothing");
3605 }
3606
3607 #[test]
3610 fn release_oldest_takes_what_it_can_and_says_how_much() {
3611 let mut region = Region::new("r".to_string(), RegionKind::Temporary, 1000);
3612 for text in ["a", "b", "c"] {
3613 region.add_entry(text.to_string(), 5).unwrap();
3614 }
3615 assert_eq!(region.release_oldest(2), 2);
3616 assert_eq!(
3617 region.content.first().map(|e| e.content.as_str()),
3618 Some("c"),
3619 "the oldest two went"
3620 );
3621 assert_eq!(region.release_oldest(10), 1, "only one was left");
3622 assert_eq!(region.release_oldest(3), 0, "and now none");
3623 assert_eq!(region.current_tokens, 0);
3624 }
3625
3626 #[test]
3630 fn a_reject_region_distinguishes_being_full_from_an_oversized_write() {
3631 let mut region = Region::new("r".to_string(), RegionKind::Temporary, 100);
3632 region.admission = Admission::Reject;
3633
3634 let err = region
3640 .add_entry("huge".to_string(), 500)
3641 .unwrap_err()
3642 .to_string();
3643 assert!(err.contains("exceeds token budget"), "{err}");
3644
3645 region.add_entry("fits".to_string(), 90).unwrap();
3646 let err = region
3647 .add_entry("more".to_string(), 50)
3648 .unwrap_err()
3649 .to_string();
3650 assert!(err.contains("Region 'r' is full"), "{err}");
3651 assert!(err.contains("90/100 tokens"), "{err}");
3652 assert!(err.contains("release an entry"), "says what to do: {err}");
3653 }
3654
3655 #[test]
3660 fn a_reject_sliding_window_refuses_rather_than_rolling_off() {
3661 let mut region = Region::new(
3662 "r".to_string(),
3663 RegionKind::SlidingWindow {
3664 max_items: 2,
3665 eviction_strategy: EvictionStrategy::PerItem,
3666 },
3667 1000,
3668 );
3669 region.admission = Admission::Reject;
3670 region.add_entry("one".to_string(), 5).unwrap();
3671 region.add_entry("two".to_string(), 5).unwrap();
3672
3673 let err = region
3674 .add_entry("three".to_string(), 5)
3675 .unwrap_err()
3676 .to_string();
3677 assert!(err.contains("is full"), "{err}");
3678 assert_eq!(region.content.len(), 2);
3679 assert_eq!(
3680 region.content.first().map(|e| e.content.as_str()),
3681 Some("one"),
3682 "the oldest survived"
3683 );
3684
3685 let mut evicting = Region::new(
3688 "r".to_string(),
3689 RegionKind::SlidingWindow {
3690 max_items: 2,
3691 eviction_strategy: EvictionStrategy::PerItem,
3692 },
3693 1000,
3694 );
3695 for text in ["one", "two", "three"] {
3696 evicting.add_entry(text.to_string(), 5).unwrap();
3697 }
3698 assert_eq!(evicting.content.len(), 2);
3699 assert_eq!(
3700 evicting.content.first().map(|e| e.content.as_str()),
3701 Some("two"),
3702 "the oldest rolled off as it always did"
3703 );
3704 }
3705
3706 #[test]
3710 fn a_saturated_window_does_not_abort_on_its_first_write() {
3711 let mut region = Region::new(
3712 "w".to_string(),
3713 RegionKind::SlidingWindow {
3714 max_items: usize::MAX,
3715 eviction_strategy: EvictionStrategy::Bulk { overflow: 10 },
3716 },
3717 100,
3718 );
3719 region.add_entry("x".to_string(), 1).unwrap();
3720 let mut region = Region::new(
3721 "w".to_string(),
3722 RegionKind::SlidingWindow {
3723 max_items: usize::MAX,
3724 eviction_strategy: EvictionStrategy::Compact { compact_count: 10 },
3725 },
3726 100,
3727 );
3728 region.add_entry("x".to_string(), 1).unwrap();
3729 }
3730}