use std::path::PathBuf;
use std::time::Duration;
use chrono::{DateTime, Utc};
use crate::config::Config;
use super::due::due_after;
pub(crate) const CHECKED_EVERY: Duration = Duration::from_secs(2);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Reloaded<'a> {
Untouched,
Unchanged,
Fresh(&'a Config),
Broken,
}
pub(crate) type Parses = Box<dyn Fn(&str) -> anyhow::Result<Config>>;
pub(crate) struct Reload {
path: PathBuf,
every: Duration,
at: Option<DateTime<Utc>>,
said: Option<String>,
in_force: Config,
parses: Parses,
}
impl Reload {
pub(crate) fn watching(
path: PathBuf,
every: Duration,
in_force: Config,
parses: Parses,
now: DateTime<Utc>,
) -> Self {
Self {
path,
every,
at: due_after(now, every),
said: None,
in_force,
parses,
}
}
pub(super) fn checks_in(&self, now: DateTime<Utc>) -> Option<Duration> {
self.at
.map(|at| (at - now).to_std().unwrap_or(Duration::ZERO))
}
pub(super) fn checks(&mut self, now: DateTime<Utc>) -> Reloaded<'_> {
if !self.at.is_some_and(|at| at <= now) {
return Reloaded::Untouched;
}
self.at = due_after(now, self.every);
self.reads()
}
fn reads(&mut self) -> Reloaded<'_> {
let Ok(text) = std::fs::read_to_string(&self.path) else {
self.said = None;
return Reloaded::Broken;
};
if self.said.as_deref() == Some(text.as_str()) {
return Reloaded::Untouched;
}
let parsed = (self.parses)(&text);
self.said = Some(text);
let Ok(written) = parsed else {
return Reloaded::Broken;
};
if written == self.in_force {
return Reloaded::Unchanged;
}
self.in_force = written;
Reloaded::Fresh(&self.in_force)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use std::fs::{File, FileTimes};
use std::rc::Rc;
use std::time::SystemTime;
const DUNWICH: &str = "[[projects]]\nname = \"dunwich\"\npath = \"/srv/work/dunwich\"\n";
const DUNWICH_AND_FERRY: &str = "[[projects]]\nname = \"dunwich\"\npath = \"/srv/work/dunwich\"\n\n[[projects]]\nname = \"ferry\"\npath = \"/srv/work/ferry\"\n";
const NOT_TOML: &str = "[[projects]\nthis is not toml\n";
const EVERY_TWO_SECONDS: Duration = Duration::from_secs(2);
#[track_caller]
fn came_into_force(reloaded: Reloaded<'_>) -> &Config {
match reloaded {
Reloaded::Fresh(written) => written,
found => panic!("the check answered {found:?} rather than with a config"),
}
}
fn both() -> Config {
Config::from_toml(DUNWICH_AND_FERRY).expect("the fixture parses")
}
fn at(seconds: i64) -> DateTime<Utc> {
DateTime::from_timestamp(seconds, 0).expect("an instant inside the epoch")
}
fn a_config(text: &str) -> Config {
Config::from_toml(text).expect("the fixture parses")
}
fn a_config_file(named: &str, text: &str, written_at: u64) -> PathBuf {
let path =
std::env::temp_dir().join(format!("bdi-reload-{named}-{}.toml", std::process::id()));
written(&path, text, written_at);
path
}
fn written(path: &PathBuf, text: &str, written_at: u64) {
std::fs::write(path, text).expect("the config is ours to write");
File::options()
.write(true)
.open(path)
.expect("the config is ours to open")
.set_times(
FileTimes::new()
.set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(written_at)),
)
.expect("the config's mtime is ours to set");
}
fn watching(path: PathBuf, in_force: &str) -> (Reload, Rc<Cell<usize>>) {
let parsed = Rc::new(Cell::new(0));
let counted = Rc::clone(&parsed);
let reload = Reload::watching(
path,
EVERY_TWO_SECONDS,
a_config(in_force),
Box::new(move |text| {
counted.set(counted.get() + 1);
Config::from_toml(text)
}),
at(0),
);
(reload, parsed)
}
#[test]
fn the_first_check_reads_the_file_the_config_in_force_came_from() {
let path = a_config_file("first-check", DUNWICH, 100);
let (mut reload, parsed) = watching(path, DUNWICH);
assert_eq!(reload.checks(at(2)), Reloaded::Unchanged);
assert_eq!(parsed.get(), 1);
}
#[test]
fn a_config_saying_what_it_already_said_is_not_parsed_again() {
let path = a_config_file("untouched", DUNWICH, 100);
let (mut reload, parsed) = watching(path, DUNWICH);
assert_eq!(reload.checks(at(2)), Reloaded::Unchanged);
assert_eq!(reload.checks(at(4)), Reloaded::Untouched);
assert_eq!(
parsed.get(),
1,
"the file was parsed once, at the first check"
);
}
#[test]
fn a_config_the_reader_has_written_comes_into_force() {
let path = a_config_file("written", DUNWICH, 100);
let (mut reload, _) = watching(path.clone(), DUNWICH);
assert_eq!(reload.checks(at(2)), Reloaded::Unchanged);
written(&path, DUNWICH_AND_FERRY, 200);
assert_eq!(came_into_force(reload.checks(at(4))), &both());
}
#[test]
fn a_config_saying_the_same_in_different_words_is_no_reload() {
let path = a_config_file("recommented", DUNWICH, 100);
let (mut reload, parsed) = watching(path.clone(), DUNWICH);
assert_eq!(reload.checks(at(2)), Reloaded::Unchanged);
written(&path, &format!("# the one project\n{DUNWICH}"), 200);
assert_eq!(reload.checks(at(4)), Reloaded::Unchanged);
assert_eq!(
parsed.get(),
2,
"the file was parsed; it just said nothing new"
);
}
#[test]
fn a_config_put_there_under_the_stamp_the_file_already_had_is_still_read() {
let path = a_config_file("same-stamp", DUNWICH, 100);
let (mut reload, _) = watching(path.clone(), DUNWICH);
assert_eq!(reload.checks(at(2)), Reloaded::Unchanged);
written(&path, DUNWICH_AND_FERRY, 100);
assert_eq!(came_into_force(reload.checks(at(4))), &both());
}
#[test]
fn a_config_put_there_under_an_earlier_stamp_is_still_read() {
let path = a_config_file("earlier-stamp", DUNWICH, 100);
let (mut reload, _) = watching(path.clone(), DUNWICH);
assert_eq!(reload.checks(at(2)), Reloaded::Unchanged);
written(&path, DUNWICH_AND_FERRY, 50);
assert_eq!(came_into_force(reload.checks(at(4))), &both());
}
#[test]
fn a_config_that_will_not_parse_leaves_the_running_one_in_force() {
let path = a_config_file("unparsed", DUNWICH_AND_FERRY, 100);
let (mut reload, _) = watching(path.clone(), DUNWICH_AND_FERRY);
assert_eq!(reload.checks(at(2)), Reloaded::Unchanged);
written(&path, NOT_TOML, 200);
assert_eq!(reload.checks(at(4)), Reloaded::Broken);
written(&path, DUNWICH_AND_FERRY, 300);
assert_eq!(
reload.checks(at(6)),
Reloaded::Unchanged,
"the config in force is the one the file said before it was broken"
);
}
#[test]
fn a_config_that_will_not_open_leaves_the_running_one_in_force() {
let path = a_config_file("unopened", DUNWICH_AND_FERRY, 100);
let (mut reload, _) = watching(path.clone(), DUNWICH_AND_FERRY);
assert_eq!(reload.checks(at(2)), Reloaded::Unchanged);
std::fs::remove_file(&path).expect("the config is ours to remove");
assert_eq!(reload.checks(at(4)), Reloaded::Broken);
written(&path, DUNWICH_AND_FERRY, 300);
assert_eq!(reload.checks(at(6)), Reloaded::Unchanged);
}
#[test]
fn a_config_fixed_under_the_stamp_the_broken_one_had_is_still_read() {
let path = a_config_file("fixed-same-stamp", DUNWICH, 100);
let (mut reload, parsed) = watching(path.clone(), DUNWICH);
assert_eq!(reload.checks(at(2)), Reloaded::Unchanged);
written(&path, NOT_TOML, 200);
assert_eq!(reload.checks(at(4)), Reloaded::Broken);
written(&path, DUNWICH_AND_FERRY, 200);
assert_eq!(came_into_force(reload.checks(at(6))), &both());
assert_eq!(parsed.get(), 3);
}
#[test]
fn a_config_still_broken_is_not_parsed_again_to_say_so() {
let path = a_config_file("still-broken", DUNWICH, 100);
let (mut reload, parsed) = watching(path.clone(), DUNWICH);
assert_eq!(reload.checks(at(2)), Reloaded::Unchanged);
written(&path, NOT_TOML, 200);
assert_eq!(reload.checks(at(4)), Reloaded::Broken);
assert_eq!(reload.checks(at(6)), Reloaded::Untouched);
assert_eq!(parsed.get(), 2);
}
#[test]
fn a_check_that_is_not_due_reads_nothing() {
let path = a_config_file("not-due", DUNWICH, 100);
let (mut reload, parsed) = watching(path, DUNWICH);
assert_eq!(reload.checks(at(1)), Reloaded::Untouched);
assert_eq!(parsed.get(), 0);
}
#[test]
fn a_check_falls_due_its_interval_after_the_one_before_it() {
let path = a_config_file("interval", DUNWICH, 100);
let (mut reload, _) = watching(path, DUNWICH);
assert_eq!(reload.checks_in(at(0)), Some(EVERY_TWO_SECONDS));
assert_eq!(reload.checks_in(at(1)), Some(Duration::from_secs(1)));
assert_eq!(reload.checks(at(2)), Reloaded::Unchanged);
assert_eq!(reload.checks_in(at(2)), Some(EVERY_TWO_SECONDS));
}
#[test]
fn a_check_already_overdue_is_due_now() {
let path = a_config_file("overdue", DUNWICH, 100);
let (reload, _) = watching(path, DUNWICH);
assert_eq!(reload.checks_in(at(9)), Some(Duration::ZERO));
}
#[test]
fn an_interval_that_outruns_time_falls_due_never() {
let path = a_config_file("outruns", DUNWICH, 100);
let mut reload = Reload::watching(
path,
Duration::MAX,
a_config(DUNWICH),
Box::new(Config::from_toml),
at(0),
);
assert_eq!(reload.checks_in(at(0)), None);
assert_eq!(reload.checks(at(9)), Reloaded::Untouched);
}
}