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) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user