use crate::persistence;
use anyhow::{Context as _, Result, ensure};
use crossbeam_channel::{Receiver, Sender, unbounded};
use serde::{Deserialize, Serialize};
use std::{
fs, io,
path::{Path, PathBuf},
thread,
time::{Duration, Instant},
};
use trailgen_core::HikingModel;
const SETTLE: Duration = Duration::from_millis(400);
const DEFAULT_BASE_PACE_KMH: f64 = 5.0;
const MIN_BASE_PACE_KMH: f64 = 0.5;
const MAX_BASE_PACE_KMH: f64 = 15.0;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BasePace(f64);
impl Default for BasePace {
fn default() -> Self {
Self(DEFAULT_BASE_PACE_KMH)
}
}
impl BasePace {
#[must_use]
pub const fn kmh(self) -> f64 {
self.0
}
#[must_use]
pub fn forge(kmh: f64) -> Option<Self> {
(kmh.is_finite() && (MIN_BASE_PACE_KMH..=MAX_BASE_PACE_KMH).contains(&kmh))
.then_some(Self(kmh))
}
#[must_use]
pub fn moving_time_s(self, population_seconds: f64) -> f64 {
population_seconds * HikingModel::reference_flat_speed_kmh() / self.0
}
#[must_use]
pub fn population_time_s(self, personal_seconds: f64) -> f64 {
personal_seconds * self.0 / HikingModel::reference_flat_speed_kmh()
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
struct Values {
base_pace_kmh: f64,
}
impl Default for Values {
fn default() -> Self {
Self {
base_pace_kmh: DEFAULT_BASE_PACE_KMH,
}
}
}
impl Values {
fn validate(&self) -> Result<()> {
ensure!(
BasePace::forge(self.base_pace_kmh).is_some(),
"base_pace_kmh must be between {MIN_BASE_PACE_KMH} and {MAX_BASE_PACE_KMH}"
);
Ok(())
}
fn base_pace(&self) -> BasePace {
BasePace::forge(self.base_pace_kmh).expect("live preferences must remain valid")
}
}
enum Command {
Commit { revision: u64, values: Values },
Finish(Values),
}
struct Receipt {
revision: u64,
values: Values,
fault: Option<String>,
}
pub struct PreferenceLedger {
path: PathBuf,
live: Values,
committed: Values,
dirty: Option<Instant>,
in_flight: Option<u64>,
revision: u64,
command: Sender<Command>,
receipts: Receiver<Receipt>,
worker: Option<thread::JoinHandle<()>>,
alarm: Option<String>,
}
impl PreferenceLedger {
pub fn raise(ctx: &egui::Context, path: PathBuf) -> Result<Self> {
let (live, alarm) = match load(&path) {
Ok(values) => (values, None),
Err(err) => (
Values::default(),
Some(format!("Preferences reset to defaults: {err:#}")),
),
};
let (command, commands) = unbounded();
let (publish, receipts) = unbounded();
let worker_path = path.clone();
let worker_ctx = ctx.clone();
let worker = thread::Builder::new()
.name("preference-committer".to_owned())
.spawn(move || {
while let Ok(command) = commands.recv() {
match command {
Command::Commit { revision, values } => {
let fault = save(&worker_path, &values)
.err()
.map(|err| format!("{err:#}"));
if publish
.send(Receipt {
revision,
values,
fault,
})
.is_err()
{
break;
}
worker_ctx.request_repaint();
}
Command::Finish(values) => {
if let Err(err) = save(&worker_path, &values) {
eprintln!("could not save trailgen preferences: {err:#}");
}
break;
}
}
}
})
.context("spawn preference committer")?;
Ok(Self {
path,
committed: live.clone(),
live,
dirty: None,
in_flight: None,
revision: 0,
command,
receipts,
worker: Some(worker),
alarm,
})
}
#[must_use]
pub fn base_pace(&self) -> BasePace {
self.live.base_pace()
}
pub fn revise_base_pace(&mut self, kmh: f64) -> bool {
let Some(pace) = BasePace::forge(kmh) else {
return false;
};
if pace == self.base_pace() {
return false;
}
self.live.base_pace_kmh = pace.kmh();
self.dirty = Some(Instant::now());
true
}
pub fn tend(&mut self, ctx: &egui::Context) -> Option<String> {
while let Ok(receipt) = self.receipts.try_recv() {
if self.in_flight == Some(receipt.revision) {
self.in_flight = None;
}
if let Some(fault) = receipt.fault {
self.alarm = Some(format!("Could not save preferences: {fault}"));
self.dirty = Some(Instant::now());
} else {
self.committed = receipt.values;
}
}
if self.live == self.committed {
self.dirty = None;
} else if self.in_flight.is_none() {
let dirty = self.dirty.get_or_insert_with(Instant::now);
let settled = dirty.elapsed();
if settled < SETTLE {
ctx.request_repaint_after(SETTLE.saturating_sub(settled));
} else {
self.revision = self.revision.saturating_add(1);
let revision = self.revision;
let values = self.live.clone();
if self
.command
.send(Command::Commit { revision, values })
.is_ok()
{
self.in_flight = Some(revision);
self.dirty = None;
} else {
self.alarm = Some("Could not save preferences: committer stopped".to_owned());
self.dirty = Some(Instant::now());
}
}
}
self.alarm.take()
}
}
impl Drop for PreferenceLedger {
fn drop(&mut self) {
if self
.command
.send(Command::Finish(self.live.clone()))
.is_err()
&& let Err(err) = save(&self.path, &self.live)
{
eprintln!("could not save trailgen preferences: {err:#}");
}
if let Some(worker) = self.worker.take()
&& worker.join().is_err()
{
eprintln!("trailgen preference committer panicked");
}
}
}
fn load(path: &Path) -> Result<Values> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Values::default()),
Err(err) => {
return Err(err).with_context(|| format!("read preferences {}", path.display()));
}
};
let values = toml::from_str::<Values>(&text)
.with_context(|| format!("parse preferences {}", path.display()))?;
values.validate()?;
Ok(values)
}
fn save(path: &Path, values: &Values) -> Result<()> {
let parent = path.parent().context("preferences path has no parent")?;
crate::habitat::create_private_dir(parent)?;
let body = toml::to_string_pretty(values).context("serialize preferences")?;
persistence::replace(path, body.as_bytes())
.with_context(|| format!("replace preferences {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_pace_is_five_kilometers_per_hour() {
assert!((BasePace::default().kmh() - 5.0).abs() <= f64::EPSILON);
}
#[test]
fn pace_projection_and_search_conversion_are_inverses() {
let pace = BasePace::forge(6.6).expect("fixture pace must be valid");
let population = 5.0 * 3_600.0;
let personal = pace.moving_time_s(population);
assert!(personal < population);
assert!((pace.population_time_s(personal) - population).abs() <= 1.0e-9);
}
#[test]
fn preferences_round_trip_and_reject_invalid_paces() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("preferences.toml");
let values = Values { base_pace_kmh: 6.6 };
save(&path, &values)?;
assert_eq!(load(&path)?, values);
fs::write(&path, "base_pace_kmh = 0.0\n")?;
assert!(load(&path).is_err());
Ok(())
}
}