diff --git a/Cargo.lock b/Cargo.lock index f2cf740..737289b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anes" version = "0.1.6" @@ -391,7 +400,9 @@ version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ + "iana-time-zone", "num-traits", + "windows-link", ] [[package]] @@ -1240,6 +1251,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -2846,6 +2881,7 @@ version = "0.1.0" dependencies = [ "async-trait", "base64 0.23.0", + "chrono", "futures-lite", "log", "lupus", @@ -3651,12 +3687,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 2e6a644..67e6e2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ serde_json = "1.0.149" serde = "1.0.228" uuid = { version = "1.23.0", features = ["v4"] } ureq = "3.0.0" +chrono = { version = "0.4.42", default-features = false, features = ["std", "clock"] } [workspace.dependencies.taurus-core] path = "./crates/taurus-core" diff --git a/crates/taurus-core/Cargo.toml b/crates/taurus-core/Cargo.toml index 2bc6c46..760344c 100644 --- a/crates/taurus-core/Cargo.toml +++ b/crates/taurus-core/Cargo.toml @@ -16,3 +16,4 @@ serde = { workspace = true } serde_json = { workspace = true } ureq = { workspace = true } tokio = { workspace = true } +chrono = { workspace = true } diff --git a/crates/taurus-core/src/runtime/functions/date.rs b/crates/taurus-core/src/runtime/functions/date.rs new file mode 100644 index 0000000..d61afeb --- /dev/null +++ b/crates/taurus-core/src/runtime/functions/date.rs @@ -0,0 +1,388 @@ +//! Date standard-library handlers. +//! +//! `DATE` is a `NUMBER` holding Unix-epoch microseconds (UTC), matching +//! `crate::time::now_unix_micros`. Keeping the on-wire representation a plain +//! number (rather than a formatted string) means dates stay comparable/sortable +//! like any other number and avoids re-parsing on every handler call. + +use crate::handler::argument::Argument; +use crate::handler::macros::{args, no_args}; +use crate::handler::registry::FunctionRegistration; +use crate::runtime::execution::value_store::ValueStore; +use crate::time::now_unix_micros; +use crate::types::errors::runtime_error::RuntimeError; +use crate::types::signal::Signal; +use crate::value::value_from_i64; +use chrono::{DateTime, NaiveDate, NaiveTime, Utc}; +use tucana::shared::{Value, value::Kind}; + +pub(crate) const FUNCTIONS: &[FunctionRegistration] = &[ + FunctionRegistration::eager("std::date::now", now, 0), + FunctionRegistration::eager("std::date::from", from, 6), + FunctionRegistration::eager("std::date::from_text", from_text, 1), + FunctionRegistration::eager("std::date::from_unix", from_unix, 1), + FunctionRegistration::eager("std::date::format", format, 2), +]; + +fn fail(message: impl Into) -> Signal { + Signal::Failure(RuntimeError::new( + "T-STD-00001", + "InvalidArgumentRuntimeError", + message, + )) +} + +/// `MONTH` is the string enum `'JAN' | 'FEB' | ... | 'DEC'`, not a number. +fn month_from_code(code: &str) -> Option { + let month = match code { + "JAN" => 1, + "FEB" => 2, + "MAR" => 3, + "APR" => 4, + "MAY" => 5, + "JUN" => 6, + "JUL" => 7, + "AUG" => 8, + "SEP" => 9, + "OCT" => 10, + "NOV" => 11, + "DEC" => 12, + _ => return None, + }; + Some(month) +} + +fn now( + args: &[Argument], + _ctx: &mut ValueStore, + _run: &mut crate::handler::registry::ThunkRunner<'_>, +) -> Signal { + no_args!(args); + Signal::Success(value_from_i64(now_unix_micros())) +} + +fn from( + args: &[Argument], + _ctx: &mut ValueStore, + _run: &mut crate::handler::registry::ThunkRunner<'_>, +) -> Signal { + args!(args => year: i64, month: String, day: i64, hour: i64, minute: i64, second: i64); + + let Some(month) = month_from_code(&month) else { + return fail(format!( + "Invalid month code '{}', expected one of JAN..DEC", + month + )); + }; + let Ok(year) = i32::try_from(year) else { + return fail(format!("Year out of range: {}", year)); + }; + let (Ok(day), Ok(hour), Ok(minute), Ok(second)) = ( + u32::try_from(day), + u32::try_from(hour), + u32::try_from(minute), + u32::try_from(second), + ) else { + return fail("day, hour, minute and second must be non-negative"); + }; + + let Some(date) = NaiveDate::from_ymd_opt(year, month, day) else { + return fail(format!( + "Invalid date: year={}, month={}, day={}", + year, month, day + )); + }; + let Some(time) = NaiveTime::from_hms_opt(hour, minute, second) else { + return fail(format!( + "Invalid time: hour={}, minute={}, second={}", + hour, minute, second + )); + }; + + Signal::Success(value_from_i64( + date.and_time(time).and_utc().timestamp_micros(), + )) +} + +fn from_text( + args: &[Argument], + _ctx: &mut ValueStore, + _run: &mut crate::handler::registry::ThunkRunner<'_>, +) -> Signal { + args!(args => value: String); + + match DateTime::parse_from_rfc3339(&value) { + Ok(parsed) => Signal::Success(value_from_i64( + parsed.with_timezone(&Utc).timestamp_micros(), + )), + Err(err) => fail(format!( + "Failed to parse date from text '{}': {}", + value, err + )), + } +} + +fn from_unix( + args: &[Argument], + _ctx: &mut ValueStore, + _run: &mut crate::handler::registry::ThunkRunner<'_>, +) -> Signal { + args!(args => value: f64); + // Saturating float->int cast: never panics/fails, matching `throwsError: false`. + let micros = (value * 1_000_000.0) as i64; + Signal::Success(value_from_i64(micros)) +} + +/// Translates the definitions' custom pattern tokens into `chrono` strftime specifiers. +const PATTERN_TOKENS: &[(&str, &str)] = &[ + ("YYYY", "%Y"), + ("YY", "%y"), + ("MM", "%m"), + ("DD", "%d"), + ("HH", "%H"), + ("hh", "%I"), + ("mm", "%M"), + ("ss", "%S"), +]; + +fn translate_pattern(pattern: &str) -> Result { + let chars: Vec = pattern.chars().collect(); + let mut out = String::with_capacity(pattern.len()); + let mut i = 0; + + while i < chars.len() { + if chars[i].is_ascii_alphabetic() { + let start = i; + while i < chars.len() && chars[i].is_ascii_alphabetic() { + i += 1; + } + let token: String = chars[start..i].iter().collect(); + match PATTERN_TOKENS.iter().find(|(t, _)| *t == token) { + Some((_, spec)) => out.push_str(spec), + None => return Err(format!("Unknown date format token '{}'", token)), + } + } else if chars[i] == '%' { + out.push_str("%%"); + i += 1; + } else { + out.push(chars[i]); + i += 1; + } + } + + Ok(out) +} + +fn format( + args: &[Argument], + _ctx: &mut ValueStore, + _run: &mut crate::handler::registry::ThunkRunner<'_>, +) -> Signal { + args!(args => date: i64, pattern: String); + + let Some(date) = DateTime::::from_timestamp_micros(date) else { + return fail(format!("Date is out of the representable range: {}", date)); + }; + let strftime_pattern = match translate_pattern(&pattern) { + Ok(pattern) => pattern, + Err(message) => return fail(message), + }; + + Signal::Success(Value { + kind: Some(Kind::StringValue( + date.format(&strftime_pattern).to_string(), + )), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::value::number_to_i64_lossy; + + fn a_num(n: i64) -> Argument { + Argument::Eval(value_from_i64(n)) + } + fn a_str(s: &str) -> Argument { + Argument::Eval(Value { + kind: Some(Kind::StringValue(s.to_string())), + }) + } + + fn dummy_run(_: &crate::handler::argument::Thunk, _: &mut ValueStore) -> Signal { + Signal::Stop + } + + fn expect_num(sig: Signal) -> i64 { + match sig { + Signal::Success(Value { + kind: Some(Kind::NumberValue(n)), + }) => number_to_i64_lossy(&n).unwrap_or_default(), + other => panic!("Expected NumberValue, got {:?}", other), + } + } + fn expect_str(sig: Signal) -> String { + match sig { + Signal::Success(Value { + kind: Some(Kind::StringValue(s)), + }) => s, + other => panic!("Expected StringValue, got {:?}", other), + } + } + + #[test] + fn test_now_returns_current_micros() { + let mut ctx = ValueStore::default(); + let mut run = dummy_run; + let before = now_unix_micros(); + let micros = expect_num(now(&[], &mut ctx, &mut run)); + let after = now_unix_micros(); + assert!(micros >= before && micros <= after); + } + + #[test] + fn test_from_builds_expected_timestamp_and_rejects_invalid_input() { + let mut ctx = ValueStore::default(); + + let mut run = dummy_run; + let micros = expect_num(from( + &[ + a_num(2026), + a_str("MAR"), + a_num(15), + a_num(10), + a_num(30), + a_num(0), + ], + &mut ctx, + &mut run, + )); + assert_eq!( + DateTime::::from_timestamp_micros(micros) + .unwrap_or_default() + .to_rfc3339(), + "2026-03-15T10:30:00+00:00" + ); + + // invalid month code + let mut run = dummy_run; + match from( + &[ + a_num(2026), + a_str("MARCH"), + a_num(15), + a_num(10), + a_num(30), + a_num(0), + ], + &mut ctx, + &mut run, + ) { + Signal::Failure(_) => {} + s => panic!("Expected Failure for invalid month, got {:?}", s), + } + + // invalid day for month + let mut run = dummy_run; + match from( + &[ + a_num(2026), + a_str("FEB"), + a_num(30), + a_num(0), + a_num(0), + a_num(0), + ], + &mut ctx, + &mut run, + ) { + Signal::Failure(_) => {} + s => panic!("Expected Failure for invalid day, got {:?}", s), + } + } + + #[test] + fn test_from_text_success_and_errors() { + let mut ctx = ValueStore::default(); + + let mut run = dummy_run; + let micros = expect_num(from_text( + &[a_str("2026-07-28T10:15:30Z")], + &mut ctx, + &mut run, + )); + assert_eq!( + DateTime::::from_timestamp_micros(micros) + .unwrap_or_default() + .to_rfc3339(), + "2026-07-28T10:15:30+00:00" + ); + + let mut run = dummy_run; + match from_text(&[a_str("not a date")], &mut ctx, &mut run) { + Signal::Failure(_) => {} + s => panic!("Expected Failure for unparseable date, got {:?}", s), + } + } + + #[test] + fn test_from_unix_converts_seconds_to_micros_and_never_fails() { + let mut ctx = ValueStore::default(); + + let mut run = dummy_run; + assert_eq!( + expect_num(from_unix( + &[Argument::Eval(value_from_i64(0))], + &mut ctx, + &mut run + )), + 0 + ); + + let mut run = dummy_run; + assert_eq!( + expect_num(from_unix( + &[Argument::Eval(crate::value::value_from_f64(1.5))], + &mut ctx, + &mut run + )), + 1_500_000 + ); + } + + #[test] + fn test_format_translates_tokens_and_rejects_unknown_ones() { + let mut ctx = ValueStore::default(); + let micros = NaiveDate::from_ymd_opt(2026, 3, 5) + .unwrap_or_default() + .and_time(NaiveTime::from_hms_opt(9, 5, 3).unwrap_or_default()) + .and_utc() + .timestamp_micros(); + + let mut run = dummy_run; + assert_eq!( + expect_str(format( + &[a_num(micros), a_str("YYYY-MM-DD")], + &mut ctx, + &mut run + )), + "2026-03-05" + ); + + let mut run = dummy_run; + assert_eq!( + expect_str(format( + &[a_num(micros), a_str("HH:mm:ss")], + &mut ctx, + &mut run + )), + "09:05:03" + ); + + let mut run = dummy_run; + match format(&[a_num(micros), a_str("YYYY-XX-DD")], &mut ctx, &mut run) { + Signal::Failure(_) => {} + s => panic!("Expected Failure for unknown token, got {:?}", s), + } + } +} diff --git a/crates/taurus-core/src/runtime/functions/file.rs b/crates/taurus-core/src/runtime/functions/file.rs new file mode 100644 index 0000000..f7a1d75 --- /dev/null +++ b/crates/taurus-core/src/runtime/functions/file.rs @@ -0,0 +1,123 @@ +//! File standard-library handlers. +//! +//! `FILE` is a struct `{ contentType: M; valueType: 'base64'; value: string }`. +//! Only `value` (the base64 payload) matters for these handlers; `contentType`/ +//! `valueType` are carried through untouched by callers. + +use crate::handler::argument::Argument; +use crate::handler::macros::args; +use crate::handler::registry::FunctionRegistration; +use crate::runtime::execution::value_store::ValueStore; +use crate::types::signal::Signal; +use crate::value::value_from_i64; +use base64::Engine; +use tucana::shared::{Struct, value::Kind}; + +pub(crate) const FUNCTIONS: &[FunctionRegistration] = + &[FunctionRegistration::eager("std::file::size", size, 1)]; + +fn size( + args: &[Argument], + _ctx: &mut ValueStore, + _run: &mut crate::handler::registry::ThunkRunner<'_>, +) -> Signal { + args!(args => file: Struct); + + // `throwsError: false` — a missing/malformed `value` field reports size 0 + // rather than failing. + let byte_len = match file.fields.get("value").and_then(|v| v.kind.as_ref()) { + Some(Kind::StringValue(base64_value)) => base64::prelude::BASE64_STANDARD + .decode(base64_value) + .map(|bytes| bytes.len()) + .unwrap_or(0), + _ => 0, + }; + + Signal::Success(value_from_i64(byte_len as i64)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::value::number_to_i64_lossy; + use base64::Engine; + use std::collections::HashMap; + use tucana::shared::Value; + + fn dummy_run(_: &crate::handler::argument::Thunk, _: &mut ValueStore) -> Signal { + Signal::Stop + } + + fn expect_num(sig: Signal) -> i64 { + match sig { + Signal::Success(Value { + kind: Some(Kind::NumberValue(n)), + }) => number_to_i64_lossy(&n).unwrap_or_default(), + other => panic!("Expected NumberValue, got {:?}", other), + } + } + + fn file_value(content_type: &str, value: &str) -> Argument { + let mut fields = HashMap::new(); + fields.insert( + "contentType".to_string(), + Value { + kind: Some(Kind::StringValue(content_type.to_string())), + }, + ); + fields.insert( + "valueType".to_string(), + Value { + kind: Some(Kind::StringValue("base64".to_string())), + }, + ); + fields.insert( + "value".to_string(), + Value { + kind: Some(Kind::StringValue(value.to_string())), + }, + ); + Argument::Eval(Value { + kind: Some(Kind::StructValue(Struct { fields })), + }) + } + + #[test] + fn test_size_decodes_base64_payload() { + let mut ctx = ValueStore::default(); + let mut run = dummy_run; + let encoded = base64::prelude::BASE64_STANDARD.encode("hello world"); + + assert_eq!( + expect_num(size( + &[file_value("text/plain", &encoded)], + &mut ctx, + &mut run + )), + 11 + ); + } + + #[test] + fn test_size_returns_zero_for_malformed_input() { + let mut ctx = ValueStore::default(); + + let mut run = dummy_run; + assert_eq!( + expect_num(size( + &[file_value("text/plain", "not-valid-base64!!")], + &mut ctx, + &mut run + )), + 0 + ); + + let mut run = dummy_run; + let empty_struct = Argument::Eval(Value { + kind: Some(Kind::StructValue(Struct { + fields: HashMap::new(), + })), + }); + assert_eq!(expect_num(size(&[empty_struct], &mut ctx, &mut run)), 0); + } +} diff --git a/crates/taurus-core/src/runtime/functions/mod.rs b/crates/taurus-core/src/runtime/functions/mod.rs index 2c9b16b..b6cbf56 100644 --- a/crates/taurus-core/src/runtime/functions/mod.rs +++ b/crates/taurus-core/src/runtime/functions/mod.rs @@ -7,6 +7,8 @@ use crate::handler::registry::FunctionRegistration; mod array; mod boolean; mod control; +mod date; +mod file; mod http; mod number; mod object; @@ -20,4 +22,6 @@ pub const ALL_FUNCTION_SETS: &[&[FunctionRegistration]] = &[ object::FUNCTIONS, control::FUNCTIONS, http::FUNCTIONS, + date::FUNCTIONS, + file::FUNCTIONS, ];