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 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::usage::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;
fn machine() -> (Context, tempfile::TempDir) {
let scratch = tempfile::tempdir().expect("a scratch home");
let root = scratch.path().to_path_buf();
let ctx = Context::new(root.clone()).with_pitboard_home(root);
home::ensure(&ctx, Permit::for_a_test()).expect("a home");
(ctx, scratch)
}
fn at(ctx: &Context, now: i64) -> Context {
ctx.clone()
.with_clock(Arc::new(FixedClock::at(now)) as Arc<dyn Clock>)
}
fn run(used_percentage: f64) -> Run {
Run {
limits: BTreeMap::from([(
"five_hour".to_string(),
Limit {
used_percentage,
resets_at: Some(NOW + 3_600),
},
)]),
}
}
#[test]
#[cfg_attr(
windows,
ignore = "W16: Pitboard writing, replacing and removing files on Windows"
)]
fn a_session_gets_back_what_it_passed_the_run_before() {
let (ctx, _scratch) = machine();
let ctx = at(&ctx, NOW);
assert_eq!(
exchange(&ctx, Permit::for_a_test(), "pane", &run(20.0)),
None
);
assert_eq!(
exchange(&ctx, Permit::for_a_test(), "pane", &run(22.0)),
Some(run(20.0))
);
assert_eq!(
exchange(&ctx, Permit::for_a_test(), "other", &run(5.0)),
None,
"each session its own"
);
assert_eq!(
exchange(&ctx, Permit::for_a_test(), "pane", &run(22.0)),
Some(run(22.0))
);
}
#[test]
#[cfg_attr(
windows,
ignore = "W16: Pitboard writing, replacing and removing files on Windows"
)]
fn a_session_an_earlier_pitboard_kept_is_still_compared_with() {
let (ctx, _scratch) = machine();
std::fs::write(
path(&ctx),
serde_json::json!({"pane": {
"account": "work-uuid",
"limits": {"five_hour": {"used_percentage": 20.0, "resets_at": NOW + 3_600}},
"seen_at": NOW,
}})
.to_string(),
)
.unwrap();
assert_eq!(
exchange(&at(&ctx, NOW), Permit::for_a_test(), "pane", &run(22.0)),
Some(run(20.0))
);
}
#[test]
#[cfg_attr(
windows,
ignore = "W16: Pitboard writing, replacing and removing files on Windows"
)]
fn a_run_that_changes_nothing_leaves_the_file_alone() {
let (ctx, _scratch) = machine();
exchange(&at(&ctx, NOW), Permit::for_a_test(), "pane", &run(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(20.0),
);
assert_eq!(
std::fs::read_to_string(path(&ctx)).unwrap(),
format!("{written} ")
);
}
#[test]
#[cfg_attr(
windows,
ignore = "W16: Pitboard writing, replacing and removing files on Windows"
)]
fn a_session_not_seen_for_a_week_is_dropped() {
let (ctx, _scratch) = machine();
exchange(&at(&ctx, NOW), Permit::for_a_test(), "gone", &run(20.0));
exchange(&at(&ctx, NOW), Permit::for_a_test(), "open", &run(30.0));
for day in 1..=7 {
exchange(
&at(&ctx, NOW + day * 86_400),
Permit::for_a_test(),
"open",
&run(30.0),
);
}
let kept: Vec<String> = load(&ctx).into_keys().collect();
assert_eq!(kept, ["open"]);
}
}