use std::path::{Path, PathBuf};
use std::time::SystemTime;
use bevy_app::{App, Update};
use bevy_ecs::prelude::{Res, ResMut, Resource};
use bevy_time::{Real, Time, Timer, TimerMode};
use retiretui_engine::params::TaxTables;
use retiretui_engine::project::project;
use crate::commands::Invalid;
use super::edit::{DraftEditor, EditSession};
use super::journal;
use super::session::{Projected, Session, Today};
pub fn plugin(app: &mut App) {
app.add_systems(Update, poll_watch);
}
pub const POLL_SECONDS: f32 = 1.0;
#[derive(Resource)]
pub struct Watch {
files: Vec<(PathBuf, Option<SystemTime>)>,
timer: Timer,
}
impl Watch {
pub fn new(files: Vec<(PathBuf, Option<SystemTime>)>) -> Self {
Self {
files,
timer: Timer::from_seconds(POLL_SECONDS, TimerMode::Repeating),
}
}
pub fn at(path: PathBuf) -> Self {
Self::new(stamp(vec![path]))
}
pub fn is_scenario(&self) -> bool {
self.files.len() > 1
}
pub fn restamp(&mut self) {
restamp(&mut self.files);
}
}
pub fn restamp(files: &mut Stamped) {
for (path, recorded) in files {
*recorded = mtime(path);
}
}
pub type Stamped = Vec<(PathBuf, Option<SystemTime>)>;
pub fn load_session(session: &Session, today: Today) -> Result<(Projected, Stamped), String> {
match &session.plan_path {
Some(path) => {
load_projected(path, &session.tables).map_err(|invalid| invalid.headline().to_owned())
}
None => Ok((Projected::blank(&session.tables, today), Vec::new())),
}
}
pub(crate) fn load_projected(
path: &Path,
tables: &TaxTables,
) -> Result<(Projected, Stamped), Invalid> {
let (plan, files) = crate::commands::validated_plan_with_files(path, tables)?;
let projection = project(&plan, tables);
Ok((Projected { plan, projection }, stamp(files)))
}
fn stamp(files: Vec<PathBuf>) -> Stamped {
files
.into_iter()
.map(|path| {
let modified = mtime(&path);
(path, modified)
})
.collect()
}
fn mtime(path: &Path) -> Option<SystemTime> {
std::fs::metadata(path)
.and_then(|metadata| metadata.modified())
.ok()
}
pub fn is_stale(files: &[(PathBuf, Option<SystemTime>)]) -> bool {
files
.iter()
.any(|(path, recorded)| mtime(path) != *recorded)
}
pub(crate) fn poll_watch(
time: Res<Time<Real>>,
mut watch: ResMut<Watch>,
mut editor: DraftEditor,
session: Res<EditSession>,
) {
if watch.timer.tick(time.delta()).just_finished() && is_stale(&watch.files) {
follow_document(&mut watch, &mut editor, &session);
}
}
pub(crate) fn on_beat(watch: Res<Watch>) -> bool {
watch.timer.just_finished()
}
fn follow_document(watch: &mut Watch, editor: &mut DraftEditor, session: &EditSession) {
if editor.draft.is_dirty() || session.is_dirty() {
watch.restamp();
journal::warn("plan changed on disk; r reloads and discards the draft");
return;
}
if let Err(message) = editor.reload(watch) {
journal::warn(message);
}
}
pub fn apply_reload(
session: &Session,
projected: &mut Projected,
watch: &mut Watch,
) -> Result<(), String> {
match load_projected(session.document()?, &session.tables) {
Ok((fresh, files)) => {
*projected = fresh;
watch.files = files;
Ok(())
}
Err(invalid) => {
watch.restamp();
Err(format!("reload failed: {}", invalid.headline()))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str, text: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!("retiretui-watch-{name}.toml"));
std::fs::write(&path, text).unwrap();
path
}
#[test]
fn staleness_follows_mtimes() {
let path = scratch("stale", "schema = 1\n");
let files = stamp(vec![path.clone()]);
assert!(!is_stale(&files));
let missing = vec![(PathBuf::from("no-such-file.toml"), mtime(&path))];
assert!(is_stale(&missing), "a vanished file counts as stale");
}
#[test]
fn scenario_chains_are_watched_whole() {
let base = scratch("chain-base", super::super::support::TEST_PLAN);
let overlay =
super::super::support::scenario_over(&base.file_name().unwrap().to_string_lossy());
let scenario = scratch("chain-overlay", &overlay);
let (plan, files) = crate::commands::load_plan_with_files(&scenario).unwrap();
assert_eq!(plan.plan.name.as_deref(), Some("variant"));
assert_eq!(files.len(), 2, "{files:?}");
assert_eq!(files[1], base.canonicalize().unwrap());
}
}