use chrono::Duration;
use crate::error::{Result, ScoopError};
const MAX_DAYS: i64 = 365 * 200;
pub(crate) fn parse_duration(s: &str) -> Result<Duration> {
if s.is_empty() {
return Err(invalid("duration cannot be empty"));
}
let suffix_start = s
.find(|c: char| !c.is_ascii_digit())
.ok_or_else(|| invalid("duration must end with a unit suffix (d/w/y), e.g. 30d"))?;
let (num_part, suffix) = s.split_at(suffix_start);
if num_part.is_empty() {
return Err(invalid("duration must start with a number, e.g. 30d"));
}
if num_part.starts_with('+') || num_part.starts_with('-') {
return Err(invalid("duration must be a positive number, e.g. 30d"));
}
let value: u64 = num_part.parse().map_err(|_| {
invalid(&format!(
"could not parse '{num_part}' as a positive integer"
))
})?;
if value == 0 {
return Err(invalid(
"duration must be greater than zero — '0d' would match every env",
));
}
if !suffix.chars().all(|c| c.is_ascii_alphabetic()) {
return Err(invalid(&format!(
"duration must be a single integer followed by one unit suffix \
(e.g. 30d). '{suffix}' is not a recognized suffix — combined \
durations (like '1y6m') and whitespace-separated forms are not \
supported; pick one unit"
)));
}
let days_per_unit: u64 = match suffix {
"d" => 1,
"w" => 7,
"y" => 365,
"m" | "mo" | "mon" | "month" | "months" => {
return Err(invalid(
"month suffixes (m/mo/month) are ambiguous between minutes \
and months; use 'd' (days), 'w' (weeks), or 'y' (years=365d)",
));
}
_ => {
return Err(invalid(&format!(
"unknown duration suffix '{suffix}'; expected 'd', 'w', or 'y'"
)));
}
};
let total_days_u64 = value
.checked_mul(days_per_unit)
.ok_or_else(|| invalid("duration arithmetic overflowed"))?;
if total_days_u64 > MAX_DAYS as u64 {
return Err(invalid(&format!(
"duration too large (max {MAX_DAYS} days ≈ 200 years)"
)));
}
let total_days =
i64::try_from(total_days_u64).map_err(|_| invalid("duration arithmetic overflowed"))?;
Duration::try_days(total_days).ok_or_else(|| invalid("duration arithmetic overflowed"))
}
fn invalid(reason: &str) -> ScoopError {
ScoopError::InvalidArgument {
message: reason.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_days() {
assert_eq!(parse_duration("1d").unwrap(), Duration::days(1));
assert_eq!(parse_duration("30d").unwrap(), Duration::days(30));
assert_eq!(parse_duration("365d").unwrap(), Duration::days(365));
}
#[test]
fn parses_weeks_as_seven_days() {
assert_eq!(parse_duration("1w").unwrap(), Duration::days(7));
assert_eq!(parse_duration("2w").unwrap(), Duration::days(14));
assert_eq!(parse_duration("52w").unwrap(), Duration::days(364));
}
#[test]
fn parses_years_as_365_days_no_leap() {
assert_eq!(parse_duration("1y").unwrap(), Duration::days(365));
assert_eq!(parse_duration("2y").unwrap(), Duration::days(730));
}
#[test]
fn rejects_empty() {
assert!(parse_duration("").is_err());
}
#[test]
fn rejects_zero() {
for s in ["0d", "0w", "0y"] {
assert!(parse_duration(s).is_err(), "should reject {s}");
}
}
#[test]
fn rejects_unknown_suffix() {
for s in ["30h", "5s", "1mi", "30x"] {
assert!(parse_duration(s).is_err(), "should reject {s}");
}
}
#[test]
fn rejects_months_with_specific_hint() {
let err = parse_duration("6m").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("month") || msg.contains("ambiguous"),
"month rejection should explain why: {msg}"
);
}
#[test]
fn rejects_no_suffix() {
assert!(parse_duration("30").is_err());
assert!(parse_duration("1").is_err());
}
#[test]
fn rejects_no_number() {
assert!(parse_duration("d").is_err());
assert!(parse_duration("w").is_err());
}
#[test]
fn rejects_signed_values() {
assert!(parse_duration("-30d").is_err());
assert!(parse_duration("+30d").is_err());
}
#[test]
fn rejects_value_past_max_days_in_each_unit() {
let past_in_days = format!("{}d", MAX_DAYS + 1);
let past_in_weeks = format!("{}w", (MAX_DAYS / 7) + 1);
let past_in_years = format!("{}y", (MAX_DAYS / 365) + 1);
for s in [&past_in_days, &past_in_weeks, &past_in_years] {
assert!(parse_duration(s).is_err(), "should reject {s}");
}
}
#[test]
fn max_days_in_years_does_not_panic_on_cutoff() {
let d = parse_duration("200y").expect("200y must parse");
let now = chrono::Utc::now();
assert!(
now.checked_sub_signed(d).is_some(),
"cutoff math must not overflow for MAX_DAYS",
);
}
#[test]
fn rejects_garbage() {
for s in ["abc", "30dx", "d30", "1.5d", "1 d", " 30d "] {
assert!(parse_duration(s).is_err(), "should reject {s:?}");
}
}
#[test]
fn combined_or_separated_durations_surface_specific_hint() {
for s in ["200y2d", "1y6m", "30 d", "30d!"] {
let err = parse_duration(s).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("single integer") || msg.contains("combined"),
"{s} should surface structural-mistake hint, got: {msg}"
);
}
}
#[test]
fn max_days_boundary_accepted() {
let exact_days = format!("{MAX_DAYS}d");
assert!(parse_duration(&exact_days).is_ok());
assert!(parse_duration("200y").is_ok());
}
#[test]
fn rejects_month_variants_with_specific_hint() {
for s in ["6m", "6mo", "6mon", "6month", "6months"] {
let err = parse_duration(s).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("month") || msg.contains("ambiguous"),
"{s} must surface month-ambiguity hint, got: {msg}"
);
}
}
}