diff --git a/src/commands/ddsql.rs b/src/commands/ddsql.rs index 68830ef3..22911971 100644 --- a/src/commands/ddsql.rs +++ b/src/commands/ddsql.rs @@ -900,6 +900,35 @@ mod tests { assert!(err.to_string().contains("invalid --from")); } + #[test] + fn test_ddsql_builders_accept_mcp_compatible_relative_time() { + let public = build_ddsql_table_request("SELECT 1", "now-24h", "now", None).unwrap(); + let security = build_advanced_table_request("SELECT 1", "now-24h", "now", None).unwrap(); + let now_ms = chrono::Utc::now().timestamp() * 1000; + + for (from, to) in [ + ( + public["data"]["attributes"]["time"]["from_timestamp"] + .as_i64() + .unwrap(), + public["data"]["attributes"]["time"]["to_timestamp"] + .as_i64() + .unwrap(), + ), + ( + security["data"]["attributes"]["query"]["time_window"]["from"] + .as_i64() + .unwrap(), + security["data"]["attributes"]["query"]["time_window"]["to"] + .as_i64() + .unwrap(), + ), + ] { + assert!((from - (now_ms - 24 * 60 * 60 * 1000)).abs() < 2000); + assert!((to - now_ms).abs() < 2000); + } + } + #[test] fn test_build_ddsql_table_request_v2_shape() { let query = diff --git a/src/main.rs b/src/main.rs index 79534ab5..03e76f1a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4519,10 +4519,14 @@ enum DdsqlActions { #[arg( long, default_value = "1h", - help = "Start time (e.g., 1h, 30m, 7d, now, unix timestamp)" + help = "Start time. Formats: now, now- (e.g., now-24h), relative duration (e.g., 24h), RFC 3339 timestamp, Unix seconds, or Unix milliseconds" )] from: String, - #[arg(long, default_value = "now", help = "End time")] + #[arg( + long, + default_value = "now", + help = "End time. Accepts the same formats as --from (e.g., now)" + )] to: String, #[arg(long, help = "Aggregation interval in milliseconds (default: 60000)")] interval: Option, @@ -4540,9 +4544,17 @@ enum DdsqlActions { help = "DDSQL query string, or use --query - to read from stdin" )] query: String, - #[arg(long, default_value = "1h", help = "Start time")] + #[arg( + long, + default_value = "1h", + help = "Start time. Formats: now, now- (e.g., now-24h), relative duration (e.g., 24h), RFC 3339 timestamp, Unix seconds, or Unix milliseconds" + )] from: String, - #[arg(long, default_value = "now", help = "End time")] + #[arg( + long, + default_value = "now", + help = "End time. Accepts the same formats as --from (e.g., now)" + )] to: String, #[arg(long, help = "Aggregation interval in milliseconds (default: 60000)")] interval: Option, @@ -5604,11 +5616,11 @@ enum SecurityFindingActions { #[arg(long, short)] query: String, - /// Start time (default: 24h ago). Relative (e.g., 24h, 7d) or ISO 8601. + /// Start time. Formats: now, now- (e.g., now-24h), relative duration (e.g., 24h), RFC 3339 timestamp, Unix seconds, or Unix milliseconds. #[arg(long, default_value = "24h")] from: String, - /// End time (default: now). ISO 8601 or relative + /// End time. Accepts the same formats as --from (e.g., now). #[arg(long, default_value = "now")] to: String, diff --git a/src/test_commands.rs b/src/test_commands.rs index e5f18fd0..d01e81ce 100644 --- a/src/test_commands.rs +++ b/src/test_commands.rs @@ -556,6 +556,48 @@ fn test_ddsql_table_query_requires_explicit_value() { ); } +#[test] +fn test_ddsql_time_help_documents_supported_formats() { + let root = crate::Cli::command(); + let table_help = root + .find_subcommand("ddsql") + .unwrap() + .find_subcommand("table") + .unwrap() + .clone() + .render_long_help() + .to_string(); + let security_help = root + .find_subcommand("security") + .unwrap() + .find_subcommand("findings") + .unwrap() + .find_subcommand("analyze") + .unwrap() + .clone() + .render_long_help() + .to_string(); + + for (command, help) in [ + ("ddsql table", table_help), + ("security findings analyze", security_help), + ] { + for expected in [ + "now-", + "now-24h", + "relative duration", + "RFC 3339 timestamp", + "Unix seconds", + "Unix milliseconds", + ] { + assert!( + help.contains(expected), + "{command} help is missing {expected:?}: {help}" + ); + } + } +} + // ------------------------------------------------------------------------- // --sort with hyphen-prefixed values (e.g. -failure_rate, -timestamp) // ------------------------------------------------------------------------- diff --git a/src/util_ext.rs b/src/util_ext.rs index b6192f8c..fabb5af3 100644 --- a/src/util_ext.rs +++ b/src/util_ext.rs @@ -42,6 +42,7 @@ fn parse_relative_duration_millis(input: &str) -> Result { /// /// Supported formats: /// - "now" (case-insensitive) +/// - MCP-compatible relative: "now-1h", "now-30m", "now-7d" /// - Relative short: "1h", "30m", "7d", "5s", "1w" /// - Relative long: "5min", "5mins", "5minute", "5minutes", "2hr", "2hours", "3days", "1week" /// - With spaces: "5 minutes", "2 hours" @@ -58,7 +59,7 @@ pub fn parse_time_to_unix_millis(input: &str) -> Result { let time_parse_error = || { anyhow::anyhow!( "unable to parse time: {input:?}\n\ - Expected: now, 1h, 30m, 7d, 5minutes, YYYY-MM, YYYY-MM-DD, RFC3339, or Unix timestamp" + Expected: now, now-1h, 1h, 30m, 7d, 5minutes, YYYY-MM, YYYY-MM-DD, RFC3339, or Unix timestamp" ) }; @@ -66,6 +67,18 @@ pub fn parse_time_to_unix_millis(input: &str) -> Result { return Ok(now_millis()); } + if input + .get(..4) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("now-")) + { + let duration = &input[4..]; + if duration.starts_with('-') { + return Err(time_parse_error()); + } + let millis = parse_relative_duration_millis(duration).map_err(|_| time_parse_error())?; + return Ok(now_millis() - millis); + } + // Unix timestamp (all digits). Treat 10-digit values as seconds to match // CLI examples and common shell usage like `date +%s`; preserve longer // values as milliseconds. @@ -634,6 +647,26 @@ mod tests { assert!(parse_time_to_unix_millis("Now").is_ok()); } + #[test] + fn test_mcp_compatible_relative_time() { + for input in ["now-24h", "NOW-24H"] { + let ms = parse_time_to_unix_millis(input).unwrap(); + let expected = (Utc::now().timestamp() - 24 * 3600) * 1000; + assert!((ms - expected).abs() < 2000, "unexpected value for {input}"); + } + } + + #[test] + fn test_invalid_mcp_compatible_relative_time() { + for input in ["now-", "now--1h", "now-yesterday"] { + let err = parse_time_to_unix_millis(input).unwrap_err(); + assert!( + err.to_string().contains("unable to parse time"), + "unexpected error for {input}: {err}" + ); + } + } + #[test] fn test_relative_short() { let ms = parse_time_to_unix_millis("1h").unwrap();