274 lines
8.1 KiB
Rust
274 lines
8.1 KiB
Rust
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::VecDeque;
|
|
|
|
use super::ConfigLayer;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TraceEntry {
|
|
pub timestamp: DateTime<Utc>,
|
|
pub layer: ConfigLayer,
|
|
pub key: String,
|
|
pub action: String,
|
|
pub detail: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TraceRingBuffer {
|
|
capacity: usize,
|
|
entries: VecDeque<TraceEntry>,
|
|
}
|
|
|
|
impl TraceRingBuffer {
|
|
pub fn new(capacity: usize) -> Self {
|
|
Self { capacity, entries: VecDeque::with_capacity(capacity) }
|
|
}
|
|
|
|
pub fn push(&mut self, entry: TraceEntry) {
|
|
if self.entries.len() >= self.capacity {
|
|
self.entries.pop_front();
|
|
}
|
|
self.entries.push_back(entry);
|
|
}
|
|
|
|
pub fn record(&mut self, layer: ConfigLayer, key: &str, action: &str, detail: &str) {
|
|
self.push(TraceEntry {
|
|
timestamp: Utc::now(),
|
|
layer,
|
|
key: key.to_string(),
|
|
action: action.to_string(),
|
|
detail: detail.to_string(),
|
|
});
|
|
}
|
|
|
|
pub fn entries(&self) -> impl Iterator<Item = &TraceEntry> {
|
|
self.entries.iter()
|
|
}
|
|
|
|
pub fn len(&self) -> usize {
|
|
self.entries.len()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.entries.is_empty()
|
|
}
|
|
|
|
pub fn capacity(&self) -> usize {
|
|
self.capacity
|
|
}
|
|
|
|
pub fn clear(&mut self) {
|
|
self.entries.clear();
|
|
}
|
|
|
|
pub fn filter_by_layer(&self, layer: ConfigLayer) -> Vec<&TraceEntry> {
|
|
self.entries.iter().filter(|e| e.layer == layer).collect()
|
|
}
|
|
|
|
pub fn filter_by_action(&self, action: &str) -> Vec<&TraceEntry> {
|
|
self.entries.iter().filter(|e| e.action == action).collect()
|
|
}
|
|
|
|
pub fn export_json(&self) -> Result<String, String> {
|
|
serde_json::to_string_pretty(&self.entries)
|
|
.map_err(|e| format!("Failed to serialize {} trace entries: {e}", self.entries.len()))
|
|
}
|
|
|
|
pub fn recent(&self, n: usize) -> Vec<&TraceEntry> {
|
|
let n = n.min(self.entries.len());
|
|
self.entries.iter().rev().take(n).collect()
|
|
}
|
|
|
|
pub fn stats(&self) -> TraceStats {
|
|
let mut action_counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
|
let mut layer_counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
|
|
|
for entry in &self.entries {
|
|
*action_counts.entry(entry.action.clone()).or_insert(0) += 1;
|
|
*layer_counts.entry(entry.layer.label().to_string()).or_insert(0) += 1;
|
|
}
|
|
|
|
TraceStats { total_entries: self.entries.len(), capacity: self.capacity, action_counts, layer_counts }
|
|
}
|
|
}
|
|
|
|
impl Default for TraceRingBuffer {
|
|
fn default() -> Self {
|
|
Self::new(1000)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TraceStats {
|
|
pub total_entries: usize,
|
|
pub capacity: usize,
|
|
pub action_counts: std::collections::HashMap<String, usize>,
|
|
pub layer_counts: std::collections::HashMap<String, usize>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use chrono::TimeZone;
|
|
|
|
fn sample_entry() -> TraceEntry {
|
|
TraceEntry {
|
|
timestamp: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
|
|
layer: ConfigLayer::Global,
|
|
key: "host".to_string(),
|
|
action: "read".to_string(),
|
|
detail: "value resolved".to_string(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_new_buffer_is_empty() {
|
|
let buf = TraceRingBuffer::new(10);
|
|
assert!(buf.is_empty());
|
|
assert_eq!(buf.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_push_adds_entry() {
|
|
let mut buf = TraceRingBuffer::new(10);
|
|
buf.push(sample_entry());
|
|
assert_eq!(buf.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_push_evicts_oldest_when_full() {
|
|
let mut buf = TraceRingBuffer::new(3);
|
|
for i in 0..5 {
|
|
buf.push(TraceEntry {
|
|
timestamp: Utc::now(),
|
|
layer: ConfigLayer::Task,
|
|
key: format!("key{i}"),
|
|
action: "write".to_string(),
|
|
detail: String::new(),
|
|
});
|
|
}
|
|
assert_eq!(buf.len(), 3);
|
|
let entries: Vec<&TraceEntry> = buf.entries().collect();
|
|
assert_eq!(entries[0].key, "key2");
|
|
assert_eq!(entries[2].key, "key4");
|
|
}
|
|
|
|
#[test]
|
|
fn test_record_convenience() {
|
|
let mut buf = TraceRingBuffer::new(10);
|
|
buf.record(ConfigLayer::Global, "host", "read", "localhost");
|
|
assert_eq!(buf.len(), 1);
|
|
let entry = buf.entries().next().unwrap();
|
|
assert_eq!(entry.layer, ConfigLayer::Global);
|
|
assert_eq!(entry.key, "host");
|
|
assert_eq!(entry.action, "read");
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_by_layer() {
|
|
let mut buf = TraceRingBuffer::new(10);
|
|
buf.record(ConfigLayer::Global, "host", "read", "");
|
|
buf.record(ConfigLayer::Project, "port", "write", "");
|
|
buf.record(ConfigLayer::Global, "user", "read", "");
|
|
|
|
let global_entries = buf.filter_by_layer(ConfigLayer::Global);
|
|
assert_eq!(global_entries.len(), 2);
|
|
|
|
let project_entries = buf.filter_by_layer(ConfigLayer::Project);
|
|
assert_eq!(project_entries.len(), 1);
|
|
|
|
let task_entries = buf.filter_by_layer(ConfigLayer::Task);
|
|
assert_eq!(task_entries.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_filter_by_action() {
|
|
let mut buf = TraceRingBuffer::new(10);
|
|
buf.record(ConfigLayer::Global, "host", "read", "");
|
|
buf.record(ConfigLayer::Global, "port", "write", "");
|
|
buf.record(ConfigLayer::Global, "user", "read", "");
|
|
|
|
let reads = buf.filter_by_action("read");
|
|
assert_eq!(reads.len(), 2);
|
|
|
|
let writes = buf.filter_by_action("write");
|
|
assert_eq!(writes.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_export_json() {
|
|
let mut buf = TraceRingBuffer::new(10);
|
|
buf.push(sample_entry());
|
|
let json = buf.export_json().unwrap();
|
|
assert!(json.contains("host"));
|
|
assert!(json.contains("read"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_recent() {
|
|
let mut buf = TraceRingBuffer::new(10);
|
|
for i in 0..5 {
|
|
buf.record(ConfigLayer::Task, &format!("key{i}"), "write", "");
|
|
}
|
|
let recent = buf.recent(3);
|
|
assert_eq!(recent.len(), 3);
|
|
assert_eq!(recent[0].key, "key4");
|
|
assert_eq!(recent[2].key, "key2");
|
|
}
|
|
|
|
#[test]
|
|
fn test_recent_less_than_n() {
|
|
let mut buf = TraceRingBuffer::new(10);
|
|
buf.record(ConfigLayer::Task, "k1", "read", "");
|
|
let recent = buf.recent(10);
|
|
assert_eq!(recent.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_clear() {
|
|
let mut buf = TraceRingBuffer::new(10);
|
|
buf.record(ConfigLayer::Task, "k1", "read", "");
|
|
buf.record(ConfigLayer::Task, "k2", "write", "");
|
|
assert_eq!(buf.len(), 2);
|
|
buf.clear();
|
|
assert!(buf.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_stats() {
|
|
let mut buf = TraceRingBuffer::new(100);
|
|
buf.record(ConfigLayer::Global, "host", "read", "");
|
|
buf.record(ConfigLayer::Global, "port", "read", "");
|
|
buf.record(ConfigLayer::Project, "config", "write", "");
|
|
|
|
let stats = buf.stats();
|
|
assert_eq!(stats.total_entries, 3);
|
|
assert_eq!(*stats.action_counts.get("read").unwrap(), 2);
|
|
assert_eq!(*stats.action_counts.get("write").unwrap(), 1);
|
|
assert_eq!(*stats.layer_counts.get("global").unwrap(), 2);
|
|
assert_eq!(*stats.layer_counts.get("project").unwrap(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_default_capacity() {
|
|
let buf = TraceRingBuffer::default();
|
|
assert_eq!(buf.capacity(), 1000);
|
|
}
|
|
|
|
#[test]
|
|
fn test_capacity_set_correctly() {
|
|
let buf = TraceRingBuffer::new(500);
|
|
assert_eq!(buf.capacity(), 500);
|
|
}
|
|
|
|
#[test]
|
|
fn test_entry_timestamp_is_set() {
|
|
let mut buf = TraceRingBuffer::new(10);
|
|
buf.record(ConfigLayer::Task, "k", "read", "detail");
|
|
let entry = buf.entries().next().unwrap();
|
|
let now = Utc::now();
|
|
let diff = now - entry.timestamp;
|
|
assert!(diff.num_seconds() < 5);
|
|
}
|
|
}
|