diff --git a/src/sync.rs b/src/sync.rs index fe3e5339..1aa2138b 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -26,6 +26,7 @@ use crate::experiments::api::create_experiment; use crate::http::ApiClient; use crate::projects::api::{create_project, list_projects, Project}; use crate::ui::{animations_enabled, fuzzy_select, is_quiet}; +use crate::utils::parse_duration_to_seconds; const STATE_SCHEMA_VERSION: u32 = 1; const DEFAULT_PULL_LIMIT: usize = 100; @@ -2606,30 +2607,6 @@ fn build_root_spans_query( parts.join(" | ") } -fn parse_duration_to_seconds(input: &str) -> Result { - let trimmed = input.trim(); - if trimmed.is_empty() { - bail!("duration cannot be empty"); - } - if let Ok(seconds) = trimmed.parse::() { - return Ok(seconds); - } - - let (num_str, unit) = trimmed.split_at(trimmed.len().saturating_sub(1)); - let value: u64 = num_str - .trim() - .parse() - .with_context(|| format!("invalid duration '{input}'"))?; - let multiplier = match unit.to_ascii_lowercase().as_str() { - "s" => 1, - "m" => 60, - "h" => 60 * 60, - "d" => 60 * 60 * 24, - _ => bail!("invalid duration '{input}'. expected suffix s/m/h/d"), - }; - Ok(value.saturating_mul(multiplier)) -} - fn build_time_filter_clause(window: &str, extra_filter: Option<&str>) -> Result { let seconds = parse_duration_to_seconds(window)?; let time_clause = format!("created >= NOW() - INTERVAL {seconds} SECOND"); diff --git a/src/traces.rs b/src/traces.rs index eee2fc78..0d7e4ea7 100644 --- a/src/traces.rs +++ b/src/traces.rs @@ -36,6 +36,7 @@ use crate::args::BaseArgs; use crate::auth::{self, login}; use crate::http::ApiClient; use crate::ui::{fuzzy_select, is_interactive, with_spinner}; +use crate::utils::parse_duration_to_seconds; const MAX_TRACE_SPANS: usize = 5000; const MAX_BTQL_PAGE_LIMIT: usize = 1000; @@ -5006,30 +5007,6 @@ fn print_span_text(item: Option<&Map>) { } } -fn parse_duration_to_seconds(input: &str) -> Result { - let trimmed = input.trim(); - if trimmed.is_empty() { - bail!("duration cannot be empty"); - } - if let Ok(seconds) = trimmed.parse::() { - return Ok(seconds); - } - - let (num_str, unit) = trimmed.split_at(trimmed.len().saturating_sub(1)); - let value: u64 = num_str - .trim() - .parse() - .with_context(|| format!("invalid duration '{input}'"))?; - let multiplier = match unit.to_ascii_lowercase().as_str() { - "s" => 1, - "m" => 60, - "h" => 60 * 60, - "d" => 60 * 60 * 24, - _ => bail!("invalid duration '{input}'. expected suffix s/m/h/d"), - }; - Ok(value.saturating_mul(multiplier)) -} - fn build_base_filter_clause( since: Option<&str>, window: &str, @@ -6155,14 +6132,6 @@ mod tests { } } - #[test] - fn parse_duration_to_seconds_supports_units() { - assert_eq!(parse_duration_to_seconds("90").expect("seconds"), 90); - assert_eq!(parse_duration_to_seconds("15m").expect("minutes"), 900); - assert_eq!(parse_duration_to_seconds("2h").expect("hours"), 7_200); - assert_eq!(parse_duration_to_seconds("1d").expect("days"), 86_400); - } - #[test] fn build_base_filter_clause_uses_window_or_since() { let from_window = build_base_filter_clause(None, "1h", Some("metadata.model IS NOT NULL")) diff --git a/src/utils/duration.rs b/src/utils/duration.rs new file mode 100644 index 00000000..e7cbccbd --- /dev/null +++ b/src/utils/duration.rs @@ -0,0 +1,50 @@ +use anyhow::{bail, Context, Result}; + +pub fn parse_duration_to_seconds(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + bail!("duration cannot be empty"); + } + if let Ok(seconds) = trimmed.parse::() { + return Ok(seconds); + } + + let suffix = trimmed.chars().last().filter(|ch| ch.is_ascii_alphabetic()); + let (num_str, unit) = match suffix { + Some(unit) => (&trimmed[..trimmed.len() - unit.len_utf8()], unit), + None => (trimmed, 's'), + }; + let value: u64 = num_str + .trim() + .parse() + .with_context(|| format!("invalid duration '{input}'"))?; + let multiplier = match unit.to_ascii_lowercase() { + 's' => 1, + 'm' => 60, + 'h' => 60 * 60, + 'd' => 60 * 60 * 24, + _ => bail!("invalid duration '{input}'. expected suffix s/m/h/d"), + }; + Ok(value.saturating_mul(multiplier)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn supports_units() { + assert_eq!(parse_duration_to_seconds("90").expect("seconds"), 90); + assert_eq!(parse_duration_to_seconds("15m").expect("minutes"), 900); + assert_eq!(parse_duration_to_seconds("2h").expect("hours"), 7_200); + assert_eq!(parse_duration_to_seconds("1d").expect("days"), 86_400); + } + + #[test] + fn rejects_non_ascii_suffix_without_panicking() { + for input in ["1–", "1é", "1🙂"] { + let err = parse_duration_to_seconds(input).expect_err("invalid unicode suffix"); + assert!(err.to_string().contains("invalid duration")); + } + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 064472aa..fcc4fcfd 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,9 +1,11 @@ +mod duration; mod fs_atomic; mod git; mod ids; mod json_object; mod plurals; +pub use duration::parse_duration_to_seconds; pub use fs_atomic::write_text_atomic; pub use git::GitRepo; pub(crate) use ids::new_uuid_id;