1use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
16#[serde(tag = "type")]
17pub enum EntryKind {
18 #[default]
20 Text,
21 UserMessage,
23 AssistantTurn { tool_calls: Vec<SerializedToolCall> },
25 ToolResult {
27 tool_call_id: String,
28 tool_name: String,
29 is_error: bool,
30 },
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35pub struct SerializedToolCall {
36 pub id: String,
37 pub name: String,
38 pub arguments: serde_json::Value,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub thought_signature: Option<String>,
43}
44
45#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(tag = "strategy", rename_all = "snake_case")]
53pub enum EvictionStrategy {
54 #[default]
56 PerItem,
57 Bulk {
60 overflow: usize,
63 },
64 Compact {
68 compact_count: usize,
70 },
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
80pub enum RegionKind {
81 Pinned,
88
89 SlidingWindow {
96 max_items: usize,
98 eviction_strategy: EvictionStrategy,
100 },
101
102 Temporary,
108
109 Compacting {
115 threshold_tokens: usize,
117 },
118
119 Clearable,
126
127 CompactHistory {
134 source_region: String,
136 },
137
138 HashMap {
142 max_entries: Option<usize>,
144 },
145
146 Custom {
162 script: String,
164 persistent: bool,
167 },
168}
169
170impl PartialEq for RegionKind {
171 #[inline(never)]
172 fn eq(&self, other: &Self) -> bool {
173 match (self, other) {
174 (Self::Pinned, Self::Pinned)
175 | (Self::Temporary, Self::Temporary)
176 | (Self::Clearable, Self::Clearable) => true,
177 (
178 Self::SlidingWindow {
179 max_items: a,
180 eviction_strategy: sa,
181 },
182 Self::SlidingWindow {
183 max_items: b,
184 eviction_strategy: sb,
185 },
186 ) => a == b && sa == sb,
187 (
188 Self::Compacting {
189 threshold_tokens: a,
190 },
191 Self::Compacting {
192 threshold_tokens: b,
193 },
194 ) => a == b,
195 (
196 Self::CompactHistory { source_region: a },
197 Self::CompactHistory { source_region: b },
198 ) => a == b,
199 (Self::HashMap { max_entries: a }, Self::HashMap { max_entries: b }) => a == b,
200 (
201 Self::Custom {
202 script: a,
203 persistent: pa,
204 },
205 Self::Custom {
206 script: b,
207 persistent: pb,
208 },
209 ) => a == b && pa == pb,
210 _ => false,
211 }
212 }
213}
214impl Eq for RegionKind {}
215
216impl RegionKind {
217 pub fn cache_hint(&self) -> crate::cache::CacheHint {
219 match self {
220 RegionKind::Pinned | RegionKind::CompactHistory { .. } => {
221 crate::cache::CacheHint::Always
222 }
223 RegionKind::Compacting { .. } => crate::cache::CacheHint::UntilChanged,
224 RegionKind::SlidingWindow { .. } => crate::cache::CacheHint::SlidingPrefix {
225 stable_fraction: 0.75,
226 },
227 RegionKind::HashMap { .. } => crate::cache::CacheHint::UntilChanged,
228 RegionKind::Temporary | RegionKind::Clearable => crate::cache::CacheHint::Never,
229 RegionKind::Custom { persistent, .. } => {
233 if *persistent {
234 crate::cache::CacheHint::Always
235 } else {
236 crate::cache::CacheHint::UntilChanged
237 }
238 }
239 }
240 }
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct Region {
249 pub name: String,
251
252 pub kind: RegionKind,
254
255 pub content: Vec<RegionEntry>,
257
258 pub max_tokens: usize,
260
261 pub current_tokens: usize,
263
264 pub schema: Option<RegionSchema>,
266
267 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub taint: Option<crate::taint::RegionTaint>,
270
271 #[serde(default)]
275 pub needs_message_compaction: bool,
276}
277
278impl Region {
279 pub fn new(name: String, kind: RegionKind, max_tokens: usize) -> Self {
281 Self {
282 name,
283 kind,
284 content: Vec::new(),
285 max_tokens,
286 current_tokens: 0,
287 schema: None,
288 taint: None,
289 needs_message_compaction: false,
290 }
291 }
292
293 pub fn with_taint_tracking(mut self) -> Self {
295 self.taint = Some(crate::taint::RegionTaint::new());
296 self
297 }
298
299 pub fn enable_taint_tracking(&mut self) {
301 if self.taint.is_none() {
302 self.taint = Some(crate::taint::RegionTaint::new());
303 }
304 }
305
306 pub fn taint_level(&self) -> Option<crate::taint::TaintLevel> {
308 self.taint.as_ref().map(|t| t.level())
309 }
310
311 pub fn add_tainted_entry(
313 &mut self,
314 content: String,
315 tokens: usize,
316 taint_level: crate::taint::TaintLevel,
317 ) -> crate::error::Result<()> {
318 if let Some(schema) = &self.schema {
320 schema.validate(&content)?;
321 }
322
323 if self.current_tokens + tokens > self.max_tokens {
325 return Err(crate::error::Error::TokenBudgetExceeded {
326 used: self.current_tokens + tokens,
327 max: self.max_tokens,
328 });
329 }
330
331 self.content.push(RegionEntry {
333 content,
334 tokens,
335 timestamp: chrono::Utc::now().timestamp(),
336 metadata: None,
337 kind: EntryKind::default(),
338 key: None,
339 });
340 self.current_tokens += tokens;
341
342 if let Some(taint) = &mut self.taint {
344 taint.add_entry(taint_level);
345 }
346
347 self.enforce_sliding_window();
349
350 Ok(())
351 }
352
353 pub fn add_typed_tainted_entry(
362 &mut self,
363 content: String,
364 tokens: usize,
365 kind: EntryKind,
366 taint_level: crate::taint::TaintLevel,
367 ) -> crate::error::Result<()> {
368 if let Some(schema) = &self.schema {
370 schema.validate(&content)?;
371 }
372
373 if self.current_tokens + tokens > self.max_tokens {
375 return Err(crate::error::Error::TokenBudgetExceeded {
376 used: self.current_tokens + tokens,
377 max: self.max_tokens,
378 });
379 }
380
381 self.content.push(RegionEntry {
383 content,
384 tokens,
385 timestamp: chrono::Utc::now().timestamp(),
386 metadata: None,
387 kind,
388 key: None,
389 });
390 self.current_tokens += tokens;
391
392 if let Some(taint) = &mut self.taint {
394 taint.add_entry(taint_level);
395 }
396
397 self.enforce_sliding_window();
399
400 Ok(())
401 }
402
403 pub fn with_schema(mut self, schema: RegionSchema) -> Self {
405 self.schema = Some(schema);
406 self
407 }
408
409 pub fn add_entry(&mut self, content: String, tokens: usize) -> crate::error::Result<()> {
414 if let Some(schema) = &self.schema {
416 schema.validate(&content)?;
417 }
418
419 if self.current_tokens + tokens > self.max_tokens {
421 return Err(crate::error::Error::TokenBudgetExceeded {
422 used: self.current_tokens + tokens,
423 max: self.max_tokens,
424 });
425 }
426
427 self.content.push(RegionEntry {
429 content,
430 tokens,
431 timestamp: chrono::Utc::now().timestamp(),
432 metadata: None,
433 kind: EntryKind::default(),
434 key: None,
435 });
436 self.current_tokens += tokens;
437
438 if let Some(taint) = &mut self.taint {
440 taint.add_entry(crate::taint::TaintLevel::Public);
441 }
442
443 self.enforce_sliding_window();
445
446 Ok(())
447 }
448
449 pub fn add_entry_with_metadata(
451 &mut self,
452 content: String,
453 tokens: usize,
454 metadata: serde_json::Value,
455 ) -> crate::error::Result<()> {
456 if let Some(schema) = &self.schema {
458 schema.validate(&content)?;
459 }
460
461 if self.current_tokens + tokens > self.max_tokens {
463 return Err(crate::error::Error::TokenBudgetExceeded {
464 used: self.current_tokens + tokens,
465 max: self.max_tokens,
466 });
467 }
468
469 self.content.push(RegionEntry {
471 content,
472 tokens,
473 timestamp: chrono::Utc::now().timestamp(),
474 metadata: Some(metadata),
475 kind: EntryKind::default(),
476 key: None,
477 });
478 self.current_tokens += tokens;
479
480 if let Some(taint) = &mut self.taint {
482 taint.add_entry(crate::taint::TaintLevel::Public);
483 }
484
485 self.enforce_sliding_window();
487
488 Ok(())
489 }
490
491 pub fn add_typed_entry(
497 &mut self,
498 content: String,
499 tokens: usize,
500 kind: EntryKind,
501 ) -> crate::error::Result<()> {
502 if let Some(schema) = &self.schema {
504 schema.validate(&content)?;
505 }
506
507 if self.current_tokens + tokens > self.max_tokens {
509 return Err(crate::error::Error::TokenBudgetExceeded {
510 used: self.current_tokens + tokens,
511 max: self.max_tokens,
512 });
513 }
514
515 self.content.push(RegionEntry {
517 content,
518 tokens,
519 timestamp: chrono::Utc::now().timestamp(),
520 metadata: None,
521 kind,
522 key: None,
523 });
524 self.current_tokens += tokens;
525
526 if let Some(taint) = &mut self.taint {
528 taint.add_entry(crate::taint::TaintLevel::Public);
529 }
530
531 self.enforce_sliding_window();
533
534 Ok(())
535 }
536
537 pub fn carry_entry(&mut self, entry: RegionEntry) -> crate::error::Result<()> {
551 if self.current_tokens + entry.tokens > self.max_tokens {
553 return Err(crate::error::Error::TokenBudgetExceeded {
554 used: self.current_tokens + entry.tokens,
555 max: self.max_tokens,
556 });
557 }
558
559 self.current_tokens += entry.tokens;
560 self.content.push(entry);
561
562 self.enforce_sliding_window();
564
565 Ok(())
566 }
567
568 pub fn upsert_by_key(
571 &mut self,
572 key: &str,
573 content: String,
574 tokens: usize,
575 ) -> Result<(), String> {
576 if let Some(pos) = self
578 .content
579 .iter()
580 .position(|e| e.key.as_deref() == Some(key))
581 {
582 let old_tokens = self.content[pos].tokens;
583 self.current_tokens -= old_tokens;
584 self.content[pos].content = content;
585 self.content[pos].tokens = tokens;
586 self.content[pos].timestamp = chrono::Utc::now().timestamp();
587 self.current_tokens += tokens;
588 return Ok(());
589 }
590
591 let max_entries = if let RegionKind::HashMap {
593 max_entries: Some(max),
594 } = &self.kind
595 {
596 Some(*max)
597 } else {
598 None
599 };
600 if let Some(max) = max_entries {
601 while self.content.len() >= max {
602 self.evict_lru_entry();
603 }
604 }
605
606 while self.current_tokens + tokens > self.max_tokens && !self.content.is_empty() {
608 self.evict_lru_entry();
609 }
610
611 if self.current_tokens + tokens > self.max_tokens {
612 return Err(format!(
613 "Entry ({} tokens) exceeds region budget ({} max)",
614 tokens, self.max_tokens
615 ));
616 }
617
618 self.content.push(RegionEntry {
619 content,
620 tokens,
621 timestamp: chrono::Utc::now().timestamp(),
622 metadata: None,
623 kind: EntryKind::default(),
624 key: Some(key.to_string()),
625 });
626 self.current_tokens += tokens;
627 Ok(())
628 }
629
630 pub fn get_by_key(&self, key: &str) -> Option<&RegionEntry> {
632 self.content.iter().find(|e| e.key.as_deref() == Some(key))
633 }
634
635 pub fn remove_by_key(&mut self, key: &str) -> bool {
637 if let Some(pos) = self
638 .content
639 .iter()
640 .position(|e| e.key.as_deref() == Some(key))
641 {
642 let tokens = self.content[pos].tokens;
643 self.content.remove(pos);
644 self.current_tokens -= tokens;
645 if let Some(taint) = &mut self.taint {
646 taint.remove_at(pos);
647 }
648 true
649 } else {
650 false
651 }
652 }
653
654 pub fn keys(&self) -> Vec<&str> {
656 self.content
657 .iter()
658 .filter_map(|e| e.key.as_deref())
659 .collect()
660 }
661
662 fn evict_lru_entry(&mut self) {
664 if self.content.is_empty() {
665 return;
666 }
667 let oldest_idx = self
668 .content
669 .iter()
670 .enumerate()
671 .min_by_key(|(_, e)| e.timestamp)
672 .map(|(i, _)| i)
673 .unwrap_or(0);
674 let tokens = self.content[oldest_idx].tokens;
675 self.content.remove(oldest_idx);
676 self.current_tokens -= tokens;
677 if let Some(taint) = &mut self.taint {
678 taint.remove_at(oldest_idx);
679 }
680 }
681
682 fn enforce_sliding_window(&mut self) {
693 if let RegionKind::SlidingWindow {
694 max_items,
695 eviction_strategy,
696 } = &self.kind
697 {
698 let max = *max_items;
699 match eviction_strategy.clone() {
700 EvictionStrategy::PerItem => {
701 while self.content.len() > max && self.remove_oldest().is_some() {}
705 }
706 EvictionStrategy::Bulk { overflow } => {
707 if self.content.len() > max + overflow {
708 while self.content.len() > max && self.remove_oldest().is_some() {}
709 }
710 }
711 EvictionStrategy::Compact { compact_count } => {
712 if self.content.len() > max + compact_count * 2 {
713 while self.content.len() > max && self.remove_oldest().is_some() {}
716 self.needs_message_compaction = false;
717 } else if self.content.len() > max + compact_count {
718 self.needs_message_compaction = true;
719 }
720 }
721 }
722 }
723 }
724
725 fn turn_group_size_at(&self, idx: usize) -> usize {
733 if idx >= self.content.len() {
734 return 0;
735 }
736 match &self.content[idx].kind {
737 EntryKind::AssistantTurn { .. } => {
738 let mut size = 1;
739 while idx + size < self.content.len() {
740 if matches!(self.content[idx + size].kind, EntryKind::ToolResult { .. }) {
741 size += 1;
742 } else {
743 break;
744 }
745 }
746 size
747 }
748 _ => 1,
749 }
750 }
751
752 pub fn clear(&mut self) {
754 self.content.clear();
755 self.current_tokens = 0;
756 if let Some(taint) = &mut self.taint {
757 taint.clear();
758 }
759 }
760
761 pub fn remove_oldest(&mut self) -> Option<RegionEntry> {
763 if self.content.is_empty() {
764 return None;
765 }
766 let group_size = self.turn_group_size_at(0);
770 let mut first = None;
771 let mut extra_tokens = 0usize;
772 let mut i = 0;
775 while i < group_size && !self.content.is_empty() {
776 let entry_tokens = self.content[0].tokens;
777 self.current_tokens -= entry_tokens;
778 let removed = self.content.remove(0);
779 if let Some(taint) = &mut self.taint {
780 taint.remove_oldest();
781 }
782 if i == 0 {
783 first = Some(removed);
784 } else {
785 extra_tokens += entry_tokens;
786 }
787 i += 1;
788 }
789 first.map(|mut entry| {
795 entry.tokens += extra_tokens;
796 entry
797 })
798 }
799
800 pub fn remove_entries_by_prefix(&mut self, prefix: &str) {
806 let mut i = 0;
807 while i < self.content.len() {
808 if self.content[i].content.starts_with(prefix) {
809 let tokens = self.content[i].tokens;
810 self.content.remove(i);
811 self.current_tokens -= tokens;
812 if let Some(taint) = &mut self.taint {
813 taint.remove_at(i);
814 }
815 } else {
816 i += 1;
817 }
818 }
819 }
820
821 pub fn entry_count(&self) -> usize {
823 self.content.len()
824 }
825
826 pub fn needs_compaction(&self) -> bool {
828 if let RegionKind::Compacting { threshold_tokens } = self.kind {
829 self.current_tokens > threshold_tokens
830 } else {
831 false
832 }
833 }
834}
835
836#[derive(Debug, Clone, Serialize, Deserialize)]
840pub struct RegionEntry {
841 pub content: String,
843
844 pub tokens: usize,
846
847 pub timestamp: i64,
849
850 pub metadata: Option<serde_json::Value>,
852
853 #[serde(default)]
857 pub kind: EntryKind,
858
859 #[serde(default, skip_serializing_if = "Option::is_none")]
861 pub key: Option<String>,
862}
863
864#[derive(Debug, Serialize, Deserialize)]
870pub struct RegionSchema {
871 pub format: ContentFormat,
873
874 #[serde(skip_serializing_if = "Option::is_none")]
876 pub custom_script: Option<String>,
877}
878
879impl Clone for RegionSchema {
880 fn clone(&self) -> Self {
881 Self {
882 format: self.format.clone(),
883 custom_script: self.custom_script.clone(),
884 }
885 }
886}
887
888impl RegionSchema {
889 pub fn new(format: ContentFormat) -> Self {
891 Self {
892 format,
893 custom_script: None,
894 }
895 }
896
897 pub fn with_custom_script(mut self, script: String) -> Self {
899 self.custom_script = Some(script);
900 self
901 }
902
903 pub fn validate(&self, content: &str) -> crate::error::Result<()> {
905 match &self.format {
906 ContentFormat::Json => {
907 serde_json::from_str::<serde_json::Value>(content).map_err(|e| {
908 crate::error::Error::ValidationFailed(format!("Invalid JSON: {}", e))
909 })?;
910 }
911 ContentFormat::Mermaid => {
912 if !content.contains("graph")
914 && !content.contains("sequenceDiagram")
915 && !content.contains("classDiagram")
916 && !content.contains("stateDiagram")
917 && !content.contains("erDiagram")
918 && !content.contains("journey")
919 && !content.contains("gantt")
920 && !content.contains("pie")
921 && !content.contains("flowchart")
922 {
923 return Err(crate::error::Error::ValidationFailed(
924 "Mermaid diagrams must contain a valid diagram type (graph, sequenceDiagram, etc.)".to_string()
925 ));
926 }
927 }
928 ContentFormat::Code { .. } => {
929 if content.trim().is_empty() {
931 return Err(crate::error::Error::ValidationFailed(
932 "Code cannot be empty".to_string(),
933 ));
934 }
935 }
936 ContentFormat::Markdown => {
937 if content.trim().is_empty() {
939 return Err(crate::error::Error::ValidationFailed(
940 "Markdown content cannot be empty".to_string(),
941 ));
942 }
943 }
944 ContentFormat::Text | ContentFormat::Custom { .. } => {
945 }
947 }
948
949 Ok(())
950 }
951}
952
953#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
955pub enum ContentFormat {
956 Text,
958
959 Json,
961
962 Mermaid,
964
965 Code { language: String },
967
968 Markdown,
970
971 Custom { format_name: String },
973}
974
975pub trait Validator: Send + Sync {
981 fn validate(&self, content: &str) -> std::result::Result<(), crate::error::ValidationError>;
983
984 fn description(&self) -> &str;
986}
987
988#[cfg(test)]
989mod tests {
990 use super::*;
991
992 #[test]
993 fn test_region_creation() {
994 let region = Region::new("test".to_string(), RegionKind::Pinned, 1000);
995 assert_eq!(region.name, "test");
996 assert_eq!(region.max_tokens, 1000);
997 assert_eq!(region.current_tokens, 0);
998 }
999
1000 #[test]
1001 fn test_sliding_window_config() {
1002 let kind = RegionKind::SlidingWindow {
1003 max_items: 10,
1004 eviction_strategy: EvictionStrategy::PerItem,
1005 };
1006 let region = Region::new("history".to_string(), kind.clone(), 5000);
1007 assert_eq!(region.kind, kind);
1008 }
1009
1010 #[test]
1011 fn test_region_kind_equality() {
1012 assert_eq!(RegionKind::Clearable, RegionKind::Clearable);
1013 assert_eq!(
1014 RegionKind::Compacting {
1015 threshold_tokens: 500
1016 },
1017 RegionKind::Compacting {
1018 threshold_tokens: 500
1019 }
1020 );
1021 assert_eq!(
1022 RegionKind::CompactHistory {
1023 source_region: "conv".to_string()
1024 },
1025 RegionKind::CompactHistory {
1026 source_region: "conv".to_string()
1027 }
1028 );
1029 assert_ne!(RegionKind::Pinned, RegionKind::Temporary);
1030 }
1031
1032 #[test]
1033 fn custom_kind_equality_compares_script_and_persistent() {
1034 let a = RegionKind::Custom {
1035 script: "conv.rhai".to_string(),
1036 persistent: false,
1037 };
1038 assert_eq!(a, a.clone());
1039 assert_ne!(
1040 a,
1041 RegionKind::Custom {
1042 script: "other.rhai".to_string(),
1043 persistent: false,
1044 }
1045 );
1046 assert_ne!(
1047 a,
1048 RegionKind::Custom {
1049 script: "conv.rhai".to_string(),
1050 persistent: true,
1051 }
1052 );
1053 assert_ne!(a, RegionKind::Temporary);
1054 }
1055
1056 #[test]
1057 fn custom_kind_serde_round_trips() {
1058 let kind = RegionKind::Custom {
1059 script: "hooks/conv.rhai".to_string(),
1060 persistent: true,
1061 };
1062 let json = serde_json::to_string(&kind).unwrap();
1063 let back: RegionKind = serde_json::from_str(&json).unwrap();
1064 assert_eq!(kind, back);
1065 let old: RegionKind = serde_json::from_str("\"Pinned\"").unwrap();
1067 assert_eq!(old, RegionKind::Pinned);
1068 }
1069
1070 #[test]
1071 fn custom_kind_cache_hint_follows_persistent() {
1072 assert_eq!(
1073 RegionKind::Custom {
1074 script: "s.rhai".to_string(),
1075 persistent: true,
1076 }
1077 .cache_hint(),
1078 crate::cache::CacheHint::Always
1079 );
1080 assert_eq!(
1081 RegionKind::Custom {
1082 script: "s.rhai".to_string(),
1083 persistent: false,
1084 }
1085 .cache_hint(),
1086 crate::cache::CacheHint::UntilChanged
1087 );
1088 }
1089
1090 #[test]
1091 fn carry_entry_preserves_kind_metadata_key_and_timestamp() {
1092 let mut source = Region::new("conversation".to_string(), RegionKind::Temporary, 10_000);
1093 source
1094 .add_typed_entry(
1095 "result body".to_string(),
1096 10,
1097 EntryKind::ToolResult {
1098 tool_call_id: "call_1".to_string(),
1099 tool_name: "read_file".to_string(),
1100 is_error: false,
1101 },
1102 )
1103 .unwrap();
1104 let mut entry = source.content[0].clone();
1105 entry.metadata = Some(serde_json::json!({"origin": "test"}));
1106 entry.key = Some("k".to_string());
1107 let stamped = entry.timestamp;
1108
1109 let mut dest = Region::new("conversation".to_string(), RegionKind::Temporary, 10_000);
1110 dest.carry_entry(entry).unwrap();
1111
1112 let carried = &dest.content[0];
1113 assert!(matches!(
1114 &carried.kind,
1115 EntryKind::ToolResult { tool_call_id, .. } if tool_call_id == "call_1"
1116 ));
1117 assert_eq!(
1118 carried.metadata,
1119 Some(serde_json::json!({"origin": "test"}))
1120 );
1121 assert_eq!(carried.key.as_deref(), Some("k"));
1122 assert_eq!(carried.timestamp, stamped);
1123 assert_eq!(dest.current_tokens, 10);
1124 }
1125
1126 #[test]
1127 fn carry_entry_rejects_over_budget() {
1128 let mut dest = Region::new("small".to_string(), RegionKind::Temporary, 5);
1129 let mut source = Region::new("src".to_string(), RegionKind::Temporary, 100);
1130 source.add_entry("filler".to_string(), 10).unwrap();
1131 let err = dest.carry_entry(source.content[0].clone()).unwrap_err();
1132 assert_eq!(err.to_string(), "Content exceeds token budget: 10 > 5");
1133 assert!(dest.content.is_empty());
1134 assert_eq!(dest.current_tokens, 0);
1135 }
1136
1137 #[test]
1138 fn carry_entry_enforces_sliding_window_max_items() {
1139 let mut source = Region::new("src".to_string(), RegionKind::Temporary, 10_000);
1140 for i in 0..4 {
1141 source.add_entry(format!("msg{i}"), 10).unwrap();
1142 }
1143 let mut dest = Region::new(
1144 "conv".to_string(),
1145 RegionKind::SlidingWindow {
1146 max_items: 3,
1147 eviction_strategy: EvictionStrategy::PerItem,
1148 },
1149 10_000,
1150 );
1151 for entry in &source.content {
1152 dest.carry_entry(entry.clone()).unwrap();
1153 }
1154 assert_eq!(dest.content.len(), 3);
1155 assert_eq!(dest.content[0].content, "msg1");
1156 }
1157
1158 #[test]
1159 fn test_sliding_window_enforces_max_items() {
1160 let mut region = Region::new(
1161 "conv".to_string(),
1162 RegionKind::SlidingWindow {
1163 max_items: 3,
1164 eviction_strategy: EvictionStrategy::PerItem,
1165 },
1166 50000,
1167 );
1168
1169 region.add_entry("msg1".to_string(), 10).unwrap();
1170 region.add_entry("msg2".to_string(), 20).unwrap();
1171 region.add_entry("msg3".to_string(), 30).unwrap();
1172 assert_eq!(region.entry_count(), 3);
1173 assert_eq!(region.current_tokens, 60);
1174
1175 region.add_entry("msg4".to_string(), 40).unwrap();
1177 assert_eq!(region.entry_count(), 3);
1178 assert_eq!(region.content[0].content, "msg2");
1179 assert_eq!(region.content[2].content, "msg4");
1180 assert_eq!(region.current_tokens, 90); region.add_entry("msg5".to_string(), 50).unwrap();
1184 assert_eq!(region.entry_count(), 3);
1185 assert_eq!(region.content[0].content, "msg3");
1186 assert_eq!(region.current_tokens, 120); }
1188
1189 #[test]
1190 fn test_sliding_window_enforces_max_items_with_metadata() {
1191 let mut region = Region::new(
1192 "conv".to_string(),
1193 RegionKind::SlidingWindow {
1194 max_items: 2,
1195 eviction_strategy: EvictionStrategy::PerItem,
1196 },
1197 50000,
1198 );
1199
1200 region
1201 .add_entry_with_metadata("a".to_string(), 10, serde_json::json!({"idx": 1}))
1202 .unwrap();
1203 region
1204 .add_entry_with_metadata("b".to_string(), 20, serde_json::json!({"idx": 2}))
1205 .unwrap();
1206 region
1207 .add_entry_with_metadata("c".to_string(), 30, serde_json::json!({"idx": 3}))
1208 .unwrap();
1209
1210 assert_eq!(region.entry_count(), 2);
1211 assert_eq!(region.content[0].content, "b");
1212 assert_eq!(region.content[1].content, "c");
1213 assert_eq!(region.current_tokens, 50);
1214 }
1215
1216 #[test]
1217 fn test_cache_hint_pinned() {
1218 let kind = RegionKind::Pinned;
1219 assert_eq!(kind.cache_hint(), crate::cache::CacheHint::Always);
1220 }
1221
1222 #[test]
1223 fn test_cache_hint_compact_history() {
1224 let kind = RegionKind::CompactHistory {
1225 source_region: "conv".to_string(),
1226 };
1227 assert_eq!(kind.cache_hint(), crate::cache::CacheHint::Always);
1228 }
1229
1230 #[test]
1231 fn test_cache_hint_compacting() {
1232 let kind = RegionKind::Compacting {
1233 threshold_tokens: 1000,
1234 };
1235 assert_eq!(kind.cache_hint(), crate::cache::CacheHint::UntilChanged);
1236 }
1237
1238 #[test]
1239 fn test_cache_hint_sliding_window() {
1240 let kind = RegionKind::SlidingWindow {
1241 max_items: 10,
1242 eviction_strategy: EvictionStrategy::PerItem,
1243 };
1244 assert_eq!(
1245 kind.cache_hint(),
1246 crate::cache::CacheHint::SlidingPrefix {
1247 stable_fraction: 0.75
1248 }
1249 );
1250 }
1251
1252 #[test]
1253 fn test_cache_hint_temporary() {
1254 assert_eq!(
1255 RegionKind::Temporary.cache_hint(),
1256 crate::cache::CacheHint::Never
1257 );
1258 }
1259
1260 #[test]
1261 fn test_cache_hint_clearable() {
1262 assert_eq!(
1263 RegionKind::Clearable.cache_hint(),
1264 crate::cache::CacheHint::Never
1265 );
1266 }
1267
1268 #[test]
1271 fn test_with_schema_attaches_schema() {
1272 let schema = RegionSchema::new(ContentFormat::Json);
1273 let region =
1274 Region::new("data".to_string(), RegionKind::Temporary, 1000).with_schema(schema);
1275 assert!(region.schema.is_some());
1276 }
1277
1278 #[test]
1279 fn test_add_entry_rejects_content_failing_schema() {
1280 let schema = RegionSchema::new(ContentFormat::Json);
1281 let mut region =
1282 Region::new("data".to_string(), RegionKind::Temporary, 1000).with_schema(schema);
1283 let result = region.add_entry("not json".to_string(), 10);
1284 assert!(result.is_err());
1285 assert_eq!(region.entry_count(), 0);
1286 }
1287
1288 #[test]
1289 fn test_add_entry_accepts_content_passing_schema() {
1290 let schema = RegionSchema::new(ContentFormat::Json);
1291 let mut region =
1292 Region::new("data".to_string(), RegionKind::Temporary, 1000).with_schema(schema);
1293 let result = region.add_entry("{\"a\":1}".to_string(), 10);
1294 assert!(result.is_ok());
1295 assert_eq!(region.entry_count(), 1);
1296 }
1297
1298 #[test]
1299 fn test_add_entry_rejects_over_budget() {
1300 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 10);
1301 let result = region.add_entry("too much".to_string(), 20);
1302 assert_eq!(
1303 result.unwrap_err().to_string(),
1304 "Content exceeds token budget: 20 > 10"
1305 );
1306 assert_eq!(region.entry_count(), 0);
1307 }
1308
1309 #[test]
1310 fn test_add_entry_with_metadata_rejects_content_failing_schema() {
1311 let schema = RegionSchema::new(ContentFormat::Json);
1312 let mut region =
1313 Region::new("data".to_string(), RegionKind::Temporary, 1000).with_schema(schema);
1314 let result =
1315 region.add_entry_with_metadata("not json".to_string(), 10, serde_json::json!({}));
1316 assert!(result.is_err());
1317 }
1318
1319 #[test]
1320 fn test_add_entry_with_metadata_rejects_over_budget() {
1321 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 10);
1322 let result =
1323 region.add_entry_with_metadata("too much".to_string(), 20, serde_json::json!({}));
1324 assert_eq!(
1325 result.unwrap_err().to_string(),
1326 "Content exceeds token budget: 20 > 10"
1327 );
1328 }
1329
1330 #[test]
1331 fn test_add_entry_with_metadata_stores_metadata() {
1332 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1333 region
1334 .add_entry_with_metadata("hello".to_string(), 5, serde_json::json!({"k": "v"}))
1335 .unwrap();
1336 assert_eq!(
1337 region.content[0].metadata,
1338 Some(serde_json::json!({"k": "v"}))
1339 );
1340 }
1341
1342 #[test]
1345 fn test_clear_removes_all_content_and_resets_tokens() {
1346 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1347 region.add_entry("a".to_string(), 10).unwrap();
1348 region.add_entry("b".to_string(), 20).unwrap();
1349 assert_eq!(region.entry_count(), 2);
1350
1351 region.clear();
1352 assert_eq!(region.entry_count(), 0);
1353 assert_eq!(region.current_tokens, 0);
1354 }
1355
1356 #[test]
1357 fn test_remove_oldest_returns_and_removes_first_entry() {
1358 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1359 region.add_entry("first".to_string(), 10).unwrap();
1360 region.add_entry("second".to_string(), 20).unwrap();
1361
1362 let removed = region.remove_oldest().unwrap();
1363 assert_eq!(removed.content, "first");
1364 assert_eq!(region.entry_count(), 1);
1365 assert_eq!(region.current_tokens, 20);
1366 }
1367
1368 #[test]
1369 fn test_remove_oldest_returns_none_when_empty() {
1370 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1371 assert!(region.remove_oldest().is_none());
1372 }
1373
1374 #[test]
1375 fn test_needs_compaction_true_when_over_threshold() {
1376 let mut region = Region::new(
1377 "impl".to_string(),
1378 RegionKind::Compacting {
1379 threshold_tokens: 10,
1380 },
1381 1000,
1382 );
1383 region.add_entry("x".to_string(), 20).unwrap();
1384 assert!(region.needs_compaction());
1385 }
1386
1387 #[test]
1388 fn test_needs_compaction_false_when_under_threshold() {
1389 let mut region = Region::new(
1390 "impl".to_string(),
1391 RegionKind::Compacting {
1392 threshold_tokens: 100,
1393 },
1394 1000,
1395 );
1396 region.add_entry("x".to_string(), 20).unwrap();
1397 assert!(!region.needs_compaction());
1398 }
1399
1400 #[test]
1401 fn test_needs_compaction_false_for_non_compacting_kind() {
1402 let region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
1403 assert!(!region.needs_compaction());
1404 }
1405
1406 #[test]
1409 fn test_region_schema_with_custom_script() {
1410 let schema = RegionSchema::new(ContentFormat::Custom {
1411 format_name: "special".to_string(),
1412 })
1413 .with_custom_script("validate_special()".to_string());
1414 assert_eq!(schema.custom_script.as_deref(), Some("validate_special()"));
1415 }
1416
1417 #[test]
1420 fn test_validate_json_valid() {
1421 let schema = RegionSchema::new(ContentFormat::Json);
1422 assert!(schema.validate("{\"a\": 1}").is_ok());
1423 }
1424
1425 #[test]
1426 fn test_validate_json_invalid() {
1427 let schema = RegionSchema::new(ContentFormat::Json);
1428 let err = schema.validate("not json").unwrap_err();
1429 assert!(err.to_string().starts_with("Region validation failed:"));
1430 }
1431
1432 #[test]
1433 fn test_validate_mermaid_valid() {
1434 let schema = RegionSchema::new(ContentFormat::Mermaid);
1435 assert!(schema.validate("graph TD\nA-->B").is_ok());
1436 }
1437
1438 #[test]
1439 fn test_validate_mermaid_all_recognized_diagram_types() {
1440 let schema = RegionSchema::new(ContentFormat::Mermaid);
1441 for kind in [
1442 "graph",
1443 "sequenceDiagram",
1444 "classDiagram",
1445 "stateDiagram",
1446 "erDiagram",
1447 "journey",
1448 "gantt",
1449 "pie",
1450 "flowchart",
1451 ] {
1452 assert!(schema.validate(&format!("{} content", kind)).is_ok());
1453 }
1454 }
1455
1456 #[test]
1457 fn test_validate_mermaid_invalid() {
1458 let schema = RegionSchema::new(ContentFormat::Mermaid);
1459 let err = schema.validate("just some text").unwrap_err();
1460 assert!(err.to_string().starts_with("Region validation failed:"));
1461 }
1462
1463 #[test]
1464 fn test_validate_code_non_empty_is_ok() {
1465 let schema = RegionSchema::new(ContentFormat::Code {
1466 language: "rust".to_string(),
1467 });
1468 assert!(schema.validate("fn main() {}").is_ok());
1469 }
1470
1471 #[test]
1472 fn test_validate_code_empty_is_error() {
1473 let schema = RegionSchema::new(ContentFormat::Code {
1474 language: "rust".to_string(),
1475 });
1476 let err = schema.validate(" ").unwrap_err();
1477 assert!(err.to_string().starts_with("Region validation failed:"));
1478 }
1479
1480 #[test]
1481 fn test_validate_markdown_non_empty_is_ok() {
1482 let schema = RegionSchema::new(ContentFormat::Markdown);
1483 assert!(schema.validate("# Heading").is_ok());
1484 }
1485
1486 #[test]
1487 fn test_validate_markdown_empty_is_error() {
1488 let schema = RegionSchema::new(ContentFormat::Markdown);
1489 let err = schema.validate("").unwrap_err();
1490 assert!(err.to_string().starts_with("Region validation failed:"));
1491 }
1492
1493 #[test]
1494 fn test_validate_text_has_no_restrictions() {
1495 let schema = RegionSchema::new(ContentFormat::Text);
1496 assert!(schema.validate("").is_ok());
1497 assert!(schema.validate("anything at all").is_ok());
1498 }
1499
1500 #[test]
1501 fn test_validate_custom_has_no_restrictions_here() {
1502 let schema = RegionSchema::new(ContentFormat::Custom {
1503 format_name: "special".to_string(),
1504 });
1505 assert!(schema.validate("").is_ok());
1508 assert!(schema.validate("whatever").is_ok());
1509 }
1510
1511 #[test]
1514 fn test_region_schema_clone_preserves_fields() {
1515 let schema = RegionSchema::new(ContentFormat::Text).with_custom_script("s".to_string());
1516 let cloned = schema.clone();
1517 assert_eq!(cloned.custom_script.as_deref(), Some("s"));
1518 assert_eq!(cloned.format, ContentFormat::Text);
1519 }
1520
1521 #[test]
1524 fn test_region_with_taint_tracking() {
1525 let region =
1526 Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
1527 assert!(region.taint.is_some());
1528 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
1529 }
1530
1531 #[test]
1532 fn test_region_without_taint_tracking() {
1533 let region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
1534 assert!(region.taint.is_none());
1535 assert_eq!(region.taint_level(), None);
1536 }
1537
1538 #[test]
1539 fn test_enable_taint_tracking() {
1540 let mut region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
1541 assert!(region.taint.is_none());
1542 region.enable_taint_tracking();
1543 assert!(region.taint.is_some());
1544 region.enable_taint_tracking();
1546 assert!(region.taint.is_some());
1547 }
1548
1549 #[test]
1550 fn test_add_tainted_entry() {
1551 let mut region =
1552 Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
1553 region
1554 .add_tainted_entry(
1555 "secret data".to_string(),
1556 10,
1557 crate::taint::TaintLevel::Private,
1558 )
1559 .unwrap();
1560 assert_eq!(
1561 region.taint_level(),
1562 Some(crate::taint::TaintLevel::Private)
1563 );
1564 assert_eq!(region.entry_count(), 1);
1565 }
1566
1567 #[test]
1568 fn test_add_tainted_entry_validates_schema() {
1569 let mut region = Region::new("test".to_string(), RegionKind::Temporary, 1000)
1570 .with_taint_tracking()
1571 .with_schema(RegionSchema::new(ContentFormat::Json));
1572 let result = region.add_tainted_entry(
1573 "not json".to_string(),
1574 10,
1575 crate::taint::TaintLevel::Internal,
1576 );
1577 assert!(result.is_err());
1578 assert_eq!(region.entry_count(), 0);
1579 }
1580
1581 #[test]
1582 fn test_add_tainted_entry_checks_budget() {
1583 let mut region =
1584 Region::new("test".to_string(), RegionKind::Temporary, 10).with_taint_tracking();
1585 let result = region.add_tainted_entry(
1586 "too much".to_string(),
1587 20,
1588 crate::taint::TaintLevel::Internal,
1589 );
1590 assert!(result.is_err());
1591 }
1592
1593 #[test]
1594 fn test_add_entry_tracks_taint_as_public() {
1595 let mut region =
1596 Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
1597 region.add_entry("public data".to_string(), 10).unwrap();
1598 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
1599 }
1600
1601 #[test]
1602 fn test_taint_recovery_on_remove_oldest() {
1603 let mut region =
1604 Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
1605 region
1606 .add_tainted_entry("private".to_string(), 10, crate::taint::TaintLevel::Private)
1607 .unwrap();
1608 region
1609 .add_tainted_entry("public".to_string(), 10, crate::taint::TaintLevel::Public)
1610 .unwrap();
1611 assert_eq!(
1612 region.taint_level(),
1613 Some(crate::taint::TaintLevel::Private)
1614 );
1615
1616 region.remove_oldest(); assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
1618 }
1619
1620 #[test]
1621 fn test_taint_recovery_on_clear() {
1622 let mut region =
1623 Region::new("test".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
1624 region
1625 .add_tainted_entry("private".to_string(), 10, crate::taint::TaintLevel::Private)
1626 .unwrap();
1627 region.clear();
1628 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
1629 }
1630
1631 #[test]
1632 fn test_taint_recovery_on_sliding_window_eviction() {
1633 let mut region = Region::new(
1634 "conv".to_string(),
1635 RegionKind::SlidingWindow {
1636 max_items: 2,
1637 eviction_strategy: EvictionStrategy::PerItem,
1638 },
1639 50000,
1640 )
1641 .with_taint_tracking();
1642
1643 region
1644 .add_tainted_entry("private".to_string(), 10, crate::taint::TaintLevel::Private)
1645 .unwrap();
1646 region
1647 .add_tainted_entry("public1".to_string(), 10, crate::taint::TaintLevel::Public)
1648 .unwrap();
1649 assert_eq!(
1650 region.taint_level(),
1651 Some(crate::taint::TaintLevel::Private)
1652 );
1653
1654 region
1656 .add_tainted_entry("public2".to_string(), 10, crate::taint::TaintLevel::Public)
1657 .unwrap();
1658 assert_eq!(region.entry_count(), 2);
1659 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
1660 }
1661
1662 #[test]
1663 fn test_taint_field_not_serialized_when_none() {
1664 let region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
1665 let json = serde_json::to_string(®ion).unwrap();
1666 assert!(!json.contains("taint"));
1667 }
1668
1669 #[test]
1670 fn test_taint_field_deserialized_as_none_when_missing() {
1671 let json = r#"{"name":"test","kind":"Temporary","content":[],"max_tokens":1000,"current_tokens":0,"schema":null}"#;
1672 let region: Region = serde_json::from_str(json).unwrap();
1673 assert!(region.taint.is_none());
1674 }
1675
1676 #[test]
1677 fn test_add_typed_tainted_entry() {
1678 let mut region = Region::new(
1679 "conversation".to_string(),
1680 RegionKind::SlidingWindow {
1681 max_items: 100,
1682 eviction_strategy: EvictionStrategy::PerItem,
1683 },
1684 1000,
1685 )
1686 .with_taint_tracking();
1687
1688 region
1689 .add_typed_tainted_entry(
1690 "secret data".to_string(),
1691 10,
1692 EntryKind::ToolResult {
1693 tool_call_id: "tc_1".to_string(),
1694 tool_name: "calendar".to_string(),
1695 is_error: false,
1696 },
1697 crate::taint::TaintLevel::Private,
1698 )
1699 .unwrap();
1700
1701 assert_eq!(region.content.len(), 1);
1702 assert_eq!(
1703 region.content[0].kind,
1704 EntryKind::ToolResult {
1705 tool_call_id: "tc_1".to_string(),
1706 tool_name: "calendar".to_string(),
1707 is_error: false,
1708 }
1709 );
1710 assert_eq!(
1711 region.taint_level(),
1712 Some(crate::taint::TaintLevel::Private)
1713 );
1714 }
1715
1716 #[test]
1720 fn serialized_tool_call_round_trips_thought_signature_and_reads_old_json() {
1721 let with = SerializedToolCall {
1722 id: "c1".into(),
1723 name: "shell".into(),
1724 arguments: serde_json::json!({"command": "ls"}),
1725 thought_signature: Some("sig".into()),
1726 };
1727 let json = serde_json::to_string(&with).unwrap();
1728 let back: SerializedToolCall = serde_json::from_str(&json).unwrap();
1729 assert_eq!(back.thought_signature.as_deref(), Some("sig"));
1730
1731 let old = r#"{"id":"c2","name":"shell","arguments":{}}"#;
1733 let back: SerializedToolCall = serde_json::from_str(old).unwrap();
1734 assert_eq!(back.thought_signature, None);
1735
1736 let without = SerializedToolCall {
1739 id: "c3".into(),
1740 name: "shell".into(),
1741 arguments: serde_json::json!({}),
1742 thought_signature: None,
1743 };
1744 assert!(
1745 !serde_json::to_string(&without)
1746 .unwrap()
1747 .contains("thought_signature")
1748 );
1749 }
1750
1751 #[test]
1752 fn test_add_typed_tainted_entry_checks_budget() {
1753 let mut region = Region::new(
1754 "conversation".to_string(),
1755 RegionKind::SlidingWindow {
1756 max_items: 100,
1757 eviction_strategy: EvictionStrategy::PerItem,
1758 },
1759 5,
1760 )
1761 .with_taint_tracking();
1762
1763 let result = region.add_typed_tainted_entry(
1764 "too large".to_string(),
1765 100,
1766 EntryKind::ToolResult {
1767 tool_call_id: "tc_1".to_string(),
1768 tool_name: "tool".to_string(),
1769 is_error: false,
1770 },
1771 crate::taint::TaintLevel::Internal,
1772 );
1773 assert!(result.is_err());
1774 }
1775
1776 #[test]
1777 fn test_add_typed_tainted_entry_validates_schema() {
1778 let mut region = Region::new("test".to_string(), RegionKind::Pinned, 1000)
1779 .with_taint_tracking()
1780 .with_schema(RegionSchema::new(ContentFormat::Json));
1781
1782 let result = region.add_typed_tainted_entry(
1784 "not json".to_string(),
1785 5,
1786 EntryKind::Text,
1787 crate::taint::TaintLevel::Public,
1788 );
1789 assert!(result.is_err());
1790 }
1791
1792 #[test]
1793 fn test_add_typed_tainted_entry_without_taint_tracking() {
1794 let mut region = Region::new(
1797 "conversation".to_string(),
1798 RegionKind::SlidingWindow {
1799 max_items: 100,
1800 eviction_strategy: EvictionStrategy::PerItem,
1801 },
1802 1000,
1803 );
1804 region
1807 .add_typed_tainted_entry(
1808 "data".to_string(),
1809 10,
1810 EntryKind::Text,
1811 crate::taint::TaintLevel::Private,
1812 )
1813 .unwrap();
1814
1815 assert_eq!(region.content.len(), 1);
1816 assert_eq!(region.taint_level(), None); }
1818
1819 #[test]
1822 fn test_turn_group_size_at_assistant_with_tool_results() {
1823 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
1824 region
1825 .add_typed_entry(
1826 "assistant response".to_string(),
1827 10,
1828 EntryKind::AssistantTurn {
1829 tool_calls: vec![
1830 SerializedToolCall {
1831 id: "tc_1".to_string(),
1832 name: "read_file".to_string(),
1833 arguments: serde_json::json!({}),
1834 thought_signature: None,
1835 },
1836 SerializedToolCall {
1837 id: "tc_2".to_string(),
1838 name: "write_file".to_string(),
1839 arguments: serde_json::json!({}),
1840 thought_signature: None,
1841 },
1842 ],
1843 },
1844 )
1845 .unwrap();
1846 region
1847 .add_typed_entry(
1848 "result 1".to_string(),
1849 5,
1850 EntryKind::ToolResult {
1851 tool_call_id: "tc_1".to_string(),
1852 tool_name: "read_file".to_string(),
1853 is_error: false,
1854 },
1855 )
1856 .unwrap();
1857 region
1858 .add_typed_entry(
1859 "result 2".to_string(),
1860 5,
1861 EntryKind::ToolResult {
1862 tool_call_id: "tc_2".to_string(),
1863 tool_name: "write_file".to_string(),
1864 is_error: false,
1865 },
1866 )
1867 .unwrap();
1868
1869 assert_eq!(region.turn_group_size_at(0), 3);
1870 }
1871
1872 #[test]
1873 fn test_turn_group_size_at_assistant_at_end() {
1874 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
1875 region
1876 .add_typed_entry(
1877 "assistant with no tools".to_string(),
1878 10,
1879 EntryKind::AssistantTurn { tool_calls: vec![] },
1880 )
1881 .unwrap();
1882
1883 assert_eq!(region.turn_group_size_at(0), 1);
1884 }
1885
1886 #[test]
1887 fn test_turn_group_size_at_out_of_bounds() {
1888 let region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
1889 assert_eq!(region.turn_group_size_at(0), 0);
1890 assert_eq!(region.turn_group_size_at(99), 0);
1891 }
1892
1893 #[test]
1894 fn test_turn_group_size_at_non_assistant_entries() {
1895 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
1896 region
1897 .add_typed_entry("hello".to_string(), 5, EntryKind::Text)
1898 .unwrap();
1899 region
1900 .add_typed_entry("hi".to_string(), 5, EntryKind::UserMessage)
1901 .unwrap();
1902 region
1903 .add_typed_entry(
1904 "orphan result".to_string(),
1905 5,
1906 EntryKind::ToolResult {
1907 tool_call_id: "tc_x".to_string(),
1908 tool_name: "tool".to_string(),
1909 is_error: false,
1910 },
1911 )
1912 .unwrap();
1913
1914 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); }
1918
1919 #[test]
1922 fn test_remove_oldest_evicts_entire_turn_group() {
1923 let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
1924 region
1926 .add_typed_entry(
1927 "assistant".to_string(),
1928 100,
1929 EntryKind::AssistantTurn {
1930 tool_calls: vec![
1931 SerializedToolCall {
1932 id: "tc_1".to_string(),
1933 name: "read_file".to_string(),
1934 arguments: serde_json::json!({}),
1935 thought_signature: None,
1936 },
1937 SerializedToolCall {
1938 id: "tc_2".to_string(),
1939 name: "list_dir".to_string(),
1940 arguments: serde_json::json!({}),
1941 thought_signature: None,
1942 },
1943 ],
1944 },
1945 )
1946 .unwrap();
1947 region
1948 .add_typed_entry(
1949 "result 1".to_string(),
1950 30,
1951 EntryKind::ToolResult {
1952 tool_call_id: "tc_1".to_string(),
1953 tool_name: "read_file".to_string(),
1954 is_error: false,
1955 },
1956 )
1957 .unwrap();
1958 region
1959 .add_typed_entry(
1960 "result 2".to_string(),
1961 20,
1962 EntryKind::ToolResult {
1963 tool_call_id: "tc_2".to_string(),
1964 tool_name: "list_dir".to_string(),
1965 is_error: false,
1966 },
1967 )
1968 .unwrap();
1969 region
1971 .add_typed_entry("user msg".to_string(), 10, EntryKind::UserMessage)
1972 .unwrap();
1973
1974 assert_eq!(region.entry_count(), 4);
1975 assert_eq!(region.current_tokens, 160);
1976
1977 let removed = region.remove_oldest().unwrap();
1978 assert_eq!(removed.content, "assistant");
1981 assert_eq!(removed.tokens, 100 + 30 + 20); assert_eq!(region.entry_count(), 1);
1984 assert_eq!(region.content[0].content, "user msg");
1985 assert_eq!(region.current_tokens, 10);
1986 }
1987
1988 #[test]
1991 fn test_remove_oldest_turn_group_calls_taint_remove_for_each_entry() {
1992 let mut region =
1993 Region::new("conv".to_string(), RegionKind::Temporary, 50000).with_taint_tracking();
1994
1995 region
1997 .add_typed_tainted_entry(
1998 "assistant".to_string(),
1999 10,
2000 EntryKind::AssistantTurn {
2001 tool_calls: vec![SerializedToolCall {
2002 id: "tc_1".to_string(),
2003 name: "tool".to_string(),
2004 arguments: serde_json::json!({}),
2005 thought_signature: None,
2006 }],
2007 },
2008 crate::taint::TaintLevel::Private,
2009 )
2010 .unwrap();
2011 region
2012 .add_typed_tainted_entry(
2013 "result".to_string(),
2014 5,
2015 EntryKind::ToolResult {
2016 tool_call_id: "tc_1".to_string(),
2017 tool_name: "tool".to_string(),
2018 is_error: false,
2019 },
2020 crate::taint::TaintLevel::Internal,
2021 )
2022 .unwrap();
2023 region
2024 .add_tainted_entry(
2025 "public stuff".to_string(),
2026 5,
2027 crate::taint::TaintLevel::Public,
2028 )
2029 .unwrap();
2030
2031 assert_eq!(
2032 region.taint_level(),
2033 Some(crate::taint::TaintLevel::Private)
2034 );
2035 assert_eq!(region.taint.as_ref().unwrap().entry_count(), 3);
2036
2037 let removed = region.remove_oldest().unwrap();
2039 assert_eq!(removed.content, "assistant");
2040 assert_eq!(region.entry_count(), 1);
2041 assert_eq!(region.taint.as_ref().unwrap().entry_count(), 1);
2044 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2045 }
2046
2047 #[test]
2050 fn test_sliding_window_evicts_entire_turn_group() {
2051 let mut region = Region::new(
2052 "conv".to_string(),
2053 RegionKind::SlidingWindow {
2054 max_items: 3,
2055 eviction_strategy: EvictionStrategy::PerItem,
2056 },
2057 50000,
2058 );
2059
2060 region
2062 .add_typed_entry(
2063 "assistant".to_string(),
2064 10,
2065 EntryKind::AssistantTurn {
2066 tool_calls: vec![
2067 SerializedToolCall {
2068 id: "tc_1".to_string(),
2069 name: "t1".to_string(),
2070 arguments: serde_json::json!({}),
2071 thought_signature: None,
2072 },
2073 SerializedToolCall {
2074 id: "tc_2".to_string(),
2075 name: "t2".to_string(),
2076 arguments: serde_json::json!({}),
2077 thought_signature: None,
2078 },
2079 ],
2080 },
2081 )
2082 .unwrap();
2083 region
2084 .add_typed_entry(
2085 "r1".to_string(),
2086 5,
2087 EntryKind::ToolResult {
2088 tool_call_id: "tc_1".to_string(),
2089 tool_name: "t1".to_string(),
2090 is_error: false,
2091 },
2092 )
2093 .unwrap();
2094 region
2095 .add_typed_entry(
2096 "r2".to_string(),
2097 5,
2098 EntryKind::ToolResult {
2099 tool_call_id: "tc_2".to_string(),
2100 tool_name: "t2".to_string(),
2101 is_error: false,
2102 },
2103 )
2104 .unwrap();
2105
2106 assert_eq!(region.entry_count(), 3);
2107
2108 region
2111 .add_typed_entry("user msg".to_string(), 15, EntryKind::UserMessage)
2112 .unwrap();
2113
2114 assert_eq!(region.entry_count(), 1);
2116 assert_eq!(region.content[0].content, "user msg");
2117 assert_eq!(region.current_tokens, 15);
2118 }
2119
2120 #[test]
2123 fn test_add_entry_with_metadata_tracks_taint_as_public() {
2124 let mut region =
2125 Region::new("data".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
2126
2127 region
2128 .add_entry_with_metadata("content".to_string(), 10, serde_json::json!({"key": "val"}))
2129 .unwrap();
2130
2131 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2132 assert_eq!(region.taint.as_ref().unwrap().entry_count(), 1);
2133 assert_eq!(
2134 region.taint.as_ref().unwrap().entry_taint(0),
2135 Some(crate::taint::TaintLevel::Public)
2136 );
2137 }
2138
2139 #[test]
2142 fn test_add_typed_entry_tracks_taint_as_public() {
2143 let mut region =
2144 Region::new("conv".to_string(), RegionKind::Temporary, 1000).with_taint_tracking();
2145
2146 region
2147 .add_typed_entry(
2148 "assistant response".to_string(),
2149 10,
2150 EntryKind::AssistantTurn { tool_calls: vec![] },
2151 )
2152 .unwrap();
2153
2154 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2155 assert_eq!(region.taint.as_ref().unwrap().entry_count(), 1);
2156 assert_eq!(
2157 region.taint.as_ref().unwrap().entry_taint(0),
2158 Some(crate::taint::TaintLevel::Public)
2159 );
2160 }
2161
2162 #[test]
2165 fn test_per_item_strategy_evicts_one_at_a_time() {
2166 let mut region = Region::new(
2167 "conv".to_string(),
2168 RegionKind::SlidingWindow {
2169 max_items: 3,
2170 eviction_strategy: EvictionStrategy::PerItem,
2171 },
2172 50000,
2173 );
2174 for i in 0..5 {
2175 region.add_entry(format!("msg{}", i), 10).unwrap();
2176 }
2177 assert_eq!(region.entry_count(), 3);
2178 assert_eq!(region.content[0].content, "msg2");
2179 assert_eq!(region.content[1].content, "msg3");
2180 assert_eq!(region.content[2].content, "msg4");
2181 }
2182
2183 #[test]
2184 fn test_bulk_eviction_triggers_on_overflow() {
2185 let mut region = Region::new(
2186 "conv".to_string(),
2187 RegionKind::SlidingWindow {
2188 max_items: 5,
2189 eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2190 },
2191 50000,
2192 );
2193 for i in 0..8 {
2196 region.add_entry(format!("msg{}", i), 10).unwrap();
2197 }
2198 assert_eq!(region.entry_count(), 8);
2199
2200 region.add_entry("msg8".to_string(), 10).unwrap();
2202 assert_eq!(region.entry_count(), 5);
2203 assert_eq!(region.content[0].content, "msg4");
2204 }
2205
2206 #[test]
2207 fn test_bulk_eviction_respects_turn_groups() {
2208 let mut region = Region::new(
2209 "conv".to_string(),
2210 RegionKind::SlidingWindow {
2211 max_items: 3,
2212 eviction_strategy: EvictionStrategy::Bulk { overflow: 2 },
2213 },
2214 50000,
2215 );
2216 region
2218 .add_typed_entry(
2219 "assistant".to_string(),
2220 10,
2221 EntryKind::AssistantTurn {
2222 tool_calls: vec![SerializedToolCall {
2223 id: "tc1".to_string(),
2224 name: "tool".to_string(),
2225 arguments: serde_json::json!({}),
2226 thought_signature: None,
2227 }],
2228 },
2229 )
2230 .unwrap();
2231 region
2232 .add_typed_entry(
2233 "result".to_string(),
2234 5,
2235 EntryKind::ToolResult {
2236 tool_call_id: "tc1".to_string(),
2237 tool_name: "tool".to_string(),
2238 is_error: false,
2239 },
2240 )
2241 .unwrap();
2242 region.add_entry("msg2".to_string(), 10).unwrap();
2244 region.add_entry("msg3".to_string(), 10).unwrap();
2245 region.add_entry("msg4".to_string(), 10).unwrap();
2246 assert_eq!(region.entry_count(), 5);
2248
2249 region.add_entry("msg5".to_string(), 10).unwrap();
2251 assert_eq!(region.entry_count(), 3);
2254 assert_eq!(region.content[0].content, "msg3");
2255 }
2256
2257 #[test]
2258 fn test_bulk_eviction_under_overflow_no_eviction() {
2259 let mut region = Region::new(
2260 "conv".to_string(),
2261 RegionKind::SlidingWindow {
2262 max_items: 5,
2263 eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2264 },
2265 50000,
2266 );
2267 for i in 0..7 {
2269 region.add_entry(format!("msg{}", i), 10).unwrap();
2270 }
2271 assert_eq!(region.entry_count(), 7);
2273 }
2274
2275 #[test]
2276 fn test_compact_sets_needs_message_compaction_flag() {
2277 let mut region = Region::new(
2278 "conv".to_string(),
2279 RegionKind::SlidingWindow {
2280 max_items: 5,
2281 eviction_strategy: EvictionStrategy::Compact { compact_count: 3 },
2282 },
2283 50000,
2284 );
2285 assert!(!region.needs_message_compaction);
2286
2287 for i in 0..9 {
2289 region.add_entry(format!("msg{}", i), 10).unwrap();
2290 }
2291 assert!(region.needs_message_compaction);
2292 assert_eq!(region.entry_count(), 9);
2294 }
2295
2296 #[test]
2297 fn test_compact_fallback_to_bulk_eviction() {
2298 let mut region = Region::new(
2299 "conv".to_string(),
2300 RegionKind::SlidingWindow {
2301 max_items: 5,
2302 eviction_strategy: EvictionStrategy::Compact { compact_count: 3 },
2303 },
2304 50000,
2305 );
2306 for i in 0..12 {
2309 region.add_entry(format!("msg{}", i), 10).unwrap();
2310 }
2311 assert_eq!(region.entry_count(), 5);
2313 assert_eq!(region.content[0].content, "msg7");
2314 assert!(!region.needs_message_compaction);
2316 }
2317
2318 #[test]
2319 fn test_eviction_strategy_default_is_per_item() {
2320 assert_eq!(EvictionStrategy::default(), EvictionStrategy::PerItem);
2321 }
2322
2323 #[test]
2324 fn test_remove_entries_by_prefix() {
2325 let mut region = Region::new("system".to_string(), RegionKind::Pinned, 50000);
2326 region
2327 .add_entry("[Stage instructions: Be terse.]".to_string(), 10)
2328 .unwrap();
2329 region
2330 .add_entry("Core identity block".to_string(), 20)
2331 .unwrap();
2332 region
2333 .add_entry("[Stage instructions: Be verbose.]".to_string(), 15)
2334 .unwrap();
2335
2336 assert_eq!(region.entry_count(), 3);
2337 region.remove_entries_by_prefix("[Stage instructions:");
2338 assert_eq!(region.entry_count(), 1);
2339 assert_eq!(region.content[0].content, "Core identity block");
2340 assert_eq!(region.current_tokens, 20);
2341 }
2342
2343 #[test]
2344 fn test_remove_entries_by_prefix_with_taint_tracking() {
2345 let mut region =
2346 Region::new("system".to_string(), RegionKind::Pinned, 50000).with_taint_tracking();
2347 region
2348 .add_tainted_entry(
2349 "[Stage instructions: Be terse.]".to_string(),
2350 10,
2351 crate::taint::TaintLevel::Private,
2352 )
2353 .unwrap();
2354 region
2355 .add_tainted_entry(
2356 "Core identity block".to_string(),
2357 20,
2358 crate::taint::TaintLevel::Public,
2359 )
2360 .unwrap();
2361 region
2362 .add_tainted_entry(
2363 "[Stage instructions: Be verbose.]".to_string(),
2364 15,
2365 crate::taint::TaintLevel::Internal,
2366 )
2367 .unwrap();
2368
2369 assert_eq!(region.entry_count(), 3);
2370 assert_eq!(
2371 region.taint_level(),
2372 Some(crate::taint::TaintLevel::Private)
2373 );
2374
2375 region.remove_entries_by_prefix("[Stage instructions:");
2376 assert_eq!(region.entry_count(), 1);
2377 assert_eq!(region.content[0].content, "Core identity block");
2378 assert_eq!(region.current_tokens, 20);
2379 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2381 assert_eq!(region.taint.as_ref().unwrap().entry_count(), 1);
2382 }
2383
2384 #[test]
2385 fn test_compact_below_threshold_no_flag() {
2386 let mut region = Region::new(
2388 "conv".to_string(),
2389 RegionKind::SlidingWindow {
2390 max_items: 5,
2391 eviction_strategy: EvictionStrategy::Compact { compact_count: 3 },
2392 },
2393 50000,
2394 );
2395 for i in 0..8 {
2396 region.add_entry(format!("msg{}", i), 10).unwrap();
2397 }
2398 assert!(!region.needs_message_compaction);
2400 assert_eq!(region.entry_count(), 8);
2401 }
2402
2403 #[test]
2404 fn test_bulk_eviction_with_taint_tracking() {
2405 let mut region = Region::new(
2406 "conv".to_string(),
2407 RegionKind::SlidingWindow {
2408 max_items: 3,
2409 eviction_strategy: EvictionStrategy::Bulk { overflow: 2 },
2410 },
2411 50000,
2412 )
2413 .with_taint_tracking();
2414
2415 region
2417 .add_tainted_entry("private".to_string(), 10, crate::taint::TaintLevel::Private)
2418 .unwrap();
2419 for i in 1..5 {
2420 region
2421 .add_tainted_entry(format!("pub{}", i), 10, crate::taint::TaintLevel::Public)
2422 .unwrap();
2423 }
2424 assert_eq!(region.entry_count(), 5);
2425
2426 region
2428 .add_tainted_entry("pub5".to_string(), 10, crate::taint::TaintLevel::Public)
2429 .unwrap();
2430 assert_eq!(region.entry_count(), 3);
2431 assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2433 }
2434
2435 #[test]
2436 fn test_eviction_strategy_serde_roundtrip() {
2437 let bulk = EvictionStrategy::Bulk { overflow: 5 };
2438 let json = serde_json::to_string(&bulk).unwrap();
2439 let parsed: EvictionStrategy = serde_json::from_str(&json).unwrap();
2440 assert_eq!(parsed, bulk);
2441
2442 let compact = EvictionStrategy::Compact { compact_count: 10 };
2443 let json = serde_json::to_string(&compact).unwrap();
2444 let parsed: EvictionStrategy = serde_json::from_str(&json).unwrap();
2445 assert_eq!(parsed, compact);
2446
2447 let per_item = EvictionStrategy::PerItem;
2448 let json = serde_json::to_string(&per_item).unwrap();
2449 let parsed: EvictionStrategy = serde_json::from_str(&json).unwrap();
2450 assert_eq!(parsed, per_item);
2451 }
2452
2453 #[test]
2454 fn test_sliding_window_kind_equality_with_eviction_strategy() {
2455 assert_eq!(
2456 RegionKind::SlidingWindow {
2457 max_items: 10,
2458 eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2459 },
2460 RegionKind::SlidingWindow {
2461 max_items: 10,
2462 eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2463 }
2464 );
2465 assert_ne!(
2466 RegionKind::SlidingWindow {
2467 max_items: 10,
2468 eviction_strategy: EvictionStrategy::PerItem,
2469 },
2470 RegionKind::SlidingWindow {
2471 max_items: 10,
2472 eviction_strategy: EvictionStrategy::Bulk { overflow: 3 },
2473 }
2474 );
2475 }
2476
2477 #[test]
2478 fn test_needs_message_compaction_default_false() {
2479 let region = Region::new("conv".to_string(), RegionKind::Temporary, 1000);
2480 assert!(!region.needs_message_compaction);
2481 }
2482
2483 #[test]
2486 fn test_add_typed_entry_validates_schema() {
2487 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000)
2488 .with_schema(RegionSchema::new(ContentFormat::Json));
2489 let result = region.add_typed_entry("not json".to_string(), 5, EntryKind::Text);
2490 assert!(result.is_err());
2491 assert_eq!(region.entry_count(), 0);
2492 }
2493
2494 #[test]
2495 fn test_add_typed_entry_checks_budget() {
2496 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 10);
2497 let result = region.add_typed_entry("too big".to_string(), 20, EntryKind::UserMessage);
2498 assert!(result.is_err());
2499 assert_eq!(region.entry_count(), 0);
2500 }
2501
2502 #[test]
2503 fn test_add_tainted_entry_without_taint_tracking() {
2504 let mut region = Region::new("data".to_string(), RegionKind::Temporary, 1000);
2506 region
2507 .add_tainted_entry("data".to_string(), 10, crate::taint::TaintLevel::Private)
2508 .unwrap();
2509 assert_eq!(region.entry_count(), 1);
2510 assert_eq!(region.taint_level(), None);
2511 }
2512
2513 #[test]
2514 fn test_remove_entries_by_prefix_no_match() {
2515 let mut region = Region::new("system".to_string(), RegionKind::Pinned, 50000);
2516 region.add_entry("Keep this".to_string(), 10).unwrap();
2517 region.add_entry("And this".to_string(), 20).unwrap();
2518 region.remove_entries_by_prefix("[Stage instructions:");
2519 assert_eq!(region.entry_count(), 2);
2520 assert_eq!(region.current_tokens, 30);
2521 }
2522
2523 #[test]
2526 fn test_hashmap_region_upsert_and_get() {
2527 let mut region = Region::new(
2528 "files".to_string(),
2529 RegionKind::HashMap { max_entries: None },
2530 10000,
2531 );
2532 region
2533 .upsert_by_key("src/main.rs", "fn main() {}".to_string(), 10)
2534 .unwrap();
2535 region
2536 .upsert_by_key("src/lib.rs", "pub mod foo;".to_string(), 8)
2537 .unwrap();
2538
2539 assert_eq!(region.entry_count(), 2);
2540 assert_eq!(region.current_tokens, 18);
2541
2542 let entry = region.get_by_key("src/main.rs").unwrap();
2543 assert_eq!(entry.content, "fn main() {}");
2544 assert_eq!(entry.key.as_deref(), Some("src/main.rs"));
2545 }
2546
2547 #[test]
2548 fn test_hashmap_region_upsert_replaces_existing() {
2549 let mut region = Region::new(
2550 "files".to_string(),
2551 RegionKind::HashMap { max_entries: None },
2552 10000,
2553 );
2554 region
2555 .upsert_by_key("file.rs", "version 1".to_string(), 10)
2556 .unwrap();
2557 assert_eq!(region.current_tokens, 10);
2558
2559 region
2560 .upsert_by_key("file.rs", "version 2".to_string(), 15)
2561 .unwrap();
2562 assert_eq!(region.entry_count(), 1);
2563 assert_eq!(region.current_tokens, 15);
2564 assert_eq!(region.get_by_key("file.rs").unwrap().content, "version 2");
2565 }
2566
2567 #[test]
2568 fn test_hashmap_region_remove_by_key() {
2569 let mut region = Region::new(
2570 "files".to_string(),
2571 RegionKind::HashMap { max_entries: None },
2572 10000,
2573 );
2574 region.upsert_by_key("a.rs", "aaa".to_string(), 10).unwrap();
2575 region.upsert_by_key("b.rs", "bbb".to_string(), 20).unwrap();
2576
2577 assert!(region.remove_by_key("a.rs"));
2578 assert_eq!(region.entry_count(), 1);
2579 assert_eq!(region.current_tokens, 20);
2580 assert!(region.get_by_key("a.rs").is_none());
2581 assert!(!region.remove_by_key("nonexistent"));
2582 }
2583
2584 #[test]
2585 fn test_hashmap_region_keys() {
2586 let mut region = Region::new(
2587 "files".to_string(),
2588 RegionKind::HashMap { max_entries: None },
2589 10000,
2590 );
2591 region.upsert_by_key("x.rs", "x".to_string(), 5).unwrap();
2592 region.upsert_by_key("y.rs", "y".to_string(), 5).unwrap();
2593
2594 let keys = region.keys();
2595 assert_eq!(keys.len(), 2);
2596 assert!(keys.contains(&"x.rs"));
2597 assert!(keys.contains(&"y.rs"));
2598 }
2599
2600 #[test]
2601 fn test_hashmap_region_lru_eviction_on_max_tokens() {
2602 let mut region = Region::new(
2603 "files".to_string(),
2604 RegionKind::HashMap { max_entries: None },
2605 30, );
2607 region.upsert_by_key("a.rs", "aaa".to_string(), 10).unwrap();
2608 region.content[0].timestamp -= 100;
2610 region.upsert_by_key("b.rs", "bbb".to_string(), 10).unwrap();
2611 region.upsert_by_key("c.rs", "ccc".to_string(), 10).unwrap();
2612 assert_eq!(region.entry_count(), 3);
2613 assert_eq!(region.current_tokens, 30);
2614
2615 region.upsert_by_key("d.rs", "ddd".to_string(), 10).unwrap();
2617 assert_eq!(region.entry_count(), 3);
2618 assert!(region.get_by_key("a.rs").is_none());
2619 assert!(region.get_by_key("d.rs").is_some());
2620 }
2621
2622 #[test]
2623 fn test_hashmap_region_max_entries_eviction() {
2624 let mut region = Region::new(
2625 "files".to_string(),
2626 RegionKind::HashMap {
2627 max_entries: Some(2),
2628 },
2629 10000,
2630 );
2631 region.upsert_by_key("a.rs", "aaa".to_string(), 10).unwrap();
2632 region.content[0].timestamp -= 100; region.upsert_by_key("b.rs", "bbb".to_string(), 10).unwrap();
2634 assert_eq!(region.entry_count(), 2);
2635
2636 region.upsert_by_key("c.rs", "ccc".to_string(), 10).unwrap();
2638 assert_eq!(region.entry_count(), 2);
2639 assert!(region.get_by_key("a.rs").is_none());
2640 assert!(region.get_by_key("c.rs").is_some());
2641 }
2642
2643 #[test]
2644 fn test_hashmap_region_upsert_too_large_for_budget() {
2645 let mut region = Region::new(
2646 "files".to_string(),
2647 RegionKind::HashMap { max_entries: None },
2648 5, );
2650 let result = region.upsert_by_key("big.rs", "huge content".to_string(), 100);
2651 assert!(result.is_err());
2652 }
2653
2654 #[test]
2655 fn test_hashmap_region_kind_equality() {
2656 assert_eq!(
2657 RegionKind::HashMap {
2658 max_entries: Some(10)
2659 },
2660 RegionKind::HashMap {
2661 max_entries: Some(10)
2662 }
2663 );
2664 assert_ne!(
2665 RegionKind::HashMap {
2666 max_entries: Some(10)
2667 },
2668 RegionKind::HashMap {
2669 max_entries: Some(20)
2670 }
2671 );
2672 assert_ne!(
2673 RegionKind::HashMap { max_entries: None },
2674 RegionKind::Pinned
2675 );
2676 }
2677
2678 #[test]
2679 fn test_hashmap_cache_hint() {
2680 let kind = RegionKind::HashMap { max_entries: None };
2681 assert_eq!(kind.cache_hint(), crate::cache::CacheHint::UntilChanged);
2682 }
2683
2684 #[test]
2685 fn test_region_entry_key_default_none() {
2686 let mut region = Region::new("test".to_string(), RegionKind::Temporary, 1000);
2687 region.add_entry("content".to_string(), 10).unwrap();
2688 assert!(region.content[0].key.is_none());
2689 }
2690
2691 #[test]
2692 fn test_region_entry_key_serde_skip_when_none() {
2693 let entry = RegionEntry {
2694 content: "test".to_string(),
2695 tokens: 5,
2696 timestamp: 0,
2697 metadata: None,
2698 kind: EntryKind::default(),
2699 key: None,
2700 };
2701 let json = serde_json::to_string(&entry).unwrap();
2702 assert!(!json.contains("key"));
2703 }
2704
2705 #[test]
2706 fn test_region_entry_key_serde_roundtrip() {
2707 let entry = RegionEntry {
2708 content: "test".to_string(),
2709 tokens: 5,
2710 timestamp: 0,
2711 metadata: None,
2712 kind: EntryKind::default(),
2713 key: Some("mykey".to_string()),
2714 };
2715 let json = serde_json::to_string(&entry).unwrap();
2716 assert!(json.contains("mykey"));
2717 let back: RegionEntry = serde_json::from_str(&json).unwrap();
2718 assert_eq!(back.key.as_deref(), Some("mykey"));
2719 }
2720
2721 #[test]
2724 fn test_hashmap_region_creation_and_basic_properties() {
2725 let region = Region::new(
2726 "lookup".to_string(),
2727 RegionKind::HashMap {
2728 max_entries: Some(5),
2729 },
2730 2000,
2731 );
2732 assert_eq!(region.name, "lookup");
2733 assert_eq!(
2734 region.kind,
2735 RegionKind::HashMap {
2736 max_entries: Some(5)
2737 }
2738 );
2739 assert_eq!(region.max_tokens, 2000);
2740 assert_eq!(region.current_tokens, 0);
2741 assert_eq!(region.entry_count(), 0);
2742 assert!(region.content.is_empty());
2743 }
2744
2745 #[test]
2746 fn test_hashmap_upsert_insert_new_entry() {
2747 let mut region = Region::new(
2748 "store".to_string(),
2749 RegionKind::HashMap {
2750 max_entries: Some(5),
2751 },
2752 5000,
2753 );
2754 region
2755 .upsert_by_key("config.toml", "[package]\nname = \"foo\"".to_string(), 12)
2756 .unwrap();
2757
2758 assert_eq!(region.entry_count(), 1);
2759 assert_eq!(region.current_tokens, 12);
2760
2761 let entry = region.get_by_key("config.toml").unwrap();
2762 assert_eq!(entry.content, "[package]\nname = \"foo\"");
2763 assert_eq!(entry.tokens, 12);
2764 assert_eq!(entry.key.as_deref(), Some("config.toml"));
2765 }
2766
2767 #[test]
2768 fn test_hashmap_upsert_update_existing_entry() {
2769 let mut region = Region::new(
2770 "store".to_string(),
2771 RegionKind::HashMap { max_entries: None },
2772 5000,
2773 );
2774 region
2775 .upsert_by_key("readme.md", "# Old".to_string(), 20)
2776 .unwrap();
2777 assert_eq!(region.current_tokens, 20);
2778
2779 region
2780 .upsert_by_key("readme.md", "# New and improved".to_string(), 35)
2781 .unwrap();
2782 assert_eq!(region.entry_count(), 1);
2783 assert_eq!(region.current_tokens, 35);
2784
2785 let entry = region.get_by_key("readme.md").unwrap();
2786 assert_eq!(entry.content, "# New and improved");
2787 assert_eq!(entry.tokens, 35);
2788 }
2789
2790 #[test]
2791 fn test_hashmap_upsert_lru_eviction_on_max_tokens() {
2792 let mut region = Region::new(
2793 "files".to_string(),
2794 RegionKind::HashMap { max_entries: None },
2795 100, );
2797
2798 region
2800 .upsert_by_key("first.rs", "first content".to_string(), 40)
2801 .unwrap();
2802 region.content[0].timestamp -= 200; region
2805 .upsert_by_key("second.rs", "second content".to_string(), 40)
2806 .unwrap();
2807 region.content[1].timestamp -= 100; region
2810 .upsert_by_key("third.rs", "third content".to_string(), 20)
2811 .unwrap();
2812 region
2816 .upsert_by_key("fourth.rs", "fourth content".to_string(), 30)
2817 .unwrap();
2818
2819 assert!(region.get_by_key("first.rs").is_none());
2821 assert!(region.get_by_key("fourth.rs").is_some());
2822 assert!(region.current_tokens <= 100);
2824 }
2825
2826 #[test]
2827 fn test_hashmap_upsert_max_entries_enforcement() {
2828 let mut region = Region::new(
2829 "cache".to_string(),
2830 RegionKind::HashMap {
2831 max_entries: Some(2),
2832 },
2833 50000,
2834 );
2835
2836 region
2837 .upsert_by_key("alpha", "aaa".to_string(), 10)
2838 .unwrap();
2839 region.content[0].timestamp -= 200; region.upsert_by_key("beta", "bbb".to_string(), 10).unwrap();
2842 region.content[1].timestamp -= 100;
2843
2844 region
2845 .upsert_by_key("gamma", "ccc".to_string(), 10)
2846 .unwrap();
2847
2848 assert_eq!(region.entry_count(), 2);
2850 assert!(region.get_by_key("alpha").is_none());
2851 assert!(region.get_by_key("beta").is_some());
2852 assert!(region.get_by_key("gamma").is_some());
2853 }
2854
2855 #[test]
2856 fn test_hashmap_get_by_key_found_and_not_found() {
2857 let mut region = Region::new(
2858 "data".to_string(),
2859 RegionKind::HashMap { max_entries: None },
2860 5000,
2861 );
2862 region
2863 .upsert_by_key("exists", "hello".to_string(), 5)
2864 .unwrap();
2865
2866 let found = region.get_by_key("exists");
2868 assert!(found.is_some());
2869 assert_eq!(found.unwrap().content, "hello");
2870
2871 let missing = region.get_by_key("does_not_exist");
2873 assert!(missing.is_none());
2874 }
2875
2876 #[test]
2877 fn test_hashmap_remove_by_key_exists() {
2878 let mut region = Region::new(
2879 "data".to_string(),
2880 RegionKind::HashMap { max_entries: None },
2881 5000,
2882 );
2883 region
2884 .upsert_by_key("target", "remove me".to_string(), 25)
2885 .unwrap();
2886 assert_eq!(region.current_tokens, 25);
2887
2888 let removed = region.remove_by_key("target");
2889 assert!(removed);
2890 assert_eq!(region.entry_count(), 0);
2891 assert_eq!(region.current_tokens, 0);
2892 assert!(region.get_by_key("target").is_none());
2893 }
2894
2895 #[test]
2896 fn test_hashmap_remove_by_key_does_not_exist() {
2897 let mut region = Region::new(
2898 "data".to_string(),
2899 RegionKind::HashMap { max_entries: None },
2900 5000,
2901 );
2902 let removed = region.remove_by_key("ghost");
2903 assert!(!removed);
2904 }
2905
2906 #[test]
2907 fn test_hashmap_keys_empty_populated_after_removal() {
2908 let mut region = Region::new(
2909 "data".to_string(),
2910 RegionKind::HashMap { max_entries: None },
2911 5000,
2912 );
2913
2914 assert!(region.keys().is_empty());
2916
2917 region.upsert_by_key("one", "1".to_string(), 5).unwrap();
2919 region.upsert_by_key("two", "2".to_string(), 5).unwrap();
2920 region.upsert_by_key("three", "3".to_string(), 5).unwrap();
2921
2922 let keys = region.keys();
2923 assert_eq!(keys.len(), 3);
2924 assert!(keys.contains(&"one"));
2925 assert!(keys.contains(&"two"));
2926 assert!(keys.contains(&"three"));
2927
2928 region.remove_by_key("two");
2930 let keys = region.keys();
2931 assert_eq!(keys.len(), 2);
2932 assert!(keys.contains(&"one"));
2933 assert!(!keys.contains(&"two"));
2934 assert!(keys.contains(&"three"));
2935 }
2936
2937 #[test]
2938 fn test_region_entry_serialization_with_key_field() {
2939 let entry_with_key = RegionEntry {
2941 content: "some data".to_string(),
2942 tokens: 10,
2943 timestamp: 1234567890,
2944 metadata: None,
2945 kind: EntryKind::default(),
2946 key: Some("mykey".to_string()),
2947 };
2948 let json = serde_json::to_string(&entry_with_key).unwrap();
2949 let deserialized: RegionEntry = serde_json::from_str(&json).unwrap();
2950 assert_eq!(deserialized.key.as_deref(), Some("mykey"));
2951 assert_eq!(deserialized.content, "some data");
2952 assert_eq!(deserialized.tokens, 10);
2953
2954 let entry_no_key = RegionEntry {
2956 content: "no key data".to_string(),
2957 tokens: 7,
2958 timestamp: 1234567890,
2959 metadata: None,
2960 kind: EntryKind::default(),
2961 key: None,
2962 };
2963 let json = serde_json::to_string(&entry_no_key).unwrap();
2964 assert!(!json.contains("\"key\""));
2965 let deserialized: RegionEntry = serde_json::from_str(&json).unwrap();
2966 assert!(deserialized.key.is_none());
2967 assert_eq!(deserialized.content, "no key data");
2968 }
2969
2970 #[test]
2971 fn test_hashmap_partial_eq() {
2972 let a = RegionKind::HashMap {
2973 max_entries: Some(5),
2974 };
2975 let b = RegionKind::HashMap {
2976 max_entries: Some(5),
2977 };
2978 let c = RegionKind::HashMap {
2979 max_entries: Some(10),
2980 };
2981 let d = RegionKind::HashMap { max_entries: None };
2982
2983 assert_eq!(a, b);
2984 assert_ne!(a, c);
2985 assert_ne!(a, d);
2986 assert_ne!(c, d);
2987 assert_ne!(a, RegionKind::Pinned);
2988 assert_ne!(a, RegionKind::Temporary);
2989 }
2990
2991 #[test]
2992 fn test_hashmap_cache_hint_returns_until_changed() {
2993 let kind = RegionKind::HashMap { max_entries: None };
2994 assert_eq!(kind.cache_hint(), crate::cache::CacheHint::UntilChanged);
2995
2996 let kind_with_max = RegionKind::HashMap {
2997 max_entries: Some(10),
2998 };
2999 assert_eq!(
3000 kind_with_max.cache_hint(),
3001 crate::cache::CacheHint::UntilChanged
3002 );
3003 }
3004
3005 #[test]
3008 fn test_remove_by_key_recomputes_taint_when_tracking_enabled() {
3009 let mut region = Region::new(
3012 "kv".to_string(),
3013 RegionKind::HashMap { max_entries: None },
3014 10_000,
3015 )
3016 .with_taint_tracking();
3017 region
3018 .upsert_by_key("k1", "value one".to_string(), 10)
3019 .unwrap();
3020 region
3021 .upsert_by_key("k2", "value two".to_string(), 10)
3022 .unwrap();
3023
3024 assert!(region.remove_by_key("k1"));
3025 assert!(!region.remove_by_key("missing"));
3026 assert_eq!(region.entry_count(), 1);
3027 assert_eq!(region.current_tokens, 10);
3028 }
3029
3030 #[test]
3031 fn test_evict_lru_entry_runs_taint_fixup() {
3032 let mut region = Region::new(
3036 "kv".to_string(),
3037 RegionKind::HashMap {
3038 max_entries: Some(1),
3039 },
3040 10_000,
3041 )
3042 .with_taint_tracking();
3043 region
3044 .upsert_by_key("first", "aaa".to_string(), 10)
3045 .unwrap();
3046 region
3047 .upsert_by_key("second", "bbb".to_string(), 10)
3048 .unwrap();
3049
3050 assert_eq!(region.entry_count(), 1);
3052 assert!(region.get_by_key("second").is_some());
3053 assert!(region.get_by_key("first").is_none());
3054 }
3055
3056 #[test]
3057 fn test_evict_lru_entry_on_empty_region_is_noop() {
3058 let mut region = Region::new(
3062 "kv".to_string(),
3063 RegionKind::HashMap {
3064 max_entries: Some(4),
3065 },
3066 1000,
3067 );
3068 assert_eq!(region.entry_count(), 0);
3069 region.evict_lru_entry();
3070 assert_eq!(region.entry_count(), 0);
3071 assert_eq!(region.current_tokens, 0);
3072 }
3073}