use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use anyhow::{Result, bail};
pub fn parse_duration(s: &str) -> Result<Duration> {
let s = s.trim();
if s.is_empty() {
bail!("empty duration string");
}
let mut total_secs: u64 = 0;
let mut current_num = String::new();
let mut found_any = false;
for ch in s.chars() {
if ch.is_ascii_digit() {
current_num.push(ch);
} else {
if current_num.is_empty() {
bail!("invalid duration: expected a number before '{}'", ch);
}
let n: u64 = current_num
.parse()
.map_err(|_| anyhow::anyhow!("invalid number in duration: {}", current_num))?;
current_num.clear();
let secs_for_unit = match ch {
'h' => n.checked_mul(3600),
'm' => n.checked_mul(60),
's' => Some(n),
_ => bail!("invalid duration unit '{}' (expected h, m, or s)", ch),
};
let secs = secs_for_unit.ok_or_else(|| {
anyhow::anyhow!("duration overflow: {}{} exceeds u64 seconds", n, ch)
})?;
total_secs = total_secs
.checked_add(secs)
.ok_or_else(|| anyhow::anyhow!("duration overflow: sum exceeds u64 seconds"))?;
found_any = true;
}
}
if !current_num.is_empty() {
bail!(
"invalid duration '{}': number {} has no unit (expected h, m, or s)",
s,
current_num
);
}
if !found_any {
bail!("invalid duration '{}'", s);
}
if total_secs == 0 {
bail!("timeout duration must be greater than zero");
}
Ok(Duration::from_secs(total_secs))
}
fn format_duration(d: Duration) -> String {
let total = d.as_secs();
let h = total / 3600;
let m = (total % 3600) / 60;
let s = total % 60;
let mut out = String::new();
if h > 0 {
out.push_str(&format!("{}h", h));
}
if m > 0 {
out.push_str(&format!("{}m", m));
}
if s > 0 || out.is_empty() {
out.push_str(&format!("{}s", s));
}
out
}
pub fn run_with_timeout<F>(timeout: Duration, f: F) -> Result<()>
where
F: FnOnce() -> Result<()>,
{
let deadline = Instant::now() + timeout;
let completed = Arc::new(AtomicBool::new(false));
let completed_clone = completed.clone();
let _watchdog = std::thread::spawn(move || {
let remaining = deadline.saturating_duration_since(Instant::now());
std::thread::sleep(remaining);
if !completed_clone.load(Ordering::SeqCst) {
eprintln!(
"\nERROR: pipeline timed out after {}; aborting. Use --timeout to increase the limit.",
format_duration(timeout)
);
std::process::exit(124);
}
});
let result = f();
completed.store(true, Ordering::SeqCst);
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_duration_seconds() {
assert_eq!(parse_duration("5s").unwrap(), Duration::from_secs(5));
}
#[test]
fn test_parse_duration_90_seconds() {
assert_eq!(parse_duration("90s").unwrap(), Duration::from_secs(90));
}
#[test]
fn test_parse_duration_minutes() {
assert_eq!(parse_duration("30m").unwrap(), Duration::from_secs(1800));
}
#[test]
fn test_parse_duration_hours() {
assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
}
#[test]
fn test_parse_duration_compound_hm() {
assert_eq!(parse_duration("2h30m").unwrap(), Duration::from_secs(9000));
}
#[test]
fn test_parse_duration_compound_hms() {
assert_eq!(
parse_duration("1h30m10s").unwrap(),
Duration::from_secs(5410)
);
}
#[test]
fn test_parse_duration_invalid_no_unit() {
assert!(parse_duration("30").is_err());
}
#[test]
fn test_parse_duration_invalid_word() {
assert!(parse_duration("invalid").is_err());
}
#[test]
fn test_parse_duration_empty() {
assert!(parse_duration("").is_err());
}
#[test]
fn test_parse_duration_invalid_unit() {
assert!(parse_duration("5x").is_err());
}
#[test]
fn test_parse_duration_zero_rejected() {
let err = parse_duration("0s").unwrap_err();
assert!(err.to_string().contains("greater than zero"));
}
#[test]
fn test_run_with_timeout_completes_before_deadline() {
let result = run_with_timeout(Duration::from_secs(5), || Ok(()));
assert!(result.is_ok());
}
#[test]
fn test_run_with_timeout_propagates_error() {
let result = run_with_timeout(Duration::from_secs(5), || {
anyhow::bail!("intentional error");
});
assert!(result.is_err());
assert_eq!(result.unwrap_err().to_string(), "intentional error");
}
}