From 2074677278a23c94e629e29bf14c25c47f610c1b Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 15:41:13 -0400 Subject: [PATCH] feat(core): add Item::prune_history honoring retention policy Forever, LastN, and Days policies all covered. Tests verify drop order (keeps newest), days cutoff, and forever-no-op semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/item.rs | 76 ++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/crates/idfoto-core/src/item.rs b/crates/idfoto-core/src/item.rs index 3237e03..2f7fbbc 100644 --- a/crates/idfoto-core/src/item.rs +++ b/crates/idfoto-core/src/item.rs @@ -215,6 +215,26 @@ impl Item { pub fn is_trashed(&self) -> bool { self.trashed_at.is_some() } + + pub fn prune_history(&mut self, retention: &crate::settings::HistoryRetention, now: i64) { + use crate::settings::HistoryRetention; + for history in self.field_history.values_mut() { + match retention { + HistoryRetention::Forever => {} + HistoryRetention::LastN(n) => { + let n = *n as usize; + if history.len() > n { + let drop_count = history.len() - n; + history.drain(..drop_count); + } + } + HistoryRetention::Days(d) => { + let cutoff = now - (*d as i64) * 86_400; + history.retain(|e| e.replaced_at > cutoff); + } + } + } + } } /// Serialize a FieldValue to the string form stored in field_history. @@ -418,4 +438,60 @@ mod tests { other => panic!("expected Login, got {:?}", other), } } + + #[test] + fn prune_history_keeps_last_n() { + use crate::settings::HistoryRetention; + + let core = ItemCore::Login(crate::item_types::LoginCore::default()); + let mut item = Item::new("x".into(), core); + let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into()))); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + + for i in 1..=5 { + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}").into()))).unwrap(); + } + assert_eq!(item.field_history[&fid].len(), 5); + + item.prune_history(&HistoryRetention::LastN(3), 0); + assert_eq!(item.field_history[&fid].len(), 3); + // Keeps the MOST RECENT 3 + assert_eq!(item.field_history[&fid][0].value.as_str(), "v2"); + } + + #[test] + fn prune_history_drops_old_entries_by_days() { + use crate::settings::HistoryRetention; + + let core = ItemCore::Login(crate::item_types::LoginCore::default()); + let mut item = Item::new("x".into(), core); + let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into()))); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + + let now = 1_000_000_000; + item.field_history.insert(fid.clone(), vec![ + FieldHistoryEntry { value: Zeroizing::new("old".into()), replaced_at: now - 100 * 86_400 }, + FieldHistoryEntry { value: Zeroizing::new("recent".into()), replaced_at: now - 1 * 86_400 }, + ]); + + item.prune_history(&HistoryRetention::Days(30), now); + assert_eq!(item.field_history[&fid].len(), 1); + assert_eq!(item.field_history[&fid][0].value.as_str(), "recent"); + } + + #[test] + fn prune_history_forever_keeps_all() { + use crate::settings::HistoryRetention; + + let core = ItemCore::Login(crate::item_types::LoginCore::default()); + let mut item = Item::new("x".into(), core); + item.field_history.insert(FieldId::new(), vec![ + FieldHistoryEntry { value: Zeroizing::new("a".into()), replaced_at: 0 }, + FieldHistoryEntry { value: Zeroizing::new("b".into()), replaced_at: 0 }, + ]); + item.prune_history(&HistoryRetention::Forever, 1_000_000_000); + assert_eq!(item.field_history.values().next().unwrap().len(), 2); + } }