use crate::settings::SettingsPersister;
use std::time::{Duration, Instant};
pub const DEFAULT_LIMIT_SECS: u32 = 120;
pub const MAX_LIMIT_SECS: u32 = 86_400;
pub const BAD_LIMIT_MESSAGE: &str =
"Scan plan time limit must be a whole number of seconds (0 = no limit).";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PlanDeadline {
start: Instant,
limit: Duration,
}
impl PlanDeadline {
#[must_use]
pub fn new(start: Instant, limit_secs: u32) -> Option<Self> {
(limit_secs > 0).then(|| Self {
start,
limit: Duration::from_secs(u64::from(limit_secs.min(MAX_LIMIT_SECS))),
})
}
#[must_use]
pub fn expired(&self, now: Instant) -> bool {
now.saturating_duration_since(self.start) >= self.limit
}
#[must_use]
pub fn remaining(&self, now: Instant) -> Duration {
self.limit
.saturating_sub(now.saturating_duration_since(self.start))
}
}
pub fn parse_limit(text: &str) -> Result<u32, String> {
let secs: u32 = text
.trim()
.parse()
.map_err(|_| BAD_LIMIT_MESSAGE.to_string())?;
if secs > MAX_LIMIT_SECS {
return Err(format!(
"Scan plan time limit must be at most {MAX_LIMIT_SECS} s (0 = no limit)."
));
}
Ok(secs)
}
#[must_use]
pub fn remaining_text(remaining: Duration) -> String {
let secs = remaining.as_secs() + u64::from(remaining.subsec_nanos() > 0);
if secs < 60 {
format!("{secs} s")
} else {
format!("{}:{:02}", secs / 60, secs % 60)
}
}
#[must_use]
pub fn stop_note(limit_secs: u64) -> String {
format!(
"Stopped at the time limit ({limit_secs} s): partial search; a rerun may differ. \
Raise the Scan plan time limit under Plan for a full plan."
)
}
#[must_use]
pub fn nothing_found_message(limit_secs: u64) -> String {
format!(
"The time limit ({limit_secs} s) was reached before the plan had found any layout. \
Raise the Scan plan time limit under Plan (0 = no limit) and plan again."
)
}
#[must_use]
pub fn stored_limit_secs() -> u32 {
SettingsPersister::installed_for_this_thread().map_or(DEFAULT_LIMIT_SECS, |persister| {
persister.snapshot().settings.plan_time_limit_secs
})
}
pub fn store_limit_secs(secs: u32) -> bool {
SettingsPersister::installed_for_this_thread().is_some_and(|persister| {
persister.update(|file| file.settings.plan_time_limit_secs = secs);
true
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings::SettingsFile;
fn at(start: Instant, secs: u64) -> Instant {
start + Duration::from_secs(secs)
}
#[test]
fn zero_is_no_limit() {
assert_eq!(PlanDeadline::new(Instant::now(), 0), None);
}
#[test]
fn the_deadline_expires_exactly_at_the_limit() {
let start = Instant::now();
let deadline = PlanDeadline::new(start, 5).expect("a limit");
assert!(!deadline.expired(start));
assert!(!deadline.expired(at(start, 4)));
assert!(deadline.expired(at(start, 5)));
assert!(deadline.expired(at(start, 500)));
assert_eq!(deadline.remaining(start), Duration::from_secs(5));
}
#[test]
fn the_time_left_counts_down_and_stops_at_zero() {
let start = Instant::now();
let deadline = PlanDeadline::new(start, 120).expect("a limit");
assert_eq!(deadline.remaining(start), Duration::from_secs(120));
assert_eq!(deadline.remaining(at(start, 20)), Duration::from_secs(100));
assert_eq!(deadline.remaining(at(start, 999)), Duration::ZERO);
assert_eq!(
deadline.remaining(start.checked_sub(Duration::from_secs(1)).unwrap_or(start)),
Duration::from_secs(120)
);
}
#[test]
fn a_huge_limit_counts_as_the_largest() {
let start = Instant::now();
let deadline = PlanDeadline::new(start, u32::MAX).expect("a limit");
assert_eq!(
deadline.remaining(start),
Duration::from_secs(u64::from(MAX_LIMIT_SECS))
);
}
#[test]
fn the_field_takes_whole_seconds_and_zero() {
assert_eq!(parse_limit("120"), Ok(120));
assert_eq!(parse_limit(" 5 "), Ok(5));
assert_eq!(parse_limit("0"), Ok(0));
assert_eq!(parse_limit("86400"), Ok(86_400));
for bad in ["", " ", "abc", "-1", "2.5", "1e3", "86401", "99999999999"] {
assert!(parse_limit(bad).is_err(), "{bad:?}");
}
assert_eq!(parse_limit("x"), Err(BAD_LIMIT_MESSAGE.to_string()));
assert!(parse_limit("86401").unwrap_err().contains("86400"));
}
#[test]
fn the_remaining_time_is_rounded_up_and_never_reads_zero_while_running() {
assert_eq!(remaining_text(Duration::from_secs(45)), "45 s");
assert_eq!(remaining_text(Duration::from_millis(44_100)), "45 s");
assert_eq!(remaining_text(Duration::from_millis(100)), "1 s");
assert_eq!(remaining_text(Duration::ZERO), "0 s");
assert_eq!(remaining_text(Duration::from_secs(60)), "1:00");
assert_eq!(remaining_text(Duration::from_secs(100)), "1:40");
assert_eq!(remaining_text(Duration::from_secs(7_325)), "122:05");
}
#[test]
fn the_notes_name_the_limit_and_the_field() {
let note = stop_note(5);
assert!(note.starts_with("Stopped at the time limit (5 s): partial search;"));
assert!(note.contains("a rerun may differ"));
assert!(note.contains("Scan plan time limit"));
let none = nothing_found_message(5);
assert!(none.contains("(5 s)"));
assert!(none.contains("before the plan had found any layout"));
assert!(none.contains("Scan plan time limit"));
}
#[test]
fn an_old_settings_file_loads_the_default_limit() {
let parsed: SettingsFile =
toml::from_str("[settings]\nexposure = 1.1\n").expect("an old file");
assert_eq!(parsed.settings.plan_time_limit_secs, DEFAULT_LIMIT_SECS);
let set: SettingsFile =
toml::from_str("[settings]\nplan_time_limit_secs = 0\n").expect("a limit of zero");
assert_eq!(set.settings.plan_time_limit_secs, 0);
}
}