use crate::Stats;
use std::{
collections::BTreeMap,
ffi::OsStr,
fs::File,
io::Write,
path::{
Path,
PathBuf,
},
};
type HistoryData = BTreeMap<String, Stats>;
const HISTORY_FILE: &str = "__brunch.last";
const MAGIC: &[u8] = b"BRUNCH00";
#[doc(hidden)]
#[derive(Debug, Clone)]
pub(crate) struct History(HistoryData);
impl Default for History {
fn default() -> Self {
Self(load_history().unwrap_or_default())
}
}
impl History {
pub(crate) fn get(&self, key: &str) -> Option<Stats> {
self.0.get(key).copied()
}
pub(crate) fn insert(&mut self, key: &str, v: Stats) {
self.0.insert(key.to_owned(), v);
}
pub(crate) fn save(&self) {
if let Some(mut f) = history_path().and_then(|f| File::create(f).ok()) {
let out = serialize(&self.0);
let _res = f.write_all(&out).and_then(|_| f.flush());
}
}
}
trait Deserialize<'a>: Sized {
fn deserialize(raw: &'a [u8]) -> Option<(Self, &'a [u8])>;
}
macro_rules! deserialize {
($($size:literal $ty:ty),+) => ($(
impl Deserialize<'_> for $ty {
fn deserialize(raw: &[u8]) -> Option<(Self, &[u8])> {
let (bytes, raw) = split_array::<$size>(raw)?;
Some((Self::from_be_bytes(bytes), raw))
}
}
)+);
}
deserialize!(2 u16, 4 u32, 8 f64);
impl<'a> Deserialize<'a> for &'a str {
fn deserialize(raw: &'a [u8]) -> Option<(Self, &'a [u8])> {
let (len, raw) = u16::deserialize(raw)?;
let len = usize::from(len);
if raw.len() < len { None }
else {
let (lbl, raw) = raw.split_at(len);
let lbl = std::str::from_utf8(lbl).ok()?.trim();
Some((lbl, raw))
}
}
}
impl Deserialize<'_> for Stats {
fn deserialize(raw: &[u8]) -> Option<(Self, &[u8])> {
let (total, raw) = u32::deserialize(raw)?;
let (valid, raw) = u32::deserialize(raw)?;
let (deviation, raw) = f64::deserialize(raw)?;
let (mean, raw) = f64::deserialize(raw)?;
let out = Self { total, valid, deviation, mean };
Some((out, raw))
}
}
fn deserialize(raw: &[u8]) -> Option<HistoryData> {
let mut raw = raw.strip_prefix(MAGIC)?;
let mut out = HistoryData::default();
while ! raw.is_empty() {
let (lbl, rest) = <&str>::deserialize(raw)?;
let (stats, rest) = Stats::deserialize(rest)?;
if ! lbl.is_empty() && stats.is_valid() {
out.insert(lbl.to_owned(), stats);
}
raw = rest;
}
Some(out)
}
#[allow(clippy::option_if_let_else)]
fn history_path() -> Option<PathBuf> {
if std::env::var("NO_BRUNCH_HISTORY").map_or(false, |s| s.trim() == "1") { None }
else if let Some(p) = std::env::var_os("BRUNCH_HISTORY") {
let p: &Path = p.as_ref();
if p.is_dir() { return None; }
let parent = try_dir(p.parent())
.or_else(|| try_dir(std::env::current_dir().ok()))?;
let name = match p.file_name() {
Some(n) if ! n.is_empty() => n,
_ => OsStr::new(HISTORY_FILE),
};
Some(parent.join(name))
}
else {
let p = try_dir(Some(std::env::temp_dir()))?;
Some(p.join(HISTORY_FILE))
}
}
fn load_history() -> Option<HistoryData> {
let file = history_path()?;
let raw = std::fs::read(file).ok()?;
deserialize(&raw)
}
fn serialize(history: &HistoryData) -> Vec<u8> {
let mut out = Vec::with_capacity(64 * history.len());
out.extend_from_slice(MAGIC);
for (lbl, s) in history.iter() {
if let Ok(len) = u16::try_from(lbl.len()) {
out.extend_from_slice(&len.to_be_bytes());
out.extend_from_slice(lbl.as_bytes());
out.extend_from_slice(&s.total.to_be_bytes());
out.extend_from_slice(&s.valid.to_be_bytes());
out.extend_from_slice(&s.deviation.to_be_bytes());
out.extend_from_slice(&s.mean.to_be_bytes());
}
}
out
}
#[allow(unsafe_code)]
fn split_array<const S: usize>(raw: &[u8]) -> Option<([u8; S], &[u8])> {
if S <= raw.len() {
Some(unsafe {(
*(raw.get_unchecked(..S).as_ptr().cast()),
raw.get_unchecked(S..),
)})
}
else { None }
}
fn try_dir<P: AsRef<Path>>(dir: Option<P>) -> Option<PathBuf> {
let dir = dir?;
let dir: &Path = dir.as_ref();
if ! dir.exists() { std::fs::create_dir_all(dir).ok()?; }
let dir = std::fs::canonicalize(dir).ok()?;
if dir.is_dir() { Some(dir) }
else { None }
}
#[cfg(test)]
mod tests {
use super::*;
use dactyl::total_cmp;
#[test]
fn t_serialize() {
const ENTRIES: [(&str, Stats); 2] = [
(
"The First One",
Stats {
total: 2500,
valid: 2496,
deviation: 0.000000123,
mean: 0.0000022,
},
),
(
"The Second One",
Stats {
total: 300,
valid: 222,
deviation: 0.000400123,
mean: 0.0000122,
},
),
];
let mut h = ENTRIES.into_iter().map(|(k, v)| (k.to_owned(), v)).collect::<HistoryData>();
let s = serialize(&h);
assert!(s.starts_with(MAGIC), "Missing magic header.");
let d = deserialize(&s).expect("Deserialization failed.");
assert_eq!(h.len(), d.len(), "Deserialized length mismatch.");
for (lbl, stat) in ENTRIES {
let tmp = d.get(lbl).expect("Missing entry!");
assert_eq!(stat.total, tmp.total, "Total changed.");
assert_eq!(stat.valid, tmp.valid, "Valid changed.");
assert!(total_cmp!((stat.deviation) == (tmp.deviation)), "Deviation changed.");
assert!(total_cmp!((stat.mean) == (tmp.mean)), "Mean changed.");
}
h.insert("A Suspect One".to_owned(), Stats {
total: 200,
valid: 300,
deviation: 0.000400123,
mean: 0.0000122,
});
h.insert(String::new(), Stats {
total: 500,
valid: 300,
deviation: 0.000400123,
mean: 0.0000122,
});
assert!(h.get("A Suspect One").is_some());
assert!(h.get("").is_some());
let mut s = serialize(&h);
let d = deserialize(&s).expect("Deserialization failed.");
assert_eq!(ENTRIES.len(), d.len(), "Deserialized length mismatch.");
assert!(d.get("A Suspect One").is_none()); assert!(d.get("").is_none());
for (lbl, stat) in ENTRIES {
let tmp = d.get(lbl).expect("Missing entry!");
assert_eq!(stat.total, tmp.total, "Total changed.");
assert_eq!(stat.valid, tmp.valid, "Valid changed.");
assert!(total_cmp!((stat.deviation) == (tmp.deviation)), "Deviation changed.");
assert!(total_cmp!((stat.mean) == (tmp.mean)), "Mean changed.");
}
s.pop().unwrap();
assert!(deserialize(&s).is_none());
assert!(deserialize(&[]).is_none());
}
}