use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::parse_rfc3339;
pub(crate) const ACCESS_LOG_FILE: &str = "access_log.jsonl";
pub(crate) const ACCESS_LOG_COMPACT_TRIGGER_BYTES: u64 = 256 * 1024;
const ACCESS_LOG_AGING_WINDOW_DAYS: i64 = 90;
const ACCESS_LOG_MAX_RETAINED_ENTRIES: usize = 20_000;
const ACCESS_DECAY_HALF_LIFE_DAYS: f64 = 30.0;
const ACCESS_MULTIPLIER_WEIGHT: f64 = 1.0;
const ACCESS_SATURATION_K: f64 = 3.0;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct AccessLogEntry {
pub id: String,
pub ts: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub(crate) struct AccessStats {
pub count: usize,
pub score: f64,
}
pub(crate) fn parse_access_log(raw: &str) -> Vec<AccessLogEntry> {
raw.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
serde_json::from_str::<AccessLogEntry>(trimmed).ok()
})
.collect()
}
pub(crate) fn aggregate_access_stats(
entries: &[AccessLogEntry],
now: DateTime<Utc>,
) -> HashMap<String, AccessStats> {
let mut stats: HashMap<String, AccessStats> = HashMap::new();
for entry in entries {
let id = entry.id.trim();
if id.is_empty() {
continue;
}
let Some(ts) = parse_rfc3339(&entry.ts) else {
continue;
};
let age_days = (now - ts).num_days().max(0) as f64;
let weight = 0.5_f64.powf(age_days / ACCESS_DECAY_HALF_LIFE_DAYS);
let entry_stats = stats.entry(id.to_string()).or_default();
entry_stats.count += 1;
entry_stats.score += weight;
}
stats
}
pub(crate) fn access_multiplier(stats: Option<&AccessStats>) -> f64 {
let score = stats.map(|value| value.score).unwrap_or(0.0);
if score <= 0.0 {
return 1.0;
}
let normalized = score / (score + ACCESS_SATURATION_K);
1.0 + ACCESS_MULTIPLIER_WEIGHT * normalized
}
pub(crate) fn compact_entries(
mut entries: Vec<AccessLogEntry>,
now: DateTime<Utc>,
) -> Vec<AccessLogEntry> {
entries.retain(|entry| {
parse_rfc3339(&entry.ts)
.map(|ts| (now - ts).num_days() <= ACCESS_LOG_AGING_WINDOW_DAYS)
.unwrap_or(false)
});
if entries.len() > ACCESS_LOG_MAX_RETAINED_ENTRIES {
let drop_count = entries.len() - ACCESS_LOG_MAX_RETAINED_ENTRIES;
entries.drain(0..drop_count);
}
entries
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(id: &str, ts: DateTime<Utc>) -> AccessLogEntry {
AccessLogEntry {
id: id.to_string(),
ts: ts.to_rfc3339(),
}
}
#[test]
fn multiplier_is_neutral_with_no_data() {
assert_eq!(access_multiplier(None), 1.0);
assert_eq!(access_multiplier(Some(&AccessStats::default())), 1.0);
}
#[test]
fn multiplier_increases_monotonically_with_score_and_stays_bounded() {
let low = access_multiplier(Some(&AccessStats {
count: 1,
score: 0.5,
}));
let mid = access_multiplier(Some(&AccessStats {
count: 5,
score: 3.0,
}));
let high = access_multiplier(Some(&AccessStats {
count: 500,
score: 10_000.0,
}));
assert!(low > 1.0, "any positive score beats the 1.0 baseline");
assert!(mid > low);
assert!(high > mid);
assert!(
high < 1.0 + ACCESS_MULTIPLIER_WEIGHT,
"even an extreme score stays strictly below the cap"
);
}
#[test]
fn aggregate_counts_and_decays_by_age() {
let now = Utc::now();
let entries = vec![
entry("mem-a", now),
entry("mem-a", now - chrono::Duration::days(30)),
entry("mem-b", now - chrono::Duration::days(365)),
];
let stats = aggregate_access_stats(&entries, now);
let a = stats.get("mem-a").unwrap();
assert_eq!(a.count, 2);
assert!((a.score - 1.5).abs() < 0.01, "score = {}", a.score);
let b = stats.get("mem-b").unwrap();
assert_eq!(b.count, 1);
assert!(b.score > 0.0 && b.score < 0.01, "score = {}", b.score);
}
#[test]
fn aggregate_skips_malformed_entries() {
let now = Utc::now();
let entries = vec![
AccessLogEntry {
id: "".to_string(),
ts: now.to_rfc3339(),
},
AccessLogEntry {
id: "mem-c".to_string(),
ts: "not-a-timestamp".to_string(),
},
];
let stats = aggregate_access_stats(&entries, now);
assert!(stats.is_empty());
}
#[test]
fn parse_access_log_skips_blank_and_malformed_lines() {
let raw = "{\"id\":\"a\",\"ts\":\"2026-01-01T00:00:00Z\"}\n\nnot json\n{\"id\":\"b\",\"ts\":\"2026-01-02T00:00:00Z\"}\n";
let entries = parse_access_log(raw);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].id, "a");
assert_eq!(entries[1].id, "b");
}
#[test]
fn compact_entries_drops_stale_and_bounds_retained_count() {
let now = Utc::now();
let mut entries = vec![entry("old", now - chrono::Duration::days(200))];
for i in 0..(ACCESS_LOG_MAX_RETAINED_ENTRIES + 500) {
entries.push(entry(&format!("mem-{i}"), now));
}
let compacted = compact_entries(entries, now);
assert!(!compacted.iter().any(|e| e.id == "old"), "aged out");
assert_eq!(compacted.len(), ACCESS_LOG_MAX_RETAINED_ENTRIES);
assert!(!compacted.iter().any(|e| e.id == "mem-0"));
assert!(compacted
.iter()
.any(|e| e.id == format!("mem-{}", ACCESS_LOG_MAX_RETAINED_ENTRIES + 499)));
}
}