#![allow(dead_code)]
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct AuditEntryId(pub u64);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ChangeType {
Created,
Deleted,
NameChanged,
TrimChanged,
RatingChanged,
KeywordAdded,
KeywordRemoved,
TagAssigned,
TagRemoved,
BinMoved,
MetadataUpdated,
NoteChanged,
ColorLabelChanged,
}
impl std::fmt::Display for ChangeType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Created => write!(f, "Created"),
Self::Deleted => write!(f, "Deleted"),
Self::NameChanged => write!(f, "Name Changed"),
Self::TrimChanged => write!(f, "Trim Changed"),
Self::RatingChanged => write!(f, "Rating Changed"),
Self::KeywordAdded => write!(f, "Keyword Added"),
Self::KeywordRemoved => write!(f, "Keyword Removed"),
Self::TagAssigned => write!(f, "Tag Assigned"),
Self::TagRemoved => write!(f, "Tag Removed"),
Self::BinMoved => write!(f, "Bin Moved"),
Self::MetadataUpdated => write!(f, "Metadata Updated"),
Self::NoteChanged => write!(f, "Note Changed"),
Self::ColorLabelChanged => write!(f, "Color Label Changed"),
}
}
}
#[derive(Debug, Clone)]
pub struct AuditEntry {
pub id: AuditEntryId,
pub clip_id: u64,
pub change_type: ChangeType,
pub user: String,
pub timestamp: u64,
pub old_value: Option<String>,
pub new_value: Option<String>,
pub description: String,
}
impl AuditEntry {
#[must_use]
pub fn new(
id: AuditEntryId,
clip_id: u64,
change_type: ChangeType,
user: impl Into<String>,
timestamp: u64,
) -> Self {
Self {
id,
clip_id,
change_type,
user: user.into(),
timestamp,
old_value: None,
new_value: None,
description: String::new(),
}
}
pub fn with_values(mut self, old: impl Into<String>, new: impl Into<String>) -> Self {
self.old_value = Some(old.into());
self.new_value = Some(new.into());
self
}
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = desc.into();
self
}
#[must_use]
pub fn is_undoable(&self) -> bool {
self.old_value.is_some() && self.new_value.is_some()
}
}
#[derive(Debug, Clone, Default)]
pub struct AuditFilter {
pub clip_id: Option<u64>,
pub change_type: Option<ChangeType>,
pub user: Option<String>,
pub after_timestamp: Option<u64>,
pub before_timestamp: Option<u64>,
pub limit: Option<usize>,
}
impl AuditFilter {
#[must_use]
pub fn for_clip(clip_id: u64) -> Self {
Self {
clip_id: Some(clip_id),
..Default::default()
}
}
#[must_use]
pub fn for_user(user: impl Into<String>) -> Self {
Self {
user: Some(user.into()),
..Default::default()
}
}
#[must_use]
pub fn in_time_range(mut self, after: u64, before: u64) -> Self {
self.after_timestamp = Some(after);
self.before_timestamp = Some(before);
self
}
#[must_use]
pub fn with_limit(mut self, limit: usize) -> Self {
self.limit = Some(limit);
self
}
#[must_use]
pub fn matches(&self, entry: &AuditEntry) -> bool {
if let Some(cid) = self.clip_id {
if entry.clip_id != cid {
return false;
}
}
if let Some(ref ct) = self.change_type {
if entry.change_type != *ct {
return false;
}
}
if let Some(ref u) = self.user {
if entry.user != *u {
return false;
}
}
if let Some(after) = self.after_timestamp {
if entry.timestamp < after {
return false;
}
}
if let Some(before) = self.before_timestamp {
if entry.timestamp > before {
return false;
}
}
true
}
}
#[derive(Debug, Clone)]
pub struct AuditSummary {
pub total_changes: usize,
pub changes_by_type: HashMap<String, usize>,
pub changes_by_user: HashMap<String, usize>,
pub unique_clips: usize,
}
#[derive(Debug)]
pub struct AuditTrail {
entries: Vec<AuditEntry>,
clip_index: HashMap<u64, Vec<usize>>,
next_id: u64,
}
impl Default for AuditTrail {
fn default() -> Self {
Self::new()
}
}
impl AuditTrail {
#[must_use]
pub fn new() -> Self {
Self {
entries: Vec::new(),
clip_index: HashMap::new(),
next_id: 1,
}
}
pub fn record(
&mut self,
clip_id: u64,
change_type: ChangeType,
user: impl Into<String>,
timestamp: u64,
) -> AuditEntryId {
let id = AuditEntryId(self.next_id);
self.next_id += 1;
let entry = AuditEntry::new(id, clip_id, change_type, user, timestamp);
let index = self.entries.len();
self.entries.push(entry);
self.clip_index.entry(clip_id).or_default().push(index);
id
}
pub fn record_with_values(
&mut self,
clip_id: u64,
change_type: ChangeType,
user: impl Into<String>,
timestamp: u64,
old_value: impl Into<String>,
new_value: impl Into<String>,
) -> AuditEntryId {
let id = AuditEntryId(self.next_id);
self.next_id += 1;
let entry = AuditEntry::new(id, clip_id, change_type, user, timestamp)
.with_values(old_value, new_value);
let index = self.entries.len();
self.entries.push(entry);
self.clip_index.entry(clip_id).or_default().push(index);
id
}
#[must_use]
pub fn get_entry(&self, id: AuditEntryId) -> Option<&AuditEntry> {
self.entries.iter().find(|e| e.id == id)
}
#[must_use]
pub fn entry_count(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn history_for_clip(&self, clip_id: u64) -> Vec<&AuditEntry> {
self.clip_index
.get(&clip_id)
.map(|indices| indices.iter().map(|&i| &self.entries[i]).collect())
.unwrap_or_default()
}
#[must_use]
pub fn query(&self, filter: &AuditFilter) -> Vec<&AuditEntry> {
let mut results: Vec<&AuditEntry> =
self.entries.iter().filter(|e| filter.matches(e)).collect();
results.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
if let Some(limit) = filter.limit {
results.truncate(limit);
}
results
}
#[must_use]
pub fn summarize(&self, filter: &AuditFilter) -> AuditSummary {
let matching: Vec<&AuditEntry> =
self.entries.iter().filter(|e| filter.matches(e)).collect();
let mut changes_by_type: HashMap<String, usize> = HashMap::new();
let mut changes_by_user: HashMap<String, usize> = HashMap::new();
let mut unique_clips = std::collections::HashSet::new();
for entry in &matching {
*changes_by_type
.entry(entry.change_type.to_string())
.or_insert(0) += 1;
*changes_by_user.entry(entry.user.clone()).or_insert(0) += 1;
unique_clips.insert(entry.clip_id);
}
AuditSummary {
total_changes: matching.len(),
changes_by_type,
changes_by_user,
unique_clips: unique_clips.len(),
}
}
#[must_use]
pub fn recent_entries(&self, count: usize) -> Vec<&AuditEntry> {
let mut entries: Vec<&AuditEntry> = self.entries.iter().collect();
entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
entries.truncate(count);
entries
}
pub fn purge_before(&mut self, timestamp: u64) {
self.entries.retain(|e| e.timestamp >= timestamp);
self.clip_index.clear();
for (i, entry) in self.entries.iter().enumerate() {
self.clip_index.entry(entry.clip_id).or_default().push(i);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_change_type_display() {
assert_eq!(ChangeType::Created.to_string(), "Created");
assert_eq!(ChangeType::NameChanged.to_string(), "Name Changed");
assert_eq!(ChangeType::TrimChanged.to_string(), "Trim Changed");
}
#[test]
fn test_audit_entry_new() {
let entry = AuditEntry::new(AuditEntryId(1), 100, ChangeType::Created, "admin", 1000);
assert_eq!(entry.id, AuditEntryId(1));
assert_eq!(entry.clip_id, 100);
assert_eq!(entry.user, "admin");
}
#[test]
fn test_audit_entry_with_values() {
let entry = AuditEntry::new(
AuditEntryId(1),
100,
ChangeType::NameChanged,
"editor",
1000,
)
.with_values("old-name", "new-name");
assert_eq!(entry.old_value.as_deref(), Some("old-name"));
assert_eq!(entry.new_value.as_deref(), Some("new-name"));
assert!(entry.is_undoable());
}
#[test]
fn test_audit_entry_not_undoable() {
let entry = AuditEntry::new(AuditEntryId(1), 100, ChangeType::Created, "admin", 1000);
assert!(!entry.is_undoable());
}
#[test]
fn test_audit_entry_with_description() {
let entry = AuditEntry::new(AuditEntryId(1), 100, ChangeType::Deleted, "admin", 1000)
.with_description("Removed duplicate clip");
assert_eq!(entry.description, "Removed duplicate clip");
}
#[test]
fn test_audit_filter_matches() {
let entry = AuditEntry::new(
AuditEntryId(1),
100,
ChangeType::RatingChanged,
"editor",
5000,
);
let filter = AuditFilter::for_clip(100);
assert!(filter.matches(&entry));
let filter2 = AuditFilter::for_clip(999);
assert!(!filter2.matches(&entry));
}
#[test]
fn test_audit_filter_time_range() {
let entry = AuditEntry::new(AuditEntryId(1), 100, ChangeType::Created, "admin", 5000);
let filter = AuditFilter::default().in_time_range(4000, 6000);
assert!(filter.matches(&entry));
let filter2 = AuditFilter::default().in_time_range(6000, 7000);
assert!(!filter2.matches(&entry));
}
#[test]
fn test_audit_filter_user() {
let entry = AuditEntry::new(AuditEntryId(1), 100, ChangeType::Created, "admin", 1000);
let filter = AuditFilter::for_user("admin");
assert!(filter.matches(&entry));
let filter2 = AuditFilter::for_user("editor");
assert!(!filter2.matches(&entry));
}
#[test]
fn test_trail_record() {
let mut trail = AuditTrail::new();
let id = trail.record(100, ChangeType::Created, "admin", 1000);
assert_eq!(trail.entry_count(), 1);
let entry = trail.get_entry(id).expect("get_entry should succeed");
assert_eq!(entry.clip_id, 100);
}
#[test]
fn test_trail_record_with_values() {
let mut trail = AuditTrail::new();
let id = trail.record_with_values(
100,
ChangeType::NameChanged,
"editor",
2000,
"clip-a",
"clip-b",
);
let entry = trail.get_entry(id).expect("get_entry should succeed");
assert!(entry.is_undoable());
}
#[test]
fn test_trail_history_for_clip() {
let mut trail = AuditTrail::new();
trail.record(100, ChangeType::Created, "admin", 1000);
trail.record(100, ChangeType::NameChanged, "editor", 2000);
trail.record(200, ChangeType::Created, "admin", 1500);
let history = trail.history_for_clip(100);
assert_eq!(history.len(), 2);
}
#[test]
fn test_trail_query_with_filter() {
let mut trail = AuditTrail::new();
trail.record(100, ChangeType::Created, "admin", 1000);
trail.record(100, ChangeType::RatingChanged, "editor", 2000);
trail.record(200, ChangeType::Created, "admin", 3000);
let filter = AuditFilter::for_clip(100);
let results = trail.query(&filter);
assert_eq!(results.len(), 2);
}
#[test]
fn test_trail_query_with_limit() {
let mut trail = AuditTrail::new();
for i in 0..10 {
trail.record(100, ChangeType::MetadataUpdated, "bot", 1000 + i);
}
let filter = AuditFilter::for_clip(100).with_limit(3);
let results = trail.query(&filter);
assert_eq!(results.len(), 3);
}
#[test]
fn test_trail_summarize() {
let mut trail = AuditTrail::new();
trail.record(100, ChangeType::Created, "admin", 1000);
trail.record(100, ChangeType::RatingChanged, "editor", 2000);
trail.record(200, ChangeType::Created, "admin", 1500);
let summary = trail.summarize(&AuditFilter::default());
assert_eq!(summary.total_changes, 3);
assert_eq!(summary.unique_clips, 2);
assert_eq!(
*summary
.changes_by_type
.get("Created")
.expect("get should succeed"),
2
);
assert_eq!(
*summary
.changes_by_user
.get("admin")
.expect("get should succeed"),
2
);
}
#[test]
fn test_trail_recent_entries() {
let mut trail = AuditTrail::new();
trail.record(100, ChangeType::Created, "admin", 1000);
trail.record(200, ChangeType::Created, "admin", 3000);
trail.record(300, ChangeType::Created, "admin", 2000);
let recent = trail.recent_entries(2);
assert_eq!(recent.len(), 2);
assert_eq!(recent[0].clip_id, 200); }
#[test]
fn test_trail_purge_before() {
let mut trail = AuditTrail::new();
trail.record(100, ChangeType::Created, "admin", 1000);
trail.record(200, ChangeType::Created, "admin", 2000);
trail.record(300, ChangeType::Created, "admin", 3000);
trail.purge_before(2000);
assert_eq!(trail.entry_count(), 2);
let history = trail.history_for_clip(100);
assert!(history.is_empty());
}
}