bamboo_memory/memory_store/
freshness.rs1use 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(×tamp), Some(0));
88 assert_eq!(memory_age_label(×tamp).as_deref(), Some("today"));
89 assert!(memory_freshness_text(×tamp, 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(×tamp), Some(3));
96 assert_eq!(memory_age_label(×tamp).as_deref(), Some("3 days old"));
97 let text = memory_freshness_text(×tamp, 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(×tamp), Some(0));
115 assert_eq!(memory_age_label(×tamp).as_deref(), Some("today"));
116 assert!(memory_freshness_text(×tamp, 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(×tamp, FreshnessKind::Index)
123 .expect("index warning expected");
124 let state_text = memory_freshness_text(×tamp, 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}