use std::collections::BTreeMap;
use std::path::Path;
use super::record::{Counts, Day, Top, TOP_SAVED};
use crate::mcp::json_rpc::{extract_raw, extract_str, split_array_elements};
const LISTS: [&str; 6] = ["people_pages", "ai_pages", "ai_agents", "bots", "referrers", "not_found"];
fn quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
fn list<'a>(day: &'a Day, name: &str) -> &'a Top {
match name {
"people_pages" => &day.people_pages,
"ai_pages" => &day.ai_pages,
"ai_agents" => &day.ai_agents,
"bots" => &day.bots,
"referrers" => &day.referrers,
_ => &day.not_found,
}
}
fn list_mut<'a>(day: &'a mut Day, name: &str) -> &'a mut Top {
match name {
"people_pages" => &mut day.people_pages,
"ai_pages" => &mut day.ai_pages,
"ai_agents" => &mut day.ai_agents,
"bots" => &mut day.bots,
"referrers" => &mut day.referrers,
_ => &mut day.not_found,
}
}
pub(crate) fn to_json(hosts: &[String], hours: &BTreeMap<(u64, u8), [Counts; 3]>, days: &BTreeMap<u64, Day>) -> String {
let mut out = String::from("{\"version\":1,\"hosts\":[");
out.push_str(&hosts.iter().map(|h| quote(h)).collect::<Vec<_>>().join(","));
out.push_str("],\"hours\":[");
let rows: Vec<String> = hours
.iter()
.map(|((hour, host), c)| format!("[{hour},{host},{},{},{},{},{},{}]", c[0].requests, c[0].pages, c[1].requests, c[1].pages, c[2].requests, c[2].pages))
.collect();
out.push_str(&rows.join(","));
out.push_str("],\"days\":[");
let days: Vec<String> = days
.iter()
.map(|(n, day)| {
let lists: Vec<String> = LISTS
.iter()
.map(|name| {
let entries: Vec<String> = list(day, name).sorted(TOP_SAVED).iter().map(|(k, v)| format!("[{},{v}]", quote(k))).collect();
format!("{}:[{}]", quote(name), entries.join(","))
})
.collect();
format!("{{\"day\":{n},\"visitors\":[{},{},{}],{}}}", day.visitors[0], day.visitors[1], day.visitors[2], lists.join(","))
})
.collect();
out.push_str(&days.join(","));
out.push_str("]}\n");
out
}
fn numbers(json: &str) -> Vec<u64> {
split_array_elements(json).iter().filter_map(|n| n.trim().parse().ok()).collect()
}
#[allow(clippy::type_complexity)]
pub(crate) fn from_json(text: &str) -> Option<(Vec<String>, BTreeMap<(u64, u8), [Counts; 3]>, BTreeMap<u64, Day>)> {
if extract_raw(text, "version")?.trim() != "1" {
return None;
}
let hosts: Vec<String> = split_array_elements(&extract_raw(text, "hosts")?)
.iter()
.filter_map(|h| extract_str(&format!("{{\"v\":{h}}}"), "v"))
.collect();
let mut hours = BTreeMap::new();
for row in split_array_elements(&extract_raw(text, "hours")?) {
let n = numbers(&row);
if n.len() == 8 && (n[1] as usize) < hosts.len() {
hours.insert(
(n[0], n[1] as u8),
[Counts { requests: n[2], pages: n[3] }, Counts { requests: n[4], pages: n[5] }, Counts { requests: n[6], pages: n[7] }],
);
}
}
let mut days = BTreeMap::new();
for object in split_array_elements(&extract_raw(text, "days")?) {
let Some(n) = extract_raw(&object, "day").and_then(|d| d.trim().parse::<u64>().ok()) else { continue };
let mut day = Day::default();
let visitors = numbers(&extract_raw(&object, "visitors").unwrap_or_default());
for (g, v) in visitors.iter().take(3).enumerate() {
day.visitors[g] = *v;
}
for name in LISTS {
let raw = extract_raw(&object, name).unwrap_or_default();
for pair in split_array_elements(&raw) {
let parts = split_array_elements(&pair);
if parts.len() != 2 {
continue;
}
let (Some(key), Ok(count)) = (extract_str(&format!("{{\"v\":{}}}", parts[0]), "v"), parts[1].trim().parse::<u64>()) else { continue };
list_mut(&mut day, name).0.insert(key, count);
}
}
days.insert(n, day);
}
Some((hosts, hours, days))
}
pub(crate) fn save(path: &Path) -> std::io::Result<()> {
if !crate::disk_guard::has_room(path) {
return Ok(());
}
let (hours, days) = super::record::snapshot(super::record::now());
let text = to_json(&super::record::host_names(), &hours, &days);
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("json.tmp");
let _ = std::fs::remove_file(&tmp);
write_private(&tmp, text.as_bytes())?;
std::fs::rename(&tmp, path)
}
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
options.open(path)?.write_all(bytes)
}
pub(crate) fn load(path: &Path) {
let Ok(text) = std::fs::read_to_string(path) else { return };
match from_json(&text) {
Some((hosts, hours, days)) => super::record::restore(hosts, hours, days),
None => eprintln!("traffic: {} isn't a traffic file this rws can read; starting empty", path.display()),
}
}