1use crate::Dirs;
24use serde::{Deserialize, Serialize};
25use sioul_sync::power::Power;
26use std::path::PathBuf;
27use std::time::{Duration, Instant};
28
29pub const WEEK: i64 = 7 * 86_400;
31pub const AFTER_STOP: i64 = 15 * 60;
33pub const AFTER_FAILURE: i64 = 86_400;
35pub const SUPPLY_EVERY: Duration = Duration::from_secs(30);
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum Trainer {
41 Here,
43 Elsewhere(String),
45 Nobody,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "kebab-case")]
52pub enum Outcome {
53 Replaced,
55 Kept,
57 Stopped,
59 Failed,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct Run {
66 pub started: i64,
68 pub job: String,
69 #[serde(default)]
71 pub fetched: bool,
72 #[serde(default)]
74 pub ended: Option<i64>,
75 #[serde(default)]
76 pub outcome: Option<Outcome>,
77}
78
79impl Run {
80 fn path(dirs: &Dirs) -> PathBuf {
81 dirs.state.join("auto.toml")
82 }
83
84 pub fn load(dirs: &Dirs) -> Option<Run> {
86 toml::from_str(&std::fs::read_to_string(Run::path(dirs)).ok()?).ok()
87 }
88
89 pub fn save(&self, dirs: &Dirs) -> Result<(), String> {
91 std::fs::create_dir_all(&dirs.state).map_err(|e| format!("{}: {e}", dirs.state.display()))?;
92 let text = toml::to_string(self).map_err(|e| e.to_string())?;
93 crate::corpus::write_whole(&Run::path(dirs), text.as_bytes()).map_err(|e| e.to_string())
94 }
95
96 pub fn finish(dirs: &Dirs, job: &str, outcome: Outcome, now: i64) -> Result<(), String> {
98 let Some(run) = Run::load(dirs).filter(|r| r.job == job) else { return Ok(()) };
99 Run { ended: Some(now), outcome: Some(outcome), ..run }.save(dirs)
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct Facts {
106 pub enabled: bool,
108 pub trainer: Trainer,
109 pub last_trained: Option<i64>,
111 pub last_run: Option<Run>,
113 pub running: bool,
115 pub focus: Option<bool>,
117 pub power: Power,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum Wait {
124 Off,
126 NoTable,
128 Elsewhere,
130 Running,
132 Recent,
134 Retry,
136 Focus,
138 Battery,
140 PowerSaver,
142 InUse,
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum Decision {
149 Train { fetch: bool },
151 Wait(Wait),
152}
153
154pub fn decide(f: &Facts, now: i64) -> Decision {
158 let wait = |why| Decision::Wait(why);
159 if !f.enabled {
160 return wait(Wait::Off);
161 }
162 match f.trainer {
163 Trainer::Nobody => return wait(Wait::NoTable),
164 Trainer::Elsewhere(_) => return wait(Wait::Elsewhere),
165 Trainer::Here => {}
166 }
167 if f.running {
168 return wait(Wait::Running);
169 }
170 if f.last_trained.is_some_and(|at| now - at < WEEK) {
171 return wait(Wait::Recent);
172 }
173 if let Some(run) = &f.last_run
175 && !f.last_trained.is_some_and(|at| at >= run.started)
176 {
177 let pause = if run.outcome == Some(Outcome::Stopped) { AFTER_STOP } else { AFTER_FAILURE };
178 if now - run.ended.unwrap_or(run.started) < pause {
179 return wait(Wait::Retry);
180 }
181 }
182 if f.focus != Some(false) {
183 return wait(Wait::Focus);
184 }
185 if f.power.on_mains != Some(true) {
186 return wait(Wait::Battery);
187 }
188 if f.power.power_saver != Some(false) {
189 return wait(Wait::PowerSaver);
190 }
191 if f.power.idle != Some(true) {
192 return wait(Wait::InUse);
193 }
194 Decision::Train { fetch: f.power.metered == Some(false) }
195}
196
197pub fn facts(dirs: &Dirs, enabled: bool, here: &str, window_trains: bool, focus: bool, now: i64, power: impl FnOnce() -> Power) -> Facts {
204 let mut facts = Facts {
205 enabled,
206 trainer: trainer(dirs, here),
207 last_trained: crate::train::last(dirs).map(|s| s.trained_at).filter(|at| *at > 0),
208 last_run: Run::load(dirs),
209 running: window_trains || a_job_runs(dirs),
210 focus: Some(focus),
211 power: Power::default(),
212 };
213 if decide(&facts, now) == Decision::Wait(Wait::Battery) {
214 facts.power = power();
215 }
216 facts
217}
218
219pub fn trainer(dirs: &Dirs, here: &str) -> Trainer {
222 match sioul_core::spam::table::Table::cached(&dirs.table()) {
223 None => Trainer::Nobody,
224 Some(table) if table.meta.device.is_empty() => Trainer::Nobody,
225 Some(table) if table.meta.device == here => Trainer::Here,
226 Some(table) => Trainer::Elsewhere(table.meta.device.clone()),
227 }
228}
229
230pub fn a_job_runs(dirs: &Dirs) -> bool {
233 let jobs = dirs.state.join("jobs");
234 std::fs::read_dir(&jobs).into_iter().flatten().filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "lock")).any(|path| {
235 let Ok(lock) = std::fs::OpenOptions::new().read(true).open(&path) else { return false };
236 match lock.try_lock() {
237 Ok(()) => {
238 let _ = lock.unlock();
239 false
240 }
241 Err(_) => true,
242 }
243 })
244}
245
246pub fn threads() -> u32 {
248 std::thread::available_parallelism().map_or(1, |n| u32::try_from(n.get() / 2).unwrap_or(1).max(1))
249}
250
251pub fn watch_supply(supply: impl Fn() -> Power, stop: impl FnOnce(), done: impl Fn() -> bool, every: Duration) {
256 let mut unknown = false;
257 loop {
258 if done() {
259 return;
260 }
261 let power = supply();
262 let away = power.on_mains == Some(false) || power.power_saver == Some(true);
263 let unsure = !away && !power.supplied();
264 if away || (unsure && unknown) {
265 stop();
266 return;
267 }
268 unknown = unsure;
269 let until = Instant::now() + every;
270 while Instant::now() < until {
271 if done() {
272 return;
273 }
274 std::thread::sleep(every.min(Duration::from_millis(200)));
275 }
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use std::cell::{Cell, RefCell};
283
284 const NOW: i64 = 1_791_360_000;
285
286 fn power(on_mains: Option<bool>, power_saver: Option<bool>, idle: Option<bool>, metered: Option<bool>) -> Power {
287 Power { on_mains, power_saver, idle, metered }
288 }
289
290 fn ready() -> Facts {
294 Facts {
295 enabled: true,
296 trainer: Trainer::Here,
297 last_trained: Some(NOW - WEEK - 1),
298 last_run: None,
299 running: false,
300 focus: Some(false),
301 power: power(Some(true), Some(false), Some(true), Some(false)),
302 }
303 }
304
305 #[test]
306 fn it_trains_when_everything_allows_it() {
307 assert_eq!(decide(&ready(), NOW), Decision::Train { fetch: true });
308 assert_eq!(decide(&Facts { last_trained: None, ..ready() }, NOW), Decision::Train { fetch: true });
310 }
311
312 #[test]
314 fn each_condition_holds_it_back() {
315 let cases: Vec<(Facts, Wait)> = vec![
316 (Facts { enabled: false, ..ready() }, Wait::Off),
317 (Facts { trainer: Trainer::Nobody, ..ready() }, Wait::NoTable),
318 (Facts { trainer: Trainer::Elsewhere("desk".into()), ..ready() }, Wait::Elsewhere),
319 (Facts { running: true, ..ready() }, Wait::Running),
320 (Facts { last_trained: Some(NOW - WEEK + 60), ..ready() }, Wait::Recent),
321 (Facts { focus: Some(true), ..ready() }, Wait::Focus),
322 (Facts { power: power(Some(false), Some(false), Some(true), Some(false)), ..ready() }, Wait::Battery),
323 (Facts { power: power(Some(true), Some(true), Some(true), Some(false)), ..ready() }, Wait::PowerSaver),
324 (Facts { power: power(Some(true), Some(false), Some(false), Some(false)), ..ready() }, Wait::InUse),
325 ];
326 for (facts, why) in cases {
327 assert_eq!(decide(&facts, NOW), Decision::Wait(why), "{facts:?}");
328 }
329 }
330
331 #[test]
334 fn unknown_counts_as_no() {
335 let with = |p: Power| Facts { power: p, ..ready() };
336 assert_eq!(decide(&Facts { focus: None, ..ready() }, NOW), Decision::Wait(Wait::Focus));
337 assert_eq!(decide(&with(power(None, Some(false), Some(true), Some(false))), NOW), Decision::Wait(Wait::Battery));
338 assert_eq!(decide(&with(power(Some(true), None, Some(true), Some(false))), NOW), Decision::Wait(Wait::PowerSaver));
339 assert_eq!(decide(&with(power(Some(true), Some(false), None, Some(false))), NOW), Decision::Wait(Wait::InUse));
340 assert_eq!(decide(&with(power(Some(true), Some(false), Some(true), Some(true))), NOW), Decision::Train { fetch: false });
341 assert_eq!(decide(&with(power(Some(true), Some(false), Some(true), None)), NOW), Decision::Train { fetch: false });
342 assert_eq!(decide(&with(Power::default()), NOW), Decision::Wait(Wait::Battery), "nothing known: no");
343 }
344
345 #[test]
349 fn once_a_week_and_not_again_too_soon() {
350 let run = |started: i64, ended: Option<i64>, outcome: Option<Outcome>| Run { started, job: "j".into(), fetched: true, ended, outcome };
351 let after = |r: Run| decide(&Facts { last_run: Some(r), ..ready() }, NOW);
352 assert_eq!(after(run(NOW - 600, Some(NOW - 300), Some(Outcome::Stopped))), Decision::Wait(Wait::Retry));
353 assert_eq!(after(run(NOW - 3600, Some(NOW - AFTER_STOP - 1), Some(Outcome::Stopped))), Decision::Train { fetch: true });
354 assert_eq!(after(run(NOW - 3600, Some(NOW - 3000), Some(Outcome::Failed))), Decision::Wait(Wait::Retry));
355 assert_eq!(after(run(NOW - AFTER_FAILURE - 1, Some(NOW - AFTER_FAILURE), Some(Outcome::Failed))), Decision::Train { fetch: true });
356 assert_eq!(after(run(NOW - 3600, None, None)), Decision::Wait(Wait::Retry), "vanished: as failed");
357 let trained = Facts { last_trained: Some(NOW - 3000), last_run: Some(run(NOW - 3600, Some(NOW - 3000), Some(Outcome::Replaced))), ..ready() };
359 assert_eq!(decide(&trained, NOW), Decision::Wait(Wait::Recent));
360 let old = Facts { last_trained: Some(NOW - WEEK - 10), last_run: Some(run(NOW - WEEK - 100, Some(NOW - WEEK - 10), Some(Outcome::Kept))), ..ready() };
361 assert_eq!(decide(&old, NOW), Decision::Train { fetch: true });
362 }
363
364 #[test]
367 fn the_system_is_asked_last() {
368 let dir = std::env::temp_dir().join(format!("sioul-auto-{}", std::process::id()));
369 let _ = std::fs::remove_dir_all(&dir);
370 let dirs = Dirs::under(&dir);
371 let asked = Cell::new(0);
372 let f = facts(&dirs, true, "desk", false, false, NOW, || {
374 asked.set(asked.get() + 1);
375 Power::default()
376 });
377 assert_eq!((f.trainer.clone(), asked.get(), decide(&f, NOW)), (Trainer::Nobody, 0, Decision::Wait(Wait::NoTable)));
378 let run = Run { started: NOW - 100, job: "20261007-031200-train-abcd".into(), fetched: false, ended: None, outcome: None };
380 run.save(&dirs).unwrap();
381 Run::finish(&dirs, "another", Outcome::Failed, NOW).unwrap();
382 assert_eq!(Run::load(&dirs), Some(run.clone()), "another job's end is not this run's");
383 Run::finish(&dirs, &run.job, Outcome::Replaced, NOW).unwrap();
384 assert_eq!(Run::load(&dirs).map(|r| (r.ended, r.outcome)), Some((Some(NOW), Some(Outcome::Replaced))));
385 let jobs = dirs.state.join("jobs");
387 std::fs::create_dir_all(&jobs).unwrap();
388 let lock = std::fs::File::create(jobs.join("x.lock")).unwrap();
389 assert!(!a_job_runs(&dirs));
390 lock.lock().unwrap();
391 assert!(a_job_runs(&dirs));
392 drop(lock);
393 assert!(threads() >= 1);
394 let _ = std::fs::remove_dir_all(dir);
395 }
396
397 #[test]
401 fn the_run_stops_when_the_power_changes() {
402 let mains = power(Some(true), Some(false), None, None);
403 let watched = |readings: Vec<Power>| {
404 let left = RefCell::new(readings);
405 let (stopped, asked) = (Cell::new(false), Cell::new(0));
406 let next = || {
407 asked.set(asked.get() + 1);
408 let mut left = left.borrow_mut();
409 if left.is_empty() { mains } else { left.remove(0) }
410 };
411 watch_supply(next, || stopped.set(true), || asked.get() >= 10, Duration::from_millis(1));
413 (stopped.get(), asked.get())
414 };
415 assert_eq!(watched(vec![mains; 3]), (false, 10), "plugged in all along");
416 assert_eq!(watched(vec![mains, mains, power(Some(false), Some(false), None, None)]), (true, 3), "unplugged");
417 assert_eq!(watched(vec![mains, power(Some(true), Some(true), None, None)]), (true, 2), "saving power");
418 assert_eq!(watched(vec![mains, Power::default(), mains, Power::default(), Power::default()]), (true, 5), "twice unknown in a row");
419 assert_eq!(watched(vec![Power::default(), mains, Power::default(), mains]), (false, 10), "once unknown, then known again");
420 }
421}