Skip to main content

ipfrs_storage/
retention_policy.rs

1//! Storage Retention Policy Engine for IPFRS block storage.
2//!
3//! Manages data retention policies that determine when blocks should be expired,
4//! archived, or permanently deleted based on age, size, access patterns, and tags.
5
6/// Action to take on a block as determined by a retention rule.
7#[derive(Debug, Clone, PartialEq)]
8pub enum RetentionAction {
9    /// No action needed; block should be kept as-is.
10    Keep,
11    /// Move the block to cold/archive storage.
12    Archive,
13    /// Permanently remove the block.
14    Delete,
15    /// Flag the block for human review with an explanatory reason.
16    Warn { reason: String },
17}
18
19/// A single retention rule with priority-based evaluation order.
20#[derive(Debug, Clone)]
21pub struct RetentionRule {
22    /// Unique identifier for this rule.
23    pub rule_id: u64,
24    /// Human-readable name for this rule.
25    pub name: String,
26    /// Maximum age in seconds before this rule triggers. `None` means no age constraint.
27    pub max_age_secs: Option<u64>,
28    /// Maximum size in bytes before this rule triggers. `None` means no size constraint.
29    pub max_size_bytes: Option<u64>,
30    /// If `Some`, this rule only applies to blocks that carry this tag.
31    pub required_tag: Option<String>,
32    /// The action to apply when this rule matches a block.
33    pub action: RetentionAction,
34    /// Evaluation priority: higher values are evaluated first.
35    pub priority: u32,
36}
37
38/// Metadata record describing a stored block.
39#[derive(Debug, Clone)]
40pub struct BlockRecord {
41    /// Content Identifier of the block.
42    pub cid: String,
43    /// Size of the block in bytes.
44    pub size_bytes: u64,
45    /// Unix timestamp (seconds) when the block was first stored.
46    pub created_at_secs: u64,
47    /// Unix timestamp (seconds) of the most recent access.
48    pub last_accessed_secs: u64,
49    /// Arbitrary tags attached to this block.
50    pub tags: Vec<String>,
51    /// When `true`, the block is protected and will always receive `Keep`.
52    pub pinned: bool,
53}
54
55/// The outcome of evaluating retention policy rules against a single block.
56#[derive(Debug, Clone, PartialEq)]
57pub struct PolicyDecision {
58    /// Content Identifier of the evaluated block.
59    pub cid: String,
60    /// The action the engine recommends for this block.
61    pub action: RetentionAction,
62    /// The rule that triggered this decision, or `None` for the default `Keep`.
63    pub matched_rule_id: Option<u64>,
64}
65
66/// Aggregate statistics computed from a collection of policy decisions.
67#[derive(Debug, Clone, PartialEq)]
68pub struct RetentionStats {
69    /// Total number of blocks evaluated.
70    pub total_evaluated: usize,
71    /// Number of blocks that will be kept.
72    pub keep_count: usize,
73    /// Number of blocks that will be archived.
74    pub archive_count: usize,
75    /// Number of blocks that will be deleted.
76    pub delete_count: usize,
77    /// Number of blocks flagged for review.
78    pub warn_count: usize,
79}
80
81/// Engine that evaluates storage retention policies against block records.
82///
83/// Rules are maintained in descending priority order so that the highest-priority
84/// rule is always evaluated first. Pinned blocks bypass all rules and always receive
85/// a `Keep` decision.
86#[derive(Debug, Clone)]
87pub struct StorageRetentionPolicyEngine {
88    /// Active rules, sorted in descending priority order.
89    pub rules: Vec<RetentionRule>,
90}
91
92impl StorageRetentionPolicyEngine {
93    /// Creates a new engine with no rules.
94    pub fn new() -> Self {
95        Self { rules: Vec::new() }
96    }
97
98    /// Adds a rule while maintaining descending priority order.
99    ///
100    /// If two rules share the same priority, the newly inserted rule is placed
101    /// after all existing rules with the same priority (stable ordering).
102    pub fn add_rule(&mut self, rule: RetentionRule) {
103        // Find the first position whose priority is strictly less than the new rule's
104        // priority, and insert before it.
105        let pos = self
106            .rules
107            .iter()
108            .position(|r| r.priority < rule.priority)
109            .unwrap_or(self.rules.len());
110        self.rules.insert(pos, rule);
111    }
112
113    /// Removes the rule with the given `rule_id`.
114    ///
115    /// Returns `true` if a rule was found and removed, `false` otherwise.
116    pub fn remove_rule(&mut self, rule_id: u64) -> bool {
117        if let Some(pos) = self.rules.iter().position(|r| r.rule_id == rule_id) {
118            self.rules.remove(pos);
119            true
120        } else {
121            false
122        }
123    }
124
125    /// Evaluates retention policy rules against a single block.
126    ///
127    /// Pinned blocks always receive `RetentionAction::Keep` with no matched rule.
128    /// Otherwise, rules are checked in descending priority order; the first rule
129    /// whose conditions **all** hold wins. If no rule matches, the default
130    /// `RetentionAction::Keep` is returned with `matched_rule_id = None`.
131    pub fn evaluate(&self, block: &BlockRecord, now_secs: u64) -> PolicyDecision {
132        if block.pinned {
133            return PolicyDecision {
134                cid: block.cid.clone(),
135                action: RetentionAction::Keep,
136                matched_rule_id: None,
137            };
138        }
139
140        for rule in &self.rules {
141            if self.rule_matches(rule, block, now_secs) {
142                return PolicyDecision {
143                    cid: block.cid.clone(),
144                    action: rule.action.clone(),
145                    matched_rule_id: Some(rule.rule_id),
146                };
147            }
148        }
149
150        // Default: keep with no rule applied.
151        PolicyDecision {
152            cid: block.cid.clone(),
153            action: RetentionAction::Keep,
154            matched_rule_id: None,
155        }
156    }
157
158    /// Evaluates retention policy rules against every block in `blocks`.
159    pub fn evaluate_all(&self, blocks: &[BlockRecord], now_secs: u64) -> Vec<PolicyDecision> {
160        blocks.iter().map(|b| self.evaluate(b, now_secs)).collect()
161    }
162
163    /// Computes aggregate statistics from a slice of policy decisions.
164    pub fn stats(&self, decisions: &[PolicyDecision]) -> RetentionStats {
165        let mut keep_count = 0usize;
166        let mut archive_count = 0usize;
167        let mut delete_count = 0usize;
168        let mut warn_count = 0usize;
169
170        for decision in decisions {
171            match &decision.action {
172                RetentionAction::Keep => keep_count += 1,
173                RetentionAction::Archive => archive_count += 1,
174                RetentionAction::Delete => delete_count += 1,
175                RetentionAction::Warn { .. } => warn_count += 1,
176            }
177        }
178
179        RetentionStats {
180            total_evaluated: decisions.len(),
181            keep_count,
182            archive_count,
183            delete_count,
184            warn_count,
185        }
186    }
187
188    // ── Private helpers ──────────────────────────────────────────────────────
189
190    /// Returns `true` if **all** active conditions of `rule` hold for `block`
191    /// at time `now_secs`.
192    fn rule_matches(&self, rule: &RetentionRule, block: &BlockRecord, now_secs: u64) -> bool {
193        // Age condition: the block must be older than max_age_secs.
194        if let Some(max_age) = rule.max_age_secs {
195            let age = now_secs.saturating_sub(block.created_at_secs);
196            if age <= max_age {
197                return false;
198            }
199        }
200
201        // Size condition: the block must exceed max_size_bytes.
202        if let Some(max_size) = rule.max_size_bytes {
203            if block.size_bytes <= max_size {
204                return false;
205            }
206        }
207
208        // Tag condition: the block must carry the required tag.
209        if let Some(ref tag) = rule.required_tag {
210            if !block.tags.contains(tag) {
211                return false;
212            }
213        }
214
215        true
216    }
217}
218
219impl Default for StorageRetentionPolicyEngine {
220    fn default() -> Self {
221        Self::new()
222    }
223}
224
225// ══════════════════════════════════════════════════════════════════════════════
226// StorageRetentionPolicy — tick-based, entry-managing retention policy
227// ══════════════════════════════════════════════════════════════════════════════
228
229use std::collections::HashMap;
230
231/// Action produced by tick-based retention evaluation.
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub enum TickRetentionAction {
234    /// Block should be kept.
235    Keep,
236    /// Block should be moved to archive/cold storage.
237    Archive,
238    /// Block should be permanently deleted.
239    Delete,
240}
241
242/// A tick-based retention rule with priority ordering.
243#[derive(Debug, Clone)]
244pub struct TickRetentionRule {
245    /// Human-readable name (also used as unique key).
246    pub name: String,
247    /// Maximum age in ticks before this rule triggers. `None` = no age constraint.
248    pub max_age_ticks: Option<u64>,
249    /// Maximum total storage size in bytes before this rule triggers. `None` = no size constraint.
250    pub max_size_bytes: Option<u64>,
251    /// The action to take when this rule matches.
252    pub action: TickRetentionAction,
253    /// Higher priority rules are evaluated first.
254    pub priority: u32,
255}
256
257/// Metadata for a block tracked by the retention policy.
258#[derive(Debug, Clone)]
259pub struct RetentionEntry {
260    /// Content identifier of the block.
261    pub block_cid: String,
262    /// Size of the block in bytes.
263    pub size_bytes: u64,
264    /// Tick at which the block was created/added.
265    pub created_tick: u64,
266    /// Tick at which the block was last accessed.
267    pub last_accessed_tick: u64,
268    /// Pinned blocks are never deleted or archived.
269    pub pinned: bool,
270}
271
272/// Aggregate statistics for the retention policy.
273#[derive(Debug, Clone)]
274pub struct RetentionPolicyStats {
275    /// Number of entries currently tracked.
276    pub entry_count: usize,
277    /// Number of rules currently registered.
278    pub rule_count: usize,
279    /// Cumulative count of deleted entries since creation.
280    pub total_deleted: u64,
281    /// Cumulative count of archived entries since creation.
282    pub total_archived: u64,
283    /// Cumulative bytes freed by deletion since creation.
284    pub bytes_freed: u64,
285}
286
287/// Tick-based storage retention policy with built-in entry tracking.
288///
289/// Maintains a set of blocks (entries) and evaluates configurable rules
290/// against them. Rules are sorted by descending priority. Pinned entries
291/// are always kept regardless of rules.
292pub struct StorageRetentionPolicy {
293    rules: Vec<TickRetentionRule>,
294    entries: HashMap<String, RetentionEntry>,
295    current_tick: u64,
296    total_deleted: u64,
297    total_archived: u64,
298    bytes_freed: u64,
299}
300
301impl StorageRetentionPolicy {
302    /// Creates a new, empty retention policy at tick 0.
303    pub fn new() -> Self {
304        Self {
305            rules: Vec::new(),
306            entries: HashMap::new(),
307            current_tick: 0,
308            total_deleted: 0,
309            total_archived: 0,
310            bytes_freed: 0,
311        }
312    }
313
314    /// Adds a rule, maintaining descending priority order.
315    /// If a rule with the same name already exists it is replaced.
316    pub fn add_rule(&mut self, rule: TickRetentionRule) {
317        // Remove existing rule with same name, if any.
318        self.rules.retain(|r| r.name != rule.name);
319        let pos = self
320            .rules
321            .iter()
322            .position(|r| r.priority < rule.priority)
323            .unwrap_or(self.rules.len());
324        self.rules.insert(pos, rule);
325    }
326
327    /// Removes the rule with the given name.
328    /// Returns `true` if a rule was found and removed.
329    pub fn remove_rule(&mut self, name: &str) -> bool {
330        let before = self.rules.len();
331        self.rules.retain(|r| r.name != name);
332        self.rules.len() < before
333    }
334
335    /// Registers a new block entry at the given creation tick.
336    pub fn add_entry(&mut self, block_cid: &str, size_bytes: u64, created_tick: u64) {
337        let entry = RetentionEntry {
338            block_cid: block_cid.to_string(),
339            size_bytes,
340            created_tick,
341            last_accessed_tick: created_tick,
342            pinned: false,
343        };
344        self.entries.insert(block_cid.to_string(), entry);
345    }
346
347    /// Pins a block, protecting it from deletion/archival.
348    /// Returns `true` if the entry was found and pinned.
349    pub fn pin(&mut self, block_cid: &str) -> bool {
350        if let Some(entry) = self.entries.get_mut(block_cid) {
351            entry.pinned = true;
352            true
353        } else {
354            false
355        }
356    }
357
358    /// Unpins a block, allowing retention rules to apply.
359    /// Returns `true` if the entry was found and unpinned.
360    pub fn unpin(&mut self, block_cid: &str) -> bool {
361        if let Some(entry) = self.entries.get_mut(block_cid) {
362            entry.pinned = false;
363            true
364        } else {
365            false
366        }
367    }
368
369    /// Evaluates retention rules against a single block.
370    ///
371    /// Pinned blocks always return `Keep`. Rules are evaluated in descending
372    /// priority order; the first matching rule wins. If no rule matches,
373    /// the default is `Keep`.
374    pub fn evaluate(&self, block_cid: &str) -> TickRetentionAction {
375        let entry = match self.entries.get(block_cid) {
376            Some(e) => e,
377            None => return TickRetentionAction::Keep,
378        };
379
380        if entry.pinned {
381            return TickRetentionAction::Keep;
382        }
383
384        let total_size = self.total_size_bytes();
385
386        for rule in &self.rules {
387            if self.tick_rule_matches(rule, entry, total_size) {
388                return rule.action;
389            }
390        }
391
392        TickRetentionAction::Keep
393    }
394
395    /// Evaluates all entries and enforces the policy.
396    ///
397    /// Entries marked for deletion are removed from the internal store.
398    /// Returns a list of `(cid, action)` pairs for entries that received
399    /// `Archive` or `Delete` actions.
400    pub fn enforce(&mut self) -> Vec<(String, TickRetentionAction)> {
401        let total_size = self.total_size_bytes();
402        let cids: Vec<String> = self.entries.keys().cloned().collect();
403
404        let mut actions = Vec::new();
405
406        for cid in &cids {
407            if let Some(entry) = self.entries.get(cid) {
408                if entry.pinned {
409                    continue;
410                }
411
412                let mut matched_action = TickRetentionAction::Keep;
413                for rule in &self.rules {
414                    if self.tick_rule_matches(rule, entry, total_size) {
415                        matched_action = rule.action;
416                        break;
417                    }
418                }
419
420                if matched_action != TickRetentionAction::Keep {
421                    actions.push((cid.clone(), matched_action));
422                }
423            }
424        }
425
426        // Apply deletions and track stats.
427        for (cid, action) in &actions {
428            match action {
429                TickRetentionAction::Delete => {
430                    if let Some(removed) = self.entries.remove(cid) {
431                        self.total_deleted += 1;
432                        self.bytes_freed += removed.size_bytes;
433                    }
434                }
435                TickRetentionAction::Archive => {
436                    self.total_archived += 1;
437                    // Archived entries remain in the map (they moved to cold storage
438                    // externally) but we still count them.
439                }
440                TickRetentionAction::Keep => {}
441            }
442        }
443
444        actions
445    }
446
447    /// Updates the `last_accessed_tick` of the given entry to the current tick.
448    pub fn access(&mut self, block_cid: &str) {
449        if let Some(entry) = self.entries.get_mut(block_cid) {
450            entry.last_accessed_tick = self.current_tick;
451        }
452    }
453
454    /// Advances the internal clock by one tick.
455    pub fn tick(&mut self) {
456        self.current_tick += 1;
457    }
458
459    /// Returns the number of entries currently tracked.
460    pub fn entry_count(&self) -> usize {
461        self.entries.len()
462    }
463
464    /// Returns the total size in bytes of all tracked entries.
465    pub fn total_size_bytes(&self) -> u64 {
466        self.entries.values().map(|e| e.size_bytes).sum()
467    }
468
469    /// Returns aggregate statistics about this policy.
470    pub fn stats(&self) -> RetentionPolicyStats {
471        RetentionPolicyStats {
472            entry_count: self.entries.len(),
473            rule_count: self.rules.len(),
474            total_deleted: self.total_deleted,
475            total_archived: self.total_archived,
476            bytes_freed: self.bytes_freed,
477        }
478    }
479
480    /// Returns the current tick value.
481    pub fn current_tick(&self) -> u64 {
482        self.current_tick
483    }
484
485    // ── Private helpers ────────────────────────────────────────────────────────
486
487    /// Checks whether a tick-based rule matches the given entry.
488    fn tick_rule_matches(
489        &self,
490        rule: &TickRetentionRule,
491        entry: &RetentionEntry,
492        total_size: u64,
493    ) -> bool {
494        // Age condition: block must be older than max_age_ticks.
495        if let Some(max_age) = rule.max_age_ticks {
496            let age = self.current_tick.saturating_sub(entry.created_tick);
497            if age <= max_age {
498                return false;
499            }
500        }
501
502        // Size condition: total storage must exceed max_size_bytes.
503        if let Some(max_size) = rule.max_size_bytes {
504            if total_size <= max_size {
505                return false;
506            }
507        }
508
509        // At least one condition must be specified for the rule to match.
510        if rule.max_age_ticks.is_none() && rule.max_size_bytes.is_none() {
511            return false;
512        }
513
514        true
515    }
516}
517
518impl Default for StorageRetentionPolicy {
519    fn default() -> Self {
520        Self::new()
521    }
522}
523
524// ── Tests ────────────────────────────────────────────────────────────────────
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    // ── Helpers ──────────────────────────────────────────────────────────────
531
532    fn make_block(
533        cid: &str,
534        size_bytes: u64,
535        created_at_secs: u64,
536        tags: Vec<&str>,
537        pinned: bool,
538    ) -> BlockRecord {
539        BlockRecord {
540            cid: cid.to_string(),
541            size_bytes,
542            created_at_secs,
543            last_accessed_secs: created_at_secs,
544            tags: tags.into_iter().map(|t| t.to_string()).collect(),
545            pinned,
546        }
547    }
548
549    fn age_rule(
550        rule_id: u64,
551        max_age_secs: u64,
552        action: RetentionAction,
553        priority: u32,
554    ) -> RetentionRule {
555        RetentionRule {
556            rule_id,
557            name: format!("age-rule-{rule_id}"),
558            max_age_secs: Some(max_age_secs),
559            max_size_bytes: None,
560            required_tag: None,
561            action,
562            priority,
563        }
564    }
565
566    fn size_rule(
567        rule_id: u64,
568        max_size_bytes: u64,
569        action: RetentionAction,
570        priority: u32,
571    ) -> RetentionRule {
572        RetentionRule {
573            rule_id,
574            name: format!("size-rule-{rule_id}"),
575            max_age_secs: None,
576            max_size_bytes: Some(max_size_bytes),
577            required_tag: None,
578            action,
579            priority,
580        }
581    }
582
583    fn tag_rule(
584        rule_id: u64,
585        required_tag: &str,
586        action: RetentionAction,
587        priority: u32,
588    ) -> RetentionRule {
589        RetentionRule {
590            rule_id,
591            name: format!("tag-rule-{rule_id}"),
592            max_age_secs: None,
593            max_size_bytes: None,
594            required_tag: Some(required_tag.to_string()),
595            action,
596            priority,
597        }
598    }
599
600    // ── Engine construction ───────────────────────────────────────────────────
601
602    #[test]
603    fn new_starts_with_no_rules() {
604        let engine = StorageRetentionPolicyEngine::new();
605        assert!(engine.rules.is_empty());
606    }
607
608    // ── add_rule ─────────────────────────────────────────────────────────────
609
610    #[test]
611    fn add_rule_single_rule() {
612        let mut engine = StorageRetentionPolicyEngine::new();
613        engine.add_rule(age_rule(1, 3600, RetentionAction::Delete, 10));
614        assert_eq!(engine.rules.len(), 1);
615    }
616
617    #[test]
618    fn add_rule_maintains_priority_order_descending() {
619        let mut engine = StorageRetentionPolicyEngine::new();
620        engine.add_rule(age_rule(1, 3600, RetentionAction::Delete, 5));
621        engine.add_rule(age_rule(2, 7200, RetentionAction::Archive, 20));
622        engine.add_rule(age_rule(3, 1800, RetentionAction::Delete, 10));
623
624        // Expected order: priority 20, 10, 5
625        assert_eq!(engine.rules[0].priority, 20);
626        assert_eq!(engine.rules[1].priority, 10);
627        assert_eq!(engine.rules[2].priority, 5);
628    }
629
630    #[test]
631    fn add_rule_equal_priorities_stable() {
632        let mut engine = StorageRetentionPolicyEngine::new();
633        engine.add_rule(age_rule(1, 3600, RetentionAction::Delete, 10));
634        engine.add_rule(age_rule(2, 7200, RetentionAction::Archive, 10));
635        // Both at priority 10; rule 1 inserted first should remain before rule 2.
636        assert_eq!(engine.rules[0].rule_id, 1);
637        assert_eq!(engine.rules[1].rule_id, 2);
638    }
639
640    // ── remove_rule ───────────────────────────────────────────────────────────
641
642    #[test]
643    fn remove_rule_returns_true_when_found() {
644        let mut engine = StorageRetentionPolicyEngine::new();
645        engine.add_rule(age_rule(42, 3600, RetentionAction::Delete, 10));
646        assert!(engine.remove_rule(42));
647        assert!(engine.rules.is_empty());
648    }
649
650    #[test]
651    fn remove_rule_returns_false_when_not_found() {
652        let mut engine = StorageRetentionPolicyEngine::new();
653        assert!(!engine.remove_rule(99));
654    }
655
656    #[test]
657    fn remove_rule_leaves_others_intact() {
658        let mut engine = StorageRetentionPolicyEngine::new();
659        engine.add_rule(age_rule(1, 3600, RetentionAction::Delete, 10));
660        engine.add_rule(age_rule(2, 7200, RetentionAction::Archive, 5));
661        engine.remove_rule(1);
662        assert_eq!(engine.rules.len(), 1);
663        assert_eq!(engine.rules[0].rule_id, 2);
664    }
665
666    // ── Pinned blocks ─────────────────────────────────────────────────────────
667
668    #[test]
669    fn pinned_block_always_keep_even_with_delete_rule() {
670        let mut engine = StorageRetentionPolicyEngine::new();
671        // A very aggressive delete rule that would normally fire.
672        engine.add_rule(age_rule(1, 0, RetentionAction::Delete, 100));
673
674        let block = make_block("QmPin", 99_999, 0, vec![], true);
675        let decision = engine.evaluate(&block, 1_000_000);
676
677        assert_eq!(decision.action, RetentionAction::Keep);
678        assert_eq!(decision.matched_rule_id, None);
679    }
680
681    #[test]
682    fn pinned_block_keep_with_no_rules() {
683        let engine = StorageRetentionPolicyEngine::new();
684        let block = make_block("QmPinNoRule", 100, 0, vec![], true);
685        let decision = engine.evaluate(&block, 1000);
686        assert_eq!(decision.action, RetentionAction::Keep);
687        assert_eq!(decision.matched_rule_id, None);
688    }
689
690    // ── Default Keep when no rules ─────────────────────────────────────────────
691
692    #[test]
693    fn no_rules_returns_default_keep() {
694        let engine = StorageRetentionPolicyEngine::new();
695        let block = make_block("QmNoRule", 512, 1000, vec![], false);
696        let decision = engine.evaluate(&block, 2000);
697        assert_eq!(decision.action, RetentionAction::Keep);
698        assert_eq!(decision.matched_rule_id, None);
699    }
700
701    // ── Age rule ──────────────────────────────────────────────────────────────
702
703    #[test]
704    fn age_rule_matches_when_old_enough() {
705        let mut engine = StorageRetentionPolicyEngine::new();
706        // max_age_secs = 100; block is 200 s old → age (200) > 100 → matches.
707        engine.add_rule(age_rule(1, 100, RetentionAction::Delete, 10));
708
709        let block = make_block("QmOld", 512, 0, vec![], false);
710        let decision = engine.evaluate(&block, 200);
711
712        assert_eq!(decision.action, RetentionAction::Delete);
713        assert_eq!(decision.matched_rule_id, Some(1));
714    }
715
716    #[test]
717    fn age_rule_does_not_match_when_too_young() {
718        let mut engine = StorageRetentionPolicyEngine::new();
719        // max_age_secs = 1000; block is only 50 s old → age (50) ≤ 1000 → no match.
720        engine.add_rule(age_rule(1, 1000, RetentionAction::Delete, 10));
721
722        let block = make_block("QmYoung", 512, 1000, vec![], false);
723        let decision = engine.evaluate(&block, 1050);
724
725        assert_eq!(decision.action, RetentionAction::Keep);
726        assert_eq!(decision.matched_rule_id, None);
727    }
728
729    #[test]
730    fn age_rule_does_not_match_exactly_at_boundary() {
731        let mut engine = StorageRetentionPolicyEngine::new();
732        // age == max_age_secs → not strictly greater → no match.
733        engine.add_rule(age_rule(1, 100, RetentionAction::Delete, 10));
734
735        let block = make_block("QmBoundary", 512, 0, vec![], false);
736        let decision = engine.evaluate(&block, 100);
737
738        assert_eq!(decision.action, RetentionAction::Keep);
739    }
740
741    // ── Size rule ─────────────────────────────────────────────────────────────
742
743    #[test]
744    fn size_rule_matches_when_large_enough() {
745        let mut engine = StorageRetentionPolicyEngine::new();
746        // max_size_bytes = 1000; block is 2000 B → size (2000) > 1000 → matches.
747        engine.add_rule(size_rule(2, 1000, RetentionAction::Archive, 10));
748
749        let block = make_block("QmBig", 2000, 0, vec![], false);
750        let decision = engine.evaluate(&block, 0);
751
752        assert_eq!(decision.action, RetentionAction::Archive);
753        assert_eq!(decision.matched_rule_id, Some(2));
754    }
755
756    #[test]
757    fn size_rule_does_not_match_when_small() {
758        let mut engine = StorageRetentionPolicyEngine::new();
759        // max_size_bytes = 1000; block is only 500 B → no match.
760        engine.add_rule(size_rule(2, 1000, RetentionAction::Archive, 10));
761
762        let block = make_block("QmSmall", 500, 0, vec![], false);
763        let decision = engine.evaluate(&block, 0);
764
765        assert_eq!(decision.action, RetentionAction::Keep);
766        assert_eq!(decision.matched_rule_id, None);
767    }
768
769    #[test]
770    fn size_rule_does_not_match_exactly_at_boundary() {
771        let mut engine = StorageRetentionPolicyEngine::new();
772        // size == max_size_bytes → not strictly greater → no match.
773        engine.add_rule(size_rule(2, 1000, RetentionAction::Archive, 10));
774
775        let block = make_block("QmExact", 1000, 0, vec![], false);
776        let decision = engine.evaluate(&block, 0);
777
778        assert_eq!(decision.action, RetentionAction::Keep);
779    }
780
781    // ── Tag rule ──────────────────────────────────────────────────────────────
782
783    #[test]
784    fn tag_rule_matches_correctly_when_tag_present() {
785        let mut engine = StorageRetentionPolicyEngine::new();
786        engine.add_rule(tag_rule(3, "archive", RetentionAction::Archive, 10));
787
788        let block = make_block("QmTagged", 512, 0, vec!["archive", "index"], false);
789        let decision = engine.evaluate(&block, 0);
790
791        assert_eq!(decision.action, RetentionAction::Archive);
792        assert_eq!(decision.matched_rule_id, Some(3));
793    }
794
795    #[test]
796    fn tag_rule_no_match_when_tag_absent() {
797        let mut engine = StorageRetentionPolicyEngine::new();
798        engine.add_rule(tag_rule(3, "archive", RetentionAction::Archive, 10));
799
800        let block = make_block("QmUntagged", 512, 0, vec!["index"], false);
801        let decision = engine.evaluate(&block, 0);
802
803        assert_eq!(decision.action, RetentionAction::Keep);
804        assert_eq!(decision.matched_rule_id, None);
805    }
806
807    // ── Priority ordering ─────────────────────────────────────────────────────
808
809    #[test]
810    fn first_matching_rule_wins_by_priority() {
811        let mut engine = StorageRetentionPolicyEngine::new();
812        // High-priority delete rule: triggers for old blocks.
813        engine.add_rule(age_rule(10, 100, RetentionAction::Delete, 50));
814        // Low-priority archive rule: also triggers for old blocks.
815        engine.add_rule(age_rule(20, 100, RetentionAction::Archive, 10));
816
817        let block = make_block("QmOld", 512, 0, vec![], false);
818        let decision = engine.evaluate(&block, 200);
819
820        // The higher-priority Delete rule (id 10) should win.
821        assert_eq!(decision.action, RetentionAction::Delete);
822        assert_eq!(decision.matched_rule_id, Some(10));
823    }
824
825    #[test]
826    fn lower_priority_rule_not_applied_when_higher_matches() {
827        let mut engine = StorageRetentionPolicyEngine::new();
828        engine.add_rule(age_rule(1, 0, RetentionAction::Keep, 100));
829        engine.add_rule(age_rule(2, 0, RetentionAction::Delete, 1));
830
831        let block = make_block("QmBlock", 512, 0, vec![], false);
832        // age == 1 > 0, so rule 1 (priority 100, Keep) matches first.
833        let decision = engine.evaluate(&block, 1);
834
835        assert_eq!(decision.action, RetentionAction::Keep);
836        assert_eq!(decision.matched_rule_id, Some(1));
837    }
838
839    // ── Action variants ───────────────────────────────────────────────────────
840
841    #[test]
842    fn archive_action_returned_correctly() {
843        let mut engine = StorageRetentionPolicyEngine::new();
844        engine.add_rule(size_rule(5, 100, RetentionAction::Archive, 10));
845
846        let block = make_block("QmArchive", 200, 0, vec![], false);
847        let decision = engine.evaluate(&block, 0);
848
849        assert_eq!(decision.action, RetentionAction::Archive);
850    }
851
852    #[test]
853    fn delete_action_returned_correctly() {
854        let mut engine = StorageRetentionPolicyEngine::new();
855        engine.add_rule(size_rule(6, 100, RetentionAction::Delete, 10));
856
857        let block = make_block("QmDelete", 200, 0, vec![], false);
858        let decision = engine.evaluate(&block, 0);
859
860        assert_eq!(decision.action, RetentionAction::Delete);
861    }
862
863    #[test]
864    fn warn_action_returned_correctly() {
865        let mut engine = StorageRetentionPolicyEngine::new();
866        engine.add_rule(RetentionRule {
867            rule_id: 7,
868            name: "warn-big".to_string(),
869            max_age_secs: None,
870            max_size_bytes: Some(100),
871            required_tag: None,
872            action: RetentionAction::Warn {
873                reason: "block too large".to_string(),
874            },
875            priority: 10,
876        });
877
878        let block = make_block("QmWarn", 200, 0, vec![], false);
879        let decision = engine.evaluate(&block, 0);
880
881        assert_eq!(
882            decision.action,
883            RetentionAction::Warn {
884                reason: "block too large".to_string()
885            }
886        );
887        assert_eq!(decision.matched_rule_id, Some(7));
888    }
889
890    // ── evaluate_all ──────────────────────────────────────────────────────────
891
892    #[test]
893    fn evaluate_all_processes_multiple_blocks() {
894        let mut engine = StorageRetentionPolicyEngine::new();
895        engine.add_rule(size_rule(1, 1000, RetentionAction::Delete, 10));
896
897        let blocks = vec![
898            make_block("QmA", 500, 0, vec![], false),  // small → Keep
899            make_block("QmB", 2000, 0, vec![], false), // large → Delete
900            make_block("QmC", 1500, 0, vec![], true),  // pinned → Keep
901        ];
902        let decisions = engine.evaluate_all(&blocks, 0);
903
904        assert_eq!(decisions.len(), 3);
905        assert_eq!(decisions[0].action, RetentionAction::Keep);
906        assert_eq!(decisions[1].action, RetentionAction::Delete);
907        assert_eq!(decisions[2].action, RetentionAction::Keep);
908    }
909
910    // ── stats ─────────────────────────────────────────────────────────────────
911
912    #[test]
913    fn stats_keep_count_correct() {
914        let engine = StorageRetentionPolicyEngine::new();
915        let blocks = vec![
916            make_block("QmA", 100, 0, vec![], false),
917            make_block("QmB", 200, 0, vec![], false),
918        ];
919        let decisions = engine.evaluate_all(&blocks, 0);
920        let stats = engine.stats(&decisions);
921        assert_eq!(stats.keep_count, 2);
922        assert_eq!(stats.total_evaluated, 2);
923    }
924
925    #[test]
926    fn stats_archive_count_correct() {
927        let mut engine = StorageRetentionPolicyEngine::new();
928        engine.add_rule(size_rule(1, 100, RetentionAction::Archive, 10));
929
930        let blocks = vec![
931            make_block("QmA", 50, 0, vec![], false),  // Keep
932            make_block("QmB", 200, 0, vec![], false), // Archive
933            make_block("QmC", 300, 0, vec![], false), // Archive
934        ];
935        let decisions = engine.evaluate_all(&blocks, 0);
936        let stats = engine.stats(&decisions);
937
938        assert_eq!(stats.archive_count, 2);
939        assert_eq!(stats.keep_count, 1);
940    }
941
942    #[test]
943    fn stats_delete_count_correct() {
944        let mut engine = StorageRetentionPolicyEngine::new();
945        engine.add_rule(size_rule(1, 100, RetentionAction::Delete, 10));
946
947        let blocks = vec![
948            make_block("QmA", 200, 0, vec![], false), // Delete
949            make_block("QmB", 50, 0, vec![], false),  // Keep
950        ];
951        let decisions = engine.evaluate_all(&blocks, 0);
952        let stats = engine.stats(&decisions);
953
954        assert_eq!(stats.delete_count, 1);
955        assert_eq!(stats.keep_count, 1);
956    }
957
958    #[test]
959    fn stats_warn_count_correct() {
960        let mut engine = StorageRetentionPolicyEngine::new();
961        engine.add_rule(RetentionRule {
962            rule_id: 1,
963            name: "warn-old".to_string(),
964            max_age_secs: Some(100),
965            max_size_bytes: None,
966            required_tag: None,
967            action: RetentionAction::Warn {
968                reason: "stale".to_string(),
969            },
970            priority: 10,
971        });
972
973        let blocks = vec![
974            make_block("QmA", 100, 0, vec![], false), // age 200 > 100 → Warn
975            make_block("QmB", 100, 0, vec![], false), // age 200 > 100 → Warn
976        ];
977        let decisions = engine.evaluate_all(&blocks, 200);
978        let stats = engine.stats(&decisions);
979
980        assert_eq!(stats.warn_count, 2);
981        assert_eq!(stats.total_evaluated, 2);
982    }
983
984    // ── matched_rule_id ───────────────────────────────────────────────────────
985
986    #[test]
987    fn matched_rule_id_set_when_rule_matches() {
988        let mut engine = StorageRetentionPolicyEngine::new();
989        engine.add_rule(age_rule(99, 100, RetentionAction::Delete, 10));
990
991        let block = make_block("QmMatch", 512, 0, vec![], false);
992        let decision = engine.evaluate(&block, 200);
993
994        assert_eq!(decision.matched_rule_id, Some(99));
995    }
996
997    #[test]
998    fn matched_rule_id_none_when_no_rule_matches() {
999        let mut engine = StorageRetentionPolicyEngine::new();
1000        // Rule requires tag "special" but block has none.
1001        engine.add_rule(tag_rule(10, "special", RetentionAction::Delete, 10));
1002
1003        let block = make_block("QmNoMatch", 512, 0, vec![], false);
1004        let decision = engine.evaluate(&block, 1000);
1005
1006        assert_eq!(decision.action, RetentionAction::Keep);
1007        assert_eq!(decision.matched_rule_id, None);
1008    }
1009
1010    // ── Combined conditions (Engine) ────────────────────────────────────────
1011
1012    #[test]
1013    fn rule_with_all_conditions_requires_all_to_match() {
1014        let mut engine = StorageRetentionPolicyEngine::new();
1015        // Rule fires only when: old AND big AND tagged "hot".
1016        engine.add_rule(RetentionRule {
1017            rule_id: 77,
1018            name: "combined".to_string(),
1019            max_age_secs: Some(100),
1020            max_size_bytes: Some(1000),
1021            required_tag: Some("hot".to_string()),
1022            action: RetentionAction::Archive,
1023            priority: 10,
1024        });
1025
1026        let now = 300u64;
1027
1028        // All three conditions met → Archive.
1029        let full_match = make_block("QmFull", 2000, 0, vec!["hot"], false);
1030        assert_eq!(
1031            engine.evaluate(&full_match, now).action,
1032            RetentionAction::Archive
1033        );
1034
1035        // Missing tag → Keep.
1036        let no_tag = make_block("QmNoTag", 2000, 0, vec![], false);
1037        assert_eq!(engine.evaluate(&no_tag, now).action, RetentionAction::Keep);
1038
1039        // Too small → Keep.
1040        let small = make_block("QmSmall", 500, 0, vec!["hot"], false);
1041        assert_eq!(engine.evaluate(&small, now).action, RetentionAction::Keep);
1042
1043        // Too young → Keep.
1044        let young = make_block("QmYoung", 2000, 250, vec!["hot"], false);
1045        assert_eq!(engine.evaluate(&young, now).action, RetentionAction::Keep);
1046    }
1047
1048    // ══════════════════════════════════════════════════════════════════════════
1049    // StorageRetentionPolicy (tick-based) tests
1050    // ══════════════════════════════════════════════════════════════════════════
1051
1052    fn tick_age_rule(
1053        name: &str,
1054        max_age: u64,
1055        action: TickRetentionAction,
1056        priority: u32,
1057    ) -> TickRetentionRule {
1058        TickRetentionRule {
1059            name: name.to_string(),
1060            max_age_ticks: Some(max_age),
1061            max_size_bytes: None,
1062            action,
1063            priority,
1064        }
1065    }
1066
1067    fn tick_size_rule(
1068        name: &str,
1069        max_size: u64,
1070        action: TickRetentionAction,
1071        priority: u32,
1072    ) -> TickRetentionRule {
1073        TickRetentionRule {
1074            name: name.to_string(),
1075            max_age_ticks: None,
1076            max_size_bytes: Some(max_size),
1077            action,
1078            priority,
1079        }
1080    }
1081
1082    // ── Construction ─────────────────────────────────────────────────────────
1083
1084    #[test]
1085    fn tick_new_starts_empty() {
1086        let policy = StorageRetentionPolicy::new();
1087        assert_eq!(policy.entry_count(), 0);
1088        assert_eq!(policy.total_size_bytes(), 0);
1089        assert_eq!(policy.current_tick(), 0);
1090    }
1091
1092    #[test]
1093    fn tick_default_is_equivalent_to_new() {
1094        let policy = StorageRetentionPolicy::default();
1095        assert_eq!(policy.entry_count(), 0);
1096        assert_eq!(policy.current_tick(), 0);
1097    }
1098
1099    // ── add_entry / entry_count / total_size_bytes ──────────────────────────
1100
1101    #[test]
1102    fn tick_add_entry_increases_count() {
1103        let mut policy = StorageRetentionPolicy::new();
1104        policy.add_entry("QmA", 100, 0);
1105        assert_eq!(policy.entry_count(), 1);
1106        policy.add_entry("QmB", 200, 0);
1107        assert_eq!(policy.entry_count(), 2);
1108    }
1109
1110    #[test]
1111    fn tick_total_size_bytes_sums_entries() {
1112        let mut policy = StorageRetentionPolicy::new();
1113        policy.add_entry("QmA", 100, 0);
1114        policy.add_entry("QmB", 250, 0);
1115        assert_eq!(policy.total_size_bytes(), 350);
1116    }
1117
1118    #[test]
1119    fn tick_add_entry_overwrites_same_cid() {
1120        let mut policy = StorageRetentionPolicy::new();
1121        policy.add_entry("QmA", 100, 0);
1122        policy.add_entry("QmA", 999, 5);
1123        assert_eq!(policy.entry_count(), 1);
1124        assert_eq!(policy.total_size_bytes(), 999);
1125    }
1126
1127    // ── pin / unpin ─────────────────────────────────────────────────────────
1128
1129    #[test]
1130    fn tick_pin_returns_true_when_entry_exists() {
1131        let mut policy = StorageRetentionPolicy::new();
1132        policy.add_entry("QmA", 100, 0);
1133        assert!(policy.pin("QmA"));
1134    }
1135
1136    #[test]
1137    fn tick_pin_returns_false_when_entry_missing() {
1138        let mut policy = StorageRetentionPolicy::new();
1139        assert!(!policy.pin("QmNone"));
1140    }
1141
1142    #[test]
1143    fn tick_unpin_returns_true_when_entry_exists() {
1144        let mut policy = StorageRetentionPolicy::new();
1145        policy.add_entry("QmA", 100, 0);
1146        policy.pin("QmA");
1147        assert!(policy.unpin("QmA"));
1148    }
1149
1150    #[test]
1151    fn tick_unpin_returns_false_when_entry_missing() {
1152        let mut policy = StorageRetentionPolicy::new();
1153        assert!(!policy.unpin("QmNone"));
1154    }
1155
1156    // ── age-based deletion ──────────────────────────────────────────────────
1157
1158    #[test]
1159    fn tick_age_rule_deletes_old_blocks() {
1160        let mut policy = StorageRetentionPolicy::new();
1161        policy.add_rule(tick_age_rule(
1162            "delete-old",
1163            5,
1164            TickRetentionAction::Delete,
1165            10,
1166        ));
1167        policy.add_entry("QmOld", 100, 0);
1168
1169        // Advance 6 ticks → age = 6 > 5 → delete.
1170        for _ in 0..6 {
1171            policy.tick();
1172        }
1173        assert_eq!(policy.evaluate("QmOld"), TickRetentionAction::Delete);
1174    }
1175
1176    #[test]
1177    fn tick_age_rule_keeps_young_blocks() {
1178        let mut policy = StorageRetentionPolicy::new();
1179        policy.add_rule(tick_age_rule(
1180            "delete-old",
1181            10,
1182            TickRetentionAction::Delete,
1183            10,
1184        ));
1185        policy.add_entry("QmYoung", 100, 0);
1186
1187        // Only 3 ticks → age = 3 ≤ 10 → keep.
1188        for _ in 0..3 {
1189            policy.tick();
1190        }
1191        assert_eq!(policy.evaluate("QmYoung"), TickRetentionAction::Keep);
1192    }
1193
1194    #[test]
1195    fn tick_age_rule_boundary_keeps_at_exact_age() {
1196        let mut policy = StorageRetentionPolicy::new();
1197        policy.add_rule(tick_age_rule(
1198            "delete-old",
1199            5,
1200            TickRetentionAction::Delete,
1201            10,
1202        ));
1203        policy.add_entry("QmBoundary", 100, 0);
1204
1205        // Exactly 5 ticks → age == max_age → not strictly greater → keep.
1206        for _ in 0..5 {
1207            policy.tick();
1208        }
1209        assert_eq!(policy.evaluate("QmBoundary"), TickRetentionAction::Keep);
1210    }
1211
1212    // ── size-based deletion ─────────────────────────────────────────────────
1213
1214    #[test]
1215    fn tick_size_rule_deletes_when_total_exceeds_limit() {
1216        let mut policy = StorageRetentionPolicy::new();
1217        policy.add_rule(tick_size_rule(
1218            "size-limit",
1219            500,
1220            TickRetentionAction::Delete,
1221            10,
1222        ));
1223        policy.add_entry("QmA", 300, 0);
1224        policy.add_entry("QmB", 300, 0);
1225
1226        // Total = 600 > 500 → delete.
1227        assert_eq!(policy.evaluate("QmA"), TickRetentionAction::Delete);
1228    }
1229
1230    #[test]
1231    fn tick_size_rule_keeps_when_total_within_limit() {
1232        let mut policy = StorageRetentionPolicy::new();
1233        policy.add_rule(tick_size_rule(
1234            "size-limit",
1235            1000,
1236            TickRetentionAction::Delete,
1237            10,
1238        ));
1239        policy.add_entry("QmA", 100, 0);
1240
1241        // Total = 100 ≤ 1000 → keep.
1242        assert_eq!(policy.evaluate("QmA"), TickRetentionAction::Keep);
1243    }
1244
1245    #[test]
1246    fn tick_size_rule_boundary_keeps_at_exact_size() {
1247        let mut policy = StorageRetentionPolicy::new();
1248        policy.add_rule(tick_size_rule(
1249            "size-limit",
1250            500,
1251            TickRetentionAction::Delete,
1252            10,
1253        ));
1254        policy.add_entry("QmA", 250, 0);
1255        policy.add_entry("QmB", 250, 0);
1256
1257        // Total = 500 == max_size → not strictly greater → keep.
1258        assert_eq!(policy.evaluate("QmA"), TickRetentionAction::Keep);
1259    }
1260
1261    // ── pinned blocks protected ─────────────────────────────────────────────
1262
1263    #[test]
1264    fn tick_pinned_block_never_deleted() {
1265        let mut policy = StorageRetentionPolicy::new();
1266        policy.add_rule(tick_age_rule(
1267            "delete-old",
1268            0,
1269            TickRetentionAction::Delete,
1270            100,
1271        ));
1272        policy.add_entry("QmPinned", 100, 0);
1273        policy.pin("QmPinned");
1274        policy.tick();
1275
1276        assert_eq!(policy.evaluate("QmPinned"), TickRetentionAction::Keep);
1277    }
1278
1279    #[test]
1280    fn tick_pinned_block_survives_enforce() {
1281        let mut policy = StorageRetentionPolicy::new();
1282        policy.add_rule(tick_age_rule(
1283            "delete-old",
1284            0,
1285            TickRetentionAction::Delete,
1286            100,
1287        ));
1288        policy.add_entry("QmPinned", 100, 0);
1289        policy.pin("QmPinned");
1290        policy.tick();
1291
1292        let actions = policy.enforce();
1293        assert!(actions.is_empty());
1294        assert_eq!(policy.entry_count(), 1);
1295    }
1296
1297    #[test]
1298    fn tick_unpin_allows_deletion() {
1299        let mut policy = StorageRetentionPolicy::new();
1300        policy.add_rule(tick_age_rule(
1301            "delete-old",
1302            0,
1303            TickRetentionAction::Delete,
1304            100,
1305        ));
1306        policy.add_entry("QmBlock", 100, 0);
1307        policy.pin("QmBlock");
1308        policy.tick();
1309
1310        // While pinned → keep.
1311        assert_eq!(policy.evaluate("QmBlock"), TickRetentionAction::Keep);
1312
1313        // After unpin → delete.
1314        policy.unpin("QmBlock");
1315        assert_eq!(policy.evaluate("QmBlock"), TickRetentionAction::Delete);
1316    }
1317
1318    // ── rule priority ordering ──────────────────────────────────────────────
1319
1320    #[test]
1321    fn tick_higher_priority_rule_wins() {
1322        let mut policy = StorageRetentionPolicy::new();
1323        // High priority: archive.
1324        policy.add_rule(tick_age_rule(
1325            "archive-rule",
1326            0,
1327            TickRetentionAction::Archive,
1328            50,
1329        ));
1330        // Low priority: delete.
1331        policy.add_rule(tick_age_rule(
1332            "delete-rule",
1333            0,
1334            TickRetentionAction::Delete,
1335            10,
1336        ));
1337
1338        policy.add_entry("QmBlock", 100, 0);
1339        policy.tick();
1340
1341        // Archive (priority 50) should win over Delete (priority 10).
1342        assert_eq!(policy.evaluate("QmBlock"), TickRetentionAction::Archive);
1343    }
1344
1345    #[test]
1346    fn tick_add_rule_replaces_same_name() {
1347        let mut policy = StorageRetentionPolicy::new();
1348        policy.add_rule(tick_age_rule(
1349            "my-rule",
1350            100,
1351            TickRetentionAction::Delete,
1352            10,
1353        ));
1354        policy.add_rule(tick_age_rule(
1355            "my-rule",
1356            5,
1357            TickRetentionAction::Archive,
1358            20,
1359        ));
1360
1361        // Should only have one rule.
1362        let stats = policy.stats();
1363        assert_eq!(stats.rule_count, 1);
1364    }
1365
1366    // ── enforce removes entries ──────────────────────────────────────────────
1367
1368    #[test]
1369    fn tick_enforce_removes_deleted_entries() {
1370        let mut policy = StorageRetentionPolicy::new();
1371        policy.add_rule(tick_age_rule(
1372            "delete-old",
1373            5,
1374            TickRetentionAction::Delete,
1375            10,
1376        ));
1377        policy.add_entry("QmOld", 200, 0);
1378        policy.add_entry("QmNew", 100, 10);
1379
1380        // Advance to tick 10.
1381        for _ in 0..10 {
1382            policy.tick();
1383        }
1384
1385        let actions = policy.enforce();
1386
1387        // QmOld: age 10 > 5 → deleted. QmNew: age 0 ≤ 5 → kept.
1388        assert!(actions
1389            .iter()
1390            .any(|(c, a)| c == "QmOld" && *a == TickRetentionAction::Delete));
1391        assert_eq!(policy.entry_count(), 1);
1392    }
1393
1394    #[test]
1395    fn tick_enforce_returns_actions_taken() {
1396        let mut policy = StorageRetentionPolicy::new();
1397        policy.add_rule(tick_age_rule("del", 0, TickRetentionAction::Delete, 10));
1398        policy.add_entry("QmA", 50, 0);
1399        policy.add_entry("QmB", 75, 0);
1400        policy.tick();
1401
1402        let actions = policy.enforce();
1403        assert_eq!(actions.len(), 2);
1404        for (_, a) in &actions {
1405            assert_eq!(*a, TickRetentionAction::Delete);
1406        }
1407    }
1408
1409    // ── access updates tick ─────────────────────────────────────────────────
1410
1411    #[test]
1412    fn tick_access_updates_last_accessed_tick() {
1413        let mut policy = StorageRetentionPolicy::new();
1414        policy.add_entry("QmA", 100, 0);
1415
1416        for _ in 0..5 {
1417            policy.tick();
1418        }
1419        policy.access("QmA");
1420
1421        // Verify via the entry (indirectly — we test that the entry
1422        // still exists and the system is consistent).
1423        assert_eq!(policy.entry_count(), 1);
1424        // The internal last_accessed_tick should be 5, but we can't
1425        // read it directly, so verify via stats consistency.
1426        let stats = policy.stats();
1427        assert_eq!(stats.entry_count, 1);
1428    }
1429
1430    #[test]
1431    fn tick_access_nonexistent_is_noop() {
1432        let mut policy = StorageRetentionPolicy::new();
1433        policy.tick();
1434        policy.access("QmGhost");
1435        // Should not panic or change anything.
1436        assert_eq!(policy.entry_count(), 0);
1437    }
1438
1439    // ── archive action ──────────────────────────────────────────────────────
1440
1441    #[test]
1442    fn tick_archive_action_does_not_remove_entry() {
1443        let mut policy = StorageRetentionPolicy::new();
1444        policy.add_rule(tick_age_rule(
1445            "archive",
1446            2,
1447            TickRetentionAction::Archive,
1448            10,
1449        ));
1450        policy.add_entry("QmA", 100, 0);
1451
1452        for _ in 0..5 {
1453            policy.tick();
1454        }
1455
1456        let actions = policy.enforce();
1457        assert!(actions
1458            .iter()
1459            .any(|(c, a)| c == "QmA" && *a == TickRetentionAction::Archive));
1460        // Entry should still exist (only Delete removes it).
1461        assert_eq!(policy.entry_count(), 1);
1462    }
1463
1464    // ── multiple rules ──────────────────────────────────────────────────────
1465
1466    #[test]
1467    fn tick_multiple_rules_evaluated_in_order() {
1468        let mut policy = StorageRetentionPolicy::new();
1469        // Priority 30: archive if total > 50 bytes.
1470        policy.add_rule(tick_size_rule(
1471            "archive-big",
1472            50,
1473            TickRetentionAction::Archive,
1474            30,
1475        ));
1476        // Priority 20: delete if age > 3.
1477        policy.add_rule(tick_age_rule(
1478            "delete-old",
1479            3,
1480            TickRetentionAction::Delete,
1481            20,
1482        ));
1483
1484        policy.add_entry("QmA", 100, 0);
1485        for _ in 0..5 {
1486            policy.tick();
1487        }
1488
1489        // Both rules match, but archive (priority 30) wins.
1490        assert_eq!(policy.evaluate("QmA"), TickRetentionAction::Archive);
1491    }
1492
1493    #[test]
1494    fn tick_only_matching_rule_applied() {
1495        let mut policy = StorageRetentionPolicy::new();
1496        // Age rule won't match (block is too young).
1497        policy.add_rule(tick_age_rule(
1498            "delete-old",
1499            100,
1500            TickRetentionAction::Delete,
1501            50,
1502        ));
1503        // Size rule matches (total 200 > 50).
1504        policy.add_rule(tick_size_rule(
1505            "archive-big",
1506            50,
1507            TickRetentionAction::Archive,
1508            10,
1509        ));
1510
1511        policy.add_entry("QmA", 200, 0);
1512
1513        assert_eq!(policy.evaluate("QmA"), TickRetentionAction::Archive);
1514    }
1515
1516    // ── stats tracking ──────────────────────────────────────────────────────
1517
1518    #[test]
1519    fn tick_stats_initial_zeroes() {
1520        let policy = StorageRetentionPolicy::new();
1521        let stats = policy.stats();
1522        assert_eq!(stats.entry_count, 0);
1523        assert_eq!(stats.rule_count, 0);
1524        assert_eq!(stats.total_deleted, 0);
1525        assert_eq!(stats.total_archived, 0);
1526        assert_eq!(stats.bytes_freed, 0);
1527    }
1528
1529    #[test]
1530    fn tick_stats_tracks_deleted_count_and_bytes() {
1531        let mut policy = StorageRetentionPolicy::new();
1532        policy.add_rule(tick_age_rule("del", 0, TickRetentionAction::Delete, 10));
1533        policy.add_entry("QmA", 100, 0);
1534        policy.add_entry("QmB", 250, 0);
1535        policy.tick();
1536        policy.enforce();
1537
1538        let stats = policy.stats();
1539        assert_eq!(stats.total_deleted, 2);
1540        assert_eq!(stats.bytes_freed, 350);
1541    }
1542
1543    #[test]
1544    fn tick_stats_tracks_archived_count() {
1545        let mut policy = StorageRetentionPolicy::new();
1546        policy.add_rule(tick_age_rule("arch", 0, TickRetentionAction::Archive, 10));
1547        policy.add_entry("QmA", 100, 0);
1548        policy.tick();
1549        policy.enforce();
1550
1551        let stats = policy.stats();
1552        assert_eq!(stats.total_archived, 1);
1553        assert_eq!(stats.bytes_freed, 0); // Archive does not free bytes.
1554    }
1555
1556    #[test]
1557    fn tick_stats_rule_count() {
1558        let mut policy = StorageRetentionPolicy::new();
1559        policy.add_rule(tick_age_rule("r1", 10, TickRetentionAction::Delete, 10));
1560        policy.add_rule(tick_size_rule("r2", 500, TickRetentionAction::Archive, 5));
1561        assert_eq!(policy.stats().rule_count, 2);
1562    }
1563
1564    #[test]
1565    fn tick_stats_accumulate_across_enforcements() {
1566        let mut policy = StorageRetentionPolicy::new();
1567        policy.add_rule(tick_age_rule("del", 0, TickRetentionAction::Delete, 10));
1568
1569        // First enforcement: QmA created at tick 0, advance to tick 1 → age 1 > 0 → delete.
1570        policy.add_entry("QmA", 100, 0);
1571        policy.tick();
1572        policy.enforce();
1573
1574        // Second enforcement: QmB created at current tick (1), advance to tick 2 → age 1 > 0 → delete.
1575        let created = policy.current_tick();
1576        policy.add_entry("QmB", 200, created);
1577        policy.tick();
1578        policy.enforce();
1579
1580        let stats = policy.stats();
1581        assert_eq!(stats.total_deleted, 2);
1582        assert_eq!(stats.bytes_freed, 300);
1583    }
1584
1585    // ── empty policy ────────────────────────────────────────────────────────
1586
1587    #[test]
1588    fn tick_empty_policy_no_rules_keeps_everything() {
1589        let mut policy = StorageRetentionPolicy::new();
1590        policy.add_entry("QmA", 100, 0);
1591        policy.add_entry("QmB", 200, 0);
1592
1593        for _ in 0..100 {
1594            policy.tick();
1595        }
1596
1597        let actions = policy.enforce();
1598        assert!(actions.is_empty());
1599        assert_eq!(policy.entry_count(), 2);
1600    }
1601
1602    #[test]
1603    fn tick_empty_policy_enforce_returns_empty() {
1604        let mut policy = StorageRetentionPolicy::new();
1605        let actions = policy.enforce();
1606        assert!(actions.is_empty());
1607    }
1608
1609    // ── remove_rule ─────────────────────────────────────────────────────────
1610
1611    #[test]
1612    fn tick_remove_rule_returns_true_when_found() {
1613        let mut policy = StorageRetentionPolicy::new();
1614        policy.add_rule(tick_age_rule(
1615            "my-rule",
1616            10,
1617            TickRetentionAction::Delete,
1618            10,
1619        ));
1620        assert!(policy.remove_rule("my-rule"));
1621        assert_eq!(policy.stats().rule_count, 0);
1622    }
1623
1624    #[test]
1625    fn tick_remove_rule_returns_false_when_not_found() {
1626        let mut policy = StorageRetentionPolicy::new();
1627        assert!(!policy.remove_rule("nonexistent"));
1628    }
1629
1630    // ── evaluate nonexistent entry ──────────────────────────────────────────
1631
1632    #[test]
1633    fn tick_evaluate_nonexistent_returns_keep() {
1634        let policy = StorageRetentionPolicy::new();
1635        assert_eq!(policy.evaluate("QmNope"), TickRetentionAction::Keep);
1636    }
1637
1638    // ── tick advancement ────────────────────────────────────────────────────
1639
1640    #[test]
1641    fn tick_advances_clock() {
1642        let mut policy = StorageRetentionPolicy::new();
1643        assert_eq!(policy.current_tick(), 0);
1644        policy.tick();
1645        assert_eq!(policy.current_tick(), 1);
1646        policy.tick();
1647        policy.tick();
1648        assert_eq!(policy.current_tick(), 3);
1649    }
1650
1651    // ── rule without conditions never matches ───────────────────────────────
1652
1653    #[test]
1654    fn tick_rule_without_conditions_never_matches() {
1655        let mut policy = StorageRetentionPolicy::new();
1656        policy.add_rule(TickRetentionRule {
1657            name: "empty-rule".to_string(),
1658            max_age_ticks: None,
1659            max_size_bytes: None,
1660            action: TickRetentionAction::Delete,
1661            priority: 100,
1662        });
1663        policy.add_entry("QmA", 100, 0);
1664        policy.tick();
1665
1666        assert_eq!(policy.evaluate("QmA"), TickRetentionAction::Keep);
1667    }
1668}