Skip to main content

bamboo_memory/memory_store/
freshness.rs

1use chrono::Utc;
2
3use super::parse_rfc3339;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum FreshnessKind {
7    Index,
8    RecalledMemory,
9    StateLikeClaim,
10}
11
12pub fn memory_age_days(updated_at: &str) -> Option<i64> {
13    let updated_at = parse_rfc3339(updated_at)?;
14    let now = Utc::now();
15    let clamped = if updated_at > now { now } else { updated_at };
16    Some((now - clamped).num_days())
17}
18
19pub fn memory_age_label(updated_at: &str) -> Option<String> {
20    let days = memory_age_days(updated_at)?;
21    Some(match days {
22        0 => "today".to_string(),
23        1 => "1 day old".to_string(),
24        _ => format!("{days} days old"),
25    })
26}
27
28pub fn memory_freshness_text(updated_at: &str, kind: FreshnessKind) -> Option<String> {
29    let days = memory_age_days(updated_at)?;
30    if days <= 1 {
31        return None;
32    }
33
34    let age_label = memory_age_label(updated_at)?;
35    let text = match kind {
36        FreshnessKind::Index => {
37            if days <= 7 {
38                format!(
39                    "Historical memory index entry ({age_label}); verify against current tools/files before treating it as live project state."
40                )
41            } else {
42                format!(
43                    "Older memory index entry ({age_label}); verify against current tools/files before treating it as current project truth."
44                )
45            }
46        }
47        FreshnessKind::RecalledMemory => {
48            if days <= 7 {
49                format!(
50                    "Historical memory ({age_label}); verify against current task context before treating it as still current."
51                )
52            } else {
53                format!(
54                    "Older historical memory ({age_label}); verify against current tools/files before relying on it."
55                )
56            }
57        }
58        FreshnessKind::StateLikeClaim => {
59            if days <= 7 {
60                format!(
61                    "Historical state-like memory ({age_label}); verify against current code/config before asserting it as fact."
62                )
63            } else {
64                format!(
65                    "Older state-like memory ({age_label}); verify against current code/config before asserting it as fact."
66                )
67            }
68        }
69    };
70
71    Some(text)
72}
73
74pub fn render_memory_freshness_note(updated_at: &str, kind: FreshnessKind) -> Option<String> {
75    memory_freshness_text(updated_at, kind).map(|text| format!("Note: {text}"))
76}
77
78#[cfg(test)]
79mod tests {
80    use chrono::{Duration, Utc};
81
82    use super::*;
83
84    #[test]
85    fn same_day_timestamp_has_no_warning() {
86        let timestamp = Utc::now().to_rfc3339();
87        assert_eq!(memory_age_days(&timestamp), Some(0));
88        assert_eq!(memory_age_label(&timestamp).as_deref(), Some("today"));
89        assert!(memory_freshness_text(&timestamp, FreshnessKind::Index).is_none());
90    }
91
92    #[test]
93    fn multi_day_timestamp_gets_warning_and_age_label() {
94        let timestamp = (Utc::now() - Duration::days(3)).to_rfc3339();
95        assert_eq!(memory_age_days(&timestamp), Some(3));
96        assert_eq!(memory_age_label(&timestamp).as_deref(), Some("3 days old"));
97        let text = memory_freshness_text(&timestamp, FreshnessKind::RecalledMemory)
98            .expect("warning expected");
99        assert!(text.contains("3 days old"));
100        assert!(text.contains("Historical memory"));
101    }
102
103    #[test]
104    fn invalid_timestamp_degrades_safely() {
105        assert!(memory_age_days("not-a-timestamp").is_none());
106        assert!(memory_age_label("not-a-timestamp").is_none());
107        assert!(memory_freshness_text("not-a-timestamp", FreshnessKind::Index).is_none());
108        assert!(render_memory_freshness_note("not-a-timestamp", FreshnessKind::Index).is_none());
109    }
110
111    #[test]
112    fn future_timestamp_is_clamped_to_zero_age() {
113        let timestamp = (Utc::now() + Duration::days(5)).to_rfc3339();
114        assert_eq!(memory_age_days(&timestamp), Some(0));
115        assert_eq!(memory_age_label(&timestamp).as_deref(), Some("today"));
116        assert!(memory_freshness_text(&timestamp, FreshnessKind::Index).is_none());
117    }
118
119    #[test]
120    fn state_like_claim_uses_stronger_wording_than_index() {
121        let timestamp = (Utc::now() - Duration::days(10)).to_rfc3339();
122        let index_text = memory_freshness_text(&timestamp, FreshnessKind::Index)
123            .expect("index warning expected");
124        let state_text = memory_freshness_text(&timestamp, FreshnessKind::StateLikeClaim)
125            .expect("state-like warning expected");
126        assert!(index_text.contains("current project truth"));
127        assert!(state_text.contains("current code/config"));
128        assert_ne!(index_text, state_text);
129    }
130}