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