Skip to main content

leviath_core/
region.rs

1//! Memory region types and validation schemas.
2//!
3//! Regions are typed sections of an agent's context window with different lifecycle
4//! policies. This module defines the region kinds, content storage, and validation
5//! schemas that enforce content format requirements.
6
7use serde::{Deserialize, Serialize};
8
9/// The kind of content stored in a region entry.
10///
11/// Entries carry typed metadata instead of relying on text-prefix parsing
12/// (e.g., "Assistant: " / "User: ") to determine message roles. This
13/// eliminates the bug where tool results stored outside the conversation
14/// region all become "user" role messages.
15#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
16#[serde(tag = "type")]
17pub enum EntryKind {
18    /// Plain text (system content, summaries, scratch).
19    #[default]
20    Text,
21    /// User message in conversation.
22    UserMessage,
23    /// Assistant response with optional tool calls.
24    AssistantTurn { tool_calls: Vec<SerializedToolCall> },
25    /// Tool execution result, paired with a tool_call_id.
26    ToolResult {
27        tool_call_id: String,
28        tool_name: String,
29        is_error: bool,
30    },
31}
32
33/// A serialized tool call stored within an `AssistantTurn` entry.
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35pub struct SerializedToolCall {
36    pub id: String,
37    pub name: String,
38    pub arguments: serde_json::Value,
39    /// Opaque provider token that must be replayed with this call
40    /// (Gemini's `thought_signature`). Persisted so it survives a restart.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub thought_signature: Option<String>,
43}
44
45/// Eviction strategy for `SlidingWindow` regions.
46///
47/// Controls how entries are removed when the window exceeds its `max_items` limit.
48/// The choice of strategy affects prompt caching effectiveness: PerItem eviction
49/// shifts the message prefix every iteration (breaking cache), while Bulk and
50/// Compact keep the prefix stable between eviction events.
51#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(tag = "strategy", rename_all = "snake_case")]
53pub enum EvictionStrategy {
54    /// Evict one turn group at a time (current behavior). Default.
55    #[default]
56    PerItem,
57    /// Evict in bulk when items exceed max + overflow.
58    /// Between bulk evictions, the prefix stays stable for caching.
59    Bulk {
60        /// How many items over max_items before triggering a bulk eviction.
61        /// When triggered, evicts items back down to max_items.
62        overflow: usize,
63    },
64    /// Summarize oldest entries when threshold is hit (requires external LLM call).
65    /// The region stores a `pending_compaction` flag; the runtime checks this
66    /// and performs compaction externally.
67    Compact {
68        /// Number of oldest entries to compact into a summary when triggered.
69        compact_count: usize,
70    },
71}
72
73/// A typed memory region within an agent's context window.
74///
75/// Regions have different lifecycle policies controlling how they behave
76/// when the context window fills up. This is inspired by hardware memory
77/// architectures like SNES VRAM, where different memory regions serve
78/// distinct purposes with their own access patterns and constraints.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub enum RegionKind {
81    /// Never evicted or compacted. Architecture diagrams, constraints, identity.
82    ///
83    /// Like SNES OAM (Object Attribute Memory) - fixed format, always present.
84    /// Use for content that defines the agent's core identity, constraints,
85    /// and architectural understanding. This content persists for the entire
86    /// agent lifecycle.
87    Pinned,
88
89    /// Maintains the last N items, oldest rolls off. Conversation history.
90    ///
91    /// Like a ring buffer with configurable size. When the buffer is full,
92    /// the oldest item is removed to make room for new content. Use for
93    /// conversation history or any sequential data where recent items
94    /// are most relevant.
95    SlidingWindow {
96        /// Maximum number of items to retain in the window
97        max_items: usize,
98        /// Strategy used to evict entries when the window is full
99        eviction_strategy: EvictionStrategy,
100    },
101
102    /// First to be evicted when space is needed. Tool outputs, intermediate results.
103    ///
104    /// Cheapest to regenerate, lowest priority to keep. Use for content that
105    /// can be easily regenerated or has low value after immediate use, such as
106    /// tool execution results or temporary computations.
107    Temporary,
108
109    /// Compacts (summarizes) when threshold is hit, then cleared.
110    ///
111    /// When token count exceeds the threshold, the region's content is summarized
112    /// and moved to a paired CompactHistory region, then the original Compacting
113    /// region is completely cleared, giving fresh capacity.
114    Compacting {
115        /// Token count that triggers compaction
116        threshold_tokens: usize,
117    },
118
119    /// Wiped entirely in one shot when space is needed. All-or-nothing eviction.
120    ///
121    /// Unlike Temporary (which evicts oldest entries one at a time), Clearable
122    /// regions are dumped completely and immediately when eviction is needed.
123    /// Use for scratch space or temporary working data where partial results
124    /// are useless.
125    Clearable,
126
127    /// Receives summaries from paired Compacting regions, never evicted.
128    ///
129    /// When a Compacting region hits its threshold and summarizes, the summary
130    /// moves here. CompactHistory regions hold compressed knowledge indefinitely
131    /// and are never evicted. Can also support sliding window behavior (oldest
132    /// summaries drop off) and re-compaction (combine multiple summaries).
133    CompactHistory {
134        /// Name of the source Compacting region
135        source_region: String,
136    },
137
138    /// Key-value region where entries are indexed by string key.
139    /// Writing with an existing key replaces that entry (upsert semantics).
140    /// When over token budget, evicts least-recently-updated entries (LRU).
141    HashMap {
142        /// Optional maximum number of keys
143        max_entries: Option<usize>,
144    },
145
146    /// Script-backed region: a user-authored Rhai script owns how the region
147    /// renders into the assembled context (`render`), may transform or reject
148    /// each incoming entry (`on_write`), and may choose what to drop under
149    /// budget pressure (`on_overflow`).
150    ///
151    /// `script` is the blueprint-dir-relative path to the `.rhai` file; path
152    /// resolution and compilation happen in the CLI spawner (this crate stays
153    /// filesystem-free), and the compiled script travels on the runtime's
154    /// context window keyed by this path. `persistent` regions behave like
155    /// [`Pinned`](Self::Pinned) for lifecycle - never evicted, immune to edge
156    /// `Clear` transforms, counted as fixed budget - while non-persistent
157    /// regions behave like [`Temporary`](Self::Temporary).
158    ///
159    /// Note: this kind is orthogonal to [`RegionSchema`]'s (unwired)
160    /// `custom_script` field, which is a content-*validation* concept.
161    Custom {
162        /// Blueprint-dir-relative path to the Rhai script backing this region
163        script: String,
164        /// Lifecycle: `true` = Pinned-like (protected, fixed budget),
165        /// `false` = Temporary-like (stage-specific, evictable)
166        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    /// Return the cache hint appropriate for this region kind.
218    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            // A persistent custom region is Pinned-like: its rendered output is
230            // expected to be stable. Non-persistent custom content changes on
231            // writes, like Compacting/HashMap.
232            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/// A single region in the context window with its content and metadata.
244///
245/// Each region tracks its own token budget, current usage, and optional
246/// validation schema to enforce content format requirements.
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct Region {
249    /// Unique name identifying this region
250    pub name: String,
251
252    /// Lifecycle policy for this region
253    pub kind: RegionKind,
254
255    /// Content entries stored in this region
256    pub content: Vec<RegionEntry>,
257
258    /// Maximum tokens allowed in this region
259    pub max_tokens: usize,
260
261    /// Current token count
262    pub current_tokens: usize,
263
264    /// Optional validation schema enforcing content format
265    pub schema: Option<RegionSchema>,
266
267    /// Taint tracking state. Present when taint tracking is enabled.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub taint: Option<crate::taint::RegionTaint>,
270
271    /// When true, the Compact eviction strategy has determined that oldest
272    /// entries should be summarized. The runtime checks this flag and
273    /// performs the compaction externally (requires an LLM call).
274    #[serde(default)]
275    pub needs_message_compaction: bool,
276}
277
278impl Region {
279    /// Create a new region with the specified configuration.
280    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    /// Enable taint tracking for this region.
294    pub fn with_taint_tracking(mut self) -> Self {
295        self.taint = Some(crate::taint::RegionTaint::new());
296        self
297    }
298
299    /// Enable taint tracking on this region (mutable).
300    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    /// Get the current taint level of this region, if taint tracking is enabled.
307    pub fn taint_level(&self) -> Option<crate::taint::TaintLevel> {
308        self.taint.as_ref().map(|t| t.level())
309    }
310
311    /// Add an entry with a taint level. Used when taint tracking is enabled.
312    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        // Validate against schema if present
319        if let Some(schema) = &self.schema {
320            schema.validate(&content)?;
321        }
322
323        // Check token budget
324        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        // Add entry
332        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        // Update taint tracking
343        if let Some(taint) = &mut self.taint {
344            taint.add_entry(taint_level);
345        }
346
347        // Enforce SlidingWindow max_items limit
348        self.enforce_sliding_window();
349
350        Ok(())
351    }
352
353    /// Add a typed entry with a taint level.
354    ///
355    /// Combines [`add_typed_entry`](Self::add_typed_entry) (the entry carries a
356    /// typed [`EntryKind`] so eviction can group turns) with
357    /// [`add_tainted_entry`](Self::add_tainted_entry) (the entry contributes a
358    /// specific taint level rather than defaulting to `Public`). Used for tool
359    /// results when taint tracking is enabled, so a sensitive tool's output
360    /// both keeps its `ToolResult` kind and raises the region's taint level.
361    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        // Validate against schema if present
369        if let Some(schema) = &self.schema {
370            schema.validate(&content)?;
371        }
372
373        // Check token budget
374        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        // Add entry
382        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        // Update taint tracking with the supplied level
393        if let Some(taint) = &mut self.taint {
394            taint.add_entry(taint_level);
395        }
396
397        // Enforce SlidingWindow max_items limit
398        self.enforce_sliding_window();
399
400        Ok(())
401    }
402
403    /// Add a validation schema to this region.
404    pub fn with_schema(mut self, schema: RegionSchema) -> Self {
405        self.schema = Some(schema);
406        self
407    }
408
409    /// Add an entry to this region.
410    ///
411    /// Validates content against schema if present, checks token budget,
412    /// and adds the entry to the region.
413    pub fn add_entry(&mut self, content: String, tokens: usize) -> crate::error::Result<()> {
414        // Validate against schema if present
415        if let Some(schema) = &self.schema {
416            schema.validate(&content)?;
417        }
418
419        // Check token budget
420        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        // Add entry
428        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        // Track taint as Public for untagged entries
439        if let Some(taint) = &mut self.taint {
440            taint.add_entry(crate::taint::TaintLevel::Public);
441        }
442
443        // Enforce SlidingWindow max_items limit
444        self.enforce_sliding_window();
445
446        Ok(())
447    }
448
449    /// Add an entry with metadata.
450    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        // Validate against schema if present
457        if let Some(schema) = &self.schema {
458            schema.validate(&content)?;
459        }
460
461        // Check token budget
462        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        // Add entry
470        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        // Track taint as Public for untagged entries
481        if let Some(taint) = &mut self.taint {
482            taint.add_entry(crate::taint::TaintLevel::Public);
483        }
484
485        // Enforce SlidingWindow max_items limit
486        self.enforce_sliding_window();
487
488        Ok(())
489    }
490
491    /// Add an entry with a specific [`EntryKind`] to this region.
492    ///
493    /// Like [`add_entry`](Self::add_entry), but the caller supplies the entry
494    /// kind so the entry carries typed metadata rather than relying on
495    /// text-prefix parsing.
496    pub fn add_typed_entry(
497        &mut self,
498        content: String,
499        tokens: usize,
500        kind: EntryKind,
501    ) -> crate::error::Result<()> {
502        // Validate against schema if present
503        if let Some(schema) = &self.schema {
504            schema.validate(&content)?;
505        }
506
507        // Check token budget
508        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        // Add entry
516        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        // Track taint as Public for untagged entries
527        if let Some(taint) = &mut self.taint {
528            taint.add_entry(crate::taint::TaintLevel::Public);
529        }
530
531        // Enforce SlidingWindow max_items limit
532        self.enforce_sliding_window();
533
534        Ok(())
535    }
536
537    /// Carry an already-accepted entry into this region verbatim, preserving
538    /// its [`EntryKind`], metadata, key, and timestamp.
539    ///
540    /// Used when a stage-layout swap rebuilds a region and moves its surviving
541    /// content across: re-adding through [`add_entry`](Self::add_entry) would
542    /// stamp every carried entry [`EntryKind::Text`], destroying the typed
543    /// `tool_use`/`tool_result` pairing the assembler needs (the orphan
544    /// sanitizer would then strip the whole history). Skips schema validation
545    /// deliberately - the entry passed it when first accepted - but keeps the
546    /// budget check and sliding-window enforcement so the destination region's
547    /// limits still hold. Taint is not touched per entry: a carry copies the
548    /// region-level [`crate::taint::RegionTaint`] wholesale instead of
549    /// re-accumulating it.
550    pub fn carry_entry(&mut self, entry: RegionEntry) -> crate::error::Result<()> {
551        // Check token budget
552        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        // Enforce SlidingWindow max_items limit
563        self.enforce_sliding_window();
564
565        Ok(())
566    }
567
568    /// Upsert an entry by key. If key exists, replace content and update timestamp/tokens.
569    /// If key doesn't exist, add new entry. Enforces max_tokens and max_entries via LRU eviction.
570    pub fn upsert_by_key(
571        &mut self,
572        key: &str,
573        content: String,
574        tokens: usize,
575    ) -> Result<(), String> {
576        // If key exists, update in place
577        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        // Enforce max_entries via LRU eviction
592        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        // Enforce max_tokens via LRU eviction
607        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    /// Get entry by key.
631    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    /// Remove entry by key.
636    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    /// List all keys in this region.
655    pub fn keys(&self) -> Vec<&str> {
656        self.content
657            .iter()
658            .filter_map(|e| e.key.as_deref())
659            .collect()
660    }
661
662    /// Evict the least-recently-updated entry (LRU) for HashMap regions.
663    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    /// Enforce the SlidingWindow max_items limit by removing oldest entries.
683    ///
684    /// Behaviour depends on the configured [`EvictionStrategy`]:
685    /// - **PerItem** – evict one turn group at a time (original behaviour).
686    /// - **Bulk** – only evict when `len > max_items + overflow`, then evict
687    ///   down to `max_items`. Between bulk evictions the prefix is stable,
688    ///   which preserves Anthropic prompt-cache keys.
689    /// - **Compact** – set `needs_message_compaction` when `len > max_items + compact_count`.
690    ///   If the runtime hasn't compacted and `len > max_items + compact_count * 2`,
691    ///   fall back to bulk eviction to prevent unbounded growth.
692    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                    // `remove_oldest` only returns None when empty, which the
702                    // `len > max >= 0` guard already precludes; folding it into
703                    // the condition keeps the guard without a dead break arm.
704                    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                        // Fallback: runtime hasn't compacted, bulk-evict to prevent
714                        // unbounded growth.
715                        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    /// Returns the number of entries in the turn group starting at `idx`.
726    ///
727    /// A turn group is:
728    /// - A single Text or UserMessage entry (group size = 1)
729    /// - An AssistantTurn followed by consecutive ToolResult entries
730    ///   (group size = 1 + number of following ToolResults)
731    /// - A lone ToolResult (shouldn't happen, but size = 1 for safety)
732    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    /// Clear all content from this region.
753    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    /// Remove the oldest entry (for Temporary regions).
762    pub fn remove_oldest(&mut self) -> Option<RegionEntry> {
763        if self.content.is_empty() {
764            return None;
765        }
766        // Respect turn groups: an AssistantTurn with tool_calls must be
767        // evicted together with its following ToolResult entries to avoid
768        // orphaned tool_use/tool_result blocks that providers reject.
769        let group_size = self.turn_group_size_at(0);
770        let mut first = None;
771        let mut extra_tokens = 0usize;
772        // `group_size <= content.len()`, so the window never empties mid-group;
773        // the `!is_empty()` guard lives in the loop condition (no dead break arm).
774        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        // Embed extra group tokens in the returned entry so callers that use
790        // `entry.tokens` to adjust their own totals account for the full group.
791        // `first` is `Some` whenever we removed anything (guaranteed by the
792        // non-empty early return), so `map` always runs; `extra_tokens` is 0
793        // for a single-entry group, making the add a no-op there.
794        first.map(|mut entry| {
795            entry.tokens += extra_tokens;
796            entry
797        })
798    }
799
800    /// Remove all entries whose content starts with the given prefix.
801    ///
802    /// Used to clear tagged entries (e.g. stage instructions) before injecting
803    /// replacements, so stale instructions don't accumulate across stage
804    /// transitions.
805    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    /// Get the number of entries in this region.
822    pub fn entry_count(&self) -> usize {
823        self.content.len()
824    }
825
826    /// Check if region needs compaction (for Compacting regions).
827    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/// A single entry within a region.
837///
838/// Each entry has content and metadata tracking its token usage.
839#[derive(Debug, Clone, Serialize, Deserialize)]
840pub struct RegionEntry {
841    /// The actual content of this entry
842    pub content: String,
843
844    /// Token count for this entry
845    pub tokens: usize,
846
847    /// Timestamp when this entry was added
848    pub timestamp: i64,
849
850    /// Optional metadata about this entry
851    pub metadata: Option<serde_json::Value>,
852
853    /// The kind of content stored in this entry.
854    /// Defaults to `EntryKind::Text` for backward compatibility with
855    /// serialized data that predates the typed-entry system.
856    #[serde(default)]
857    pub kind: EntryKind,
858
859    /// Optional key for HashMap regions. When set, upsert semantics apply.
860    #[serde(default, skip_serializing_if = "Option::is_none")]
861    pub key: Option<String>,
862}
863
864/// Validation schema for a region's content.
865///
866/// Enforces that content matches expected format (e.g., mermaid diagrams only,
867/// JSON only, code only). Schemas can include multiple validators that are
868/// checked when content is added to a region.
869#[derive(Debug, Serialize, Deserialize)]
870pub struct RegionSchema {
871    /// Expected content format
872    pub format: ContentFormat,
873
874    /// Optional custom validation script (Rhai)
875    #[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    /// Create a new schema with the specified format.
890    pub fn new(format: ContentFormat) -> Self {
891        Self {
892            format,
893            custom_script: None,
894        }
895    }
896
897    /// Add a custom validation script.
898    pub fn with_custom_script(mut self, script: String) -> Self {
899        self.custom_script = Some(script);
900        self
901    }
902
903    /// Validate content against this schema.
904    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                // Basic mermaid syntax validation
913                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                // Basic code validation - just check it's not empty
930                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                // Markdown is very permissive, just check it's not empty
938                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                // Text has no restrictions, Custom is handled by scripting layer
946            }
947        }
948
949        Ok(())
950    }
951}
952
953/// Content format types that can be enforced via schemas.
954#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
955pub enum ContentFormat {
956    /// Plain text, no formatting requirements
957    Text,
958
959    /// Valid JSON
960    Json,
961
962    /// Mermaid diagram syntax
963    Mermaid,
964
965    /// Source code in a specific language
966    Code { language: String },
967
968    /// Markdown formatted text
969    Markdown,
970
971    /// Custom format with user-defined validation
972    Custom { format_name: String },
973}
974
975/// Trait for content validators.
976///
977/// Validators check whether content meets specific requirements before
978/// it's added to a region. This enables enforcing architectural constraints
979/// like "only mermaid diagrams in the architecture region".
980pub trait Validator: Send + Sync {
981    /// Validate content and return an error message if invalid.
982    fn validate(&self, content: &str) -> std::result::Result<(), crate::error::ValidationError>;
983
984    /// Get a description of what this validator checks.
985    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        // Pre-existing serialized kinds still deserialize (additive variant).
1066        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        // Adding a 4th entry should evict the oldest
1176        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); // 20 + 30 + 40
1181
1182        // Adding a 5th entry should evict again
1183        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); // 30 + 40 + 50
1187    }
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    // ─── Region::with_schema / add_entry schema + budget checks ────────────
1269
1270    #[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    // ─── clear / remove_oldest / needs_compaction ──────────────────────────
1343
1344    #[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    // ─── RegionSchema::with_custom_script ──────────────────────────────────
1407
1408    #[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    // ─── RegionSchema::validate - every ContentFormat branch ───────────────
1418
1419    #[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        // Custom format validation is deferred to the scripting layer -
1506        // this schema's own validate() is a no-op for it.
1507        assert!(schema.validate("").is_ok());
1508        assert!(schema.validate("whatever").is_ok());
1509    }
1510
1511    // ─── RegionSchema Clone impl ────────────────────────────────────────────
1512
1513    #[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    // ─── Region taint tracking ──────────────────────────────────────────────
1522
1523    #[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        // Calling again is a no-op
1545        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(); // removes private entry
1617        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        // Third entry evicts the private one
1655        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(&region).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    /// The replay token survives persistence, and archives written before the
1717    /// field existed still load (`#[serde(default)]`) - a restart must not
1718    /// strand a Gemini run on a missing signature or fail on an old run dir.
1719    #[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        // Pre-field JSON (what every existing run dir contains).
1732        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        // And a `None` signature serializes to the old shape, so new writes
1737        // stay readable by anything parsing the documented format.
1738        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        // Non-JSON content should fail validation
1783        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        // When taint tracking is NOT enabled, add_typed_tainted_entry still works
1795        // but the taint level is not tracked
1796        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        // No .with_taint_tracking()
1805
1806        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); // no tracking
1817    }
1818
1819    // ─── turn_group_size_at ────────────────────────────────────────────────
1820
1821    #[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); // Text
1915        assert_eq!(region.turn_group_size_at(1), 1); // UserMessage
1916        assert_eq!(region.turn_group_size_at(2), 1); // ToolResult (orphan)
1917    }
1918
1919    // ─── remove_oldest with turn group eviction ────────────────────────────
1920
1921    #[test]
1922    fn test_remove_oldest_evicts_entire_turn_group() {
1923        let mut region = Region::new("conv".to_string(), RegionKind::Temporary, 50000);
1924        // AssistantTurn with 2 tool calls
1925        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        // A trailing user message that should survive
1970        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        // The returned entry is the AssistantTurn, with tokens adjusted to
1979        // include the extra tokens from the 2 ToolResult entries.
1980        assert_eq!(removed.content, "assistant");
1981        assert_eq!(removed.tokens, 100 + 30 + 20); // 150
1982        // Only the user message remains
1983        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    // ─── remove_oldest with taint tracking and turn group ──────────────────
1989
1990    #[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        // AssistantTurn (Private) + 1 ToolResult (Internal) + 1 trailing Public entry
1996        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        // Evict the turn group (AssistantTurn + ToolResult)
2038        let removed = region.remove_oldest().unwrap();
2039        assert_eq!(removed.content, "assistant");
2040        assert_eq!(region.entry_count(), 1);
2041        // Taint should have called remove_oldest twice (once per group member),
2042        // leaving only the Public entry's taint.
2043        assert_eq!(region.taint.as_ref().unwrap().entry_count(), 1);
2044        assert_eq!(region.taint_level(), Some(crate::taint::TaintLevel::Public));
2045    }
2046
2047    // ─── enforce_sliding_window with turn group ────────────────────────────
2048
2049    #[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        // Add an AssistantTurn + 2 ToolResults = 3 entries (fills the window)
2061        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        // Adding a 4th entry should evict the entire turn group (3 entries)
2109        // because the group at index 0 is an AssistantTurn with 2 ToolResults.
2110        region
2111            .add_typed_entry("user msg".to_string(), 15, EntryKind::UserMessage)
2112            .unwrap();
2113
2114        // After eviction: only the new user message remains
2115        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    // ─── add_entry_with_metadata with taint tracking ───────────────────────
2121
2122    #[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    // ─── add_typed_entry with taint tracking ───────────────────────────────
2140
2141    #[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    // ─── EvictionStrategy tests ───────────────────────────────────────────
2163
2164    #[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        // Add 8 entries: 5 (max) + 3 (overflow) = 8, which does NOT trigger
2194        // because the check is > not >=.
2195        for i in 0..8 {
2196            region.add_entry(format!("msg{}", i), 10).unwrap();
2197        }
2198        assert_eq!(region.entry_count(), 8);
2199
2200        // Adding one more (9 total > 5+3=8) triggers bulk eviction → down to 5
2201        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        // Add AssistantTurn + ToolResult (turn group of 2)
2217        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        // Add more entries to exceed overflow
2243        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        // 5 entries, under overflow (5 < 3+2=5 is not >), no eviction yet
2247        assert_eq!(region.entry_count(), 5);
2248
2249        // Adding 6th entry: 6 > 5 triggers bulk eviction
2250        region.add_entry("msg5".to_string(), 10).unwrap();
2251        // Turn group (assistant+result=2) evicted together, then msg2 evicted
2252        // to get down to max_items=3
2253        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        // Add exactly max_items + overflow - 1 = 7 entries
2268        for i in 0..7 {
2269            region.add_entry(format!("msg{}", i), 10).unwrap();
2270        }
2271        // 7 <= 8 (5+3), so no eviction
2272        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        // Add 9 entries: > max_items(5) + compact_count(3) = 8
2288        for i in 0..9 {
2289            region.add_entry(format!("msg{}", i), 10).unwrap();
2290        }
2291        assert!(region.needs_message_compaction);
2292        // No entries were evicted - compaction flag is set for the runtime
2293        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        // Add enough entries to exceed 2x threshold:
2307        // > max_items(5) + compact_count(3) * 2 = 11
2308        for i in 0..12 {
2309            region.add_entry(format!("msg{}", i), 10).unwrap();
2310        }
2311        // Should have bulk-evicted down to max_items=5
2312        assert_eq!(region.entry_count(), 5);
2313        assert_eq!(region.content[0].content, "msg7");
2314        // Compaction flag should be cleared after fallback
2315        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        // After removing Private and Internal entries, only Public remains
2380        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        // When entries are <= max_items + compact_count, no flag should be set
2387        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        // 8 == max_items(5) + compact_count(3), not >, so no flag
2399        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        // Add 5 entries (3+2): at threshold, no eviction
2416        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        // 6th entry triggers bulk eviction to max_items=3
2427        region
2428            .add_tainted_entry("pub5".to_string(), 10, crate::taint::TaintLevel::Public)
2429            .unwrap();
2430        assert_eq!(region.entry_count(), 3);
2431        // Private entry was evicted, only public remain
2432        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    // ─── add_typed_entry schema + budget edge cases ───────────────────────
2484
2485    #[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        // When taint tracking is NOT enabled, the taint level is silently ignored.
2505        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    // ─── HashMap region tests ──────────────────────────────────────────────
2524
2525    #[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, // tight budget
2606        );
2607        region.upsert_by_key("a.rs", "aaa".to_string(), 10).unwrap();
2608        // Make 'a' older by manually adjusting timestamp
2609        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        // Adding d.rs should evict a.rs (oldest timestamp)
2616        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; // make oldest
2633        region.upsert_by_key("b.rs", "bbb".to_string(), 10).unwrap();
2634        assert_eq!(region.entry_count(), 2);
2635
2636        // Adding c.rs should evict a.rs (oldest, max_entries=2)
2637        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, // very small
2649        );
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    // ─── Additional HashMap region tests ──────────────────────────────────
2722
2723    #[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, // small token budget
2796        );
2797
2798        // Insert entries that together fill the budget
2799        region
2800            .upsert_by_key("first.rs", "first content".to_string(), 40)
2801            .unwrap();
2802        region.content[0].timestamp -= 200; // oldest
2803
2804        region
2805            .upsert_by_key("second.rs", "second content".to_string(), 40)
2806            .unwrap();
2807        region.content[1].timestamp -= 100; // middle age
2808
2809        region
2810            .upsert_by_key("third.rs", "third content".to_string(), 20)
2811            .unwrap();
2812        // total = 100, at budget
2813
2814        // Inserting another entry that exceeds budget should evict oldest
2815        region
2816            .upsert_by_key("fourth.rs", "fourth content".to_string(), 30)
2817            .unwrap();
2818
2819        // first.rs (oldest timestamp) should have been evicted
2820        assert!(region.get_by_key("first.rs").is_none());
2821        assert!(region.get_by_key("fourth.rs").is_some());
2822        // total tokens should be within budget
2823        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; // make oldest
2840
2841        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        // Only 2 entries should remain, oldest evicted
2849        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        // Found
2867        let found = region.get_by_key("exists");
2868        assert!(found.is_some());
2869        assert_eq!(found.unwrap().content, "hello");
2870
2871        // Not found
2872        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        // Empty
2915        assert!(region.keys().is_empty());
2916
2917        // Populated
2918        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        // After removal
2929        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        // Entry with key
2940        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        // Entry without key
2955        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    // ─── taint-vector fixups on keyed removal / LRU eviction ───────────────
3006
3007    #[test]
3008    fn test_remove_by_key_recomputes_taint_when_tracking_enabled() {
3009        // A taint-tracked region: remove_by_key must run its taint-vector
3010        // fixup branch (`taint.remove_at`) without panicking.
3011        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        // A taint-tracked HashMap region with a max_entries cap: inserting past
3033        // the cap triggers evict_lru_entry, which must run its taint-vector
3034        // fixup branch.
3035        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        // Only the most-recently-inserted key survives after LRU eviction.
3051        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        // Directly exercise the early-return guard in `evict_lru_entry` when
3059        // there is nothing to evict - a defensive branch not reachable through
3060        // the public upsert path (which only evicts non-empty regions).
3061        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}