use crate::context::Context;
use crate::service::Permit;
use crate::{atomic, home};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
const FORGET_AFTER: i64 = 7 * 86_400;
const SEEN_EVERY: i64 = 86_400;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub(crate) struct Limit {
pub used_percentage: f64,
pub resets_at: Option<i64>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub(crate) struct Run {
pub account: Option<String>,
pub limits: BTreeMap<String, Limit>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Seen {
#[serde(flatten)]
run: Run,
seen_at: i64,
}
fn path(ctx: &Context) -> PathBuf {
home::dir(ctx).join("sessions.json")
}
fn load(ctx: &Context) -> HashMap<String, Seen> {
std::fs::read_to_string(path(ctx))
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default()
}
pub(crate) fn last(ctx: &Context, id: &str) -> Option<Run> {
load(ctx).remove(id).map(|seen| seen.run)
}
pub(crate) fn exchange(ctx: &Context, permit: Permit, id: &str, run: &Run) -> Option<Run> {
let now = ctx.now();
if let Some(seen) = load(ctx).remove(id)
&& seen.run == *run
&& now - seen.seen_at < SEEN_EVERY
{
return Some(seen.run);
}
let _held = crate::readings::exclusive(ctx, permit)?;
let mut all = load(ctx);
let before = all.remove(id).map(|seen| seen.run);
all.retain(|_, seen| now - seen.seen_at < FORGET_AFTER);
all.insert(
id.to_string(),
Seen {
run: run.clone(),
seen_at: now,
},
);
if let Ok(body) = serde_json::to_string(&all) {
let _ = atomic::write(permit, &path(ctx), body.as_bytes(), atomic::Perms::Secret);
}
before
}
#[cfg(test)]
mod tests {
use super::*;
use crate::time::{Clock, FixedClock};
use std::sync::Arc;
const NOW: i64 = 1_789_935_000;
struct Scratch(PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn machine(name: &str) -> (Context, Scratch) {
let root = std::env::temp_dir().join(format!(
"pitboard-sessions-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
let ctx = Context::new(root.clone()).with_pitboard_home(root.clone());
home::ensure(&ctx, Permit::for_a_test()).expect("a home");
(ctx, Scratch(root))
}
fn at(ctx: &Context, now: i64) -> Context {
ctx.clone()
.with_clock(Arc::new(FixedClock::at(now)) as Arc<dyn Clock>)
}
fn run(account: &str, used_percentage: f64) -> Run {
Run {
account: Some(account.into()),
limits: BTreeMap::from([(
"five_hour".to_string(),
Limit {
used_percentage,
resets_at: Some(NOW + 3_600),
},
)]),
}
}
#[test]
fn a_session_gets_back_what_it_passed_the_run_before() {
let (ctx, _scratch) = machine("exchange");
let ctx = at(&ctx, NOW);
assert_eq!(
exchange(&ctx, Permit::for_a_test(), "pane", &run("work", 20.0)),
None
);
assert_eq!(
exchange(&ctx, Permit::for_a_test(), "pane", &run("work", 22.0)),
Some(run("work", 20.0))
);
assert_eq!(
exchange(&ctx, Permit::for_a_test(), "other", &run("personal", 5.0)),
None,
"each session its own"
);
assert_eq!(
exchange(&ctx, Permit::for_a_test(), "pane", &run("work", 22.0)),
Some(run("work", 22.0))
);
}
#[test]
fn a_run_that_changes_nothing_leaves_the_file_alone() {
let (ctx, _scratch) = machine("unchanged");
exchange(
&at(&ctx, NOW),
Permit::for_a_test(),
"pane",
&run("work", 20.0),
);
let written = std::fs::read_to_string(path(&ctx)).unwrap();
std::fs::write(path(&ctx), format!("{written} ")).unwrap();
exchange(
&at(&ctx, NOW + 3_600),
Permit::for_a_test(),
"pane",
&run("work", 20.0),
);
assert_eq!(
std::fs::read_to_string(path(&ctx)).unwrap(),
format!("{written} ")
);
}
#[test]
fn a_session_not_seen_for_a_week_is_dropped() {
let (ctx, _scratch) = machine("pruned");
exchange(
&at(&ctx, NOW),
Permit::for_a_test(),
"gone",
&run("work", 20.0),
);
exchange(
&at(&ctx, NOW),
Permit::for_a_test(),
"open",
&run("work", 30.0),
);
for day in 1..=7 {
exchange(
&at(&ctx, NOW + day * 86_400),
Permit::for_a_test(),
"open",
&run("work", 30.0),
);
}
let kept: Vec<String> = load(&ctx).into_keys().collect();
assert_eq!(kept, ["open"]);
}
}