diff --git a/Cargo.lock b/Cargo.lock index ce46381..bbe341a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -425,7 +425,7 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libhaystack" -version = "3.1.3" +version = "3.1.4" dependencies = [ "chrono", "chrono-tz", diff --git a/Cargo.toml b/Cargo.toml index 25596bb..0e7799e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libhaystack" -version = "3.1.3" +version = "3.1.4" description = "Rust implementation of the Haystack 4 data types, defs, filter, units, and encodings" authors = ["J2 Innovations", "Radu Racariu "] edition = "2024" diff --git a/src/haystack.rs b/src/haystack.rs index b9dd841..6bff270 100644 --- a/src/haystack.rs +++ b/src/haystack.rs @@ -19,5 +19,6 @@ pub mod filter; pub mod timezone; #[cfg(feature = "units")] pub mod units; +pub mod util; #[cfg(feature = "value")] pub mod val; diff --git a/src/haystack/encoding/brio/json_fixture_tests.rs b/src/haystack/encoding/brio/json_fixture_tests.rs index c50ef2c..66ec93e 100644 --- a/src/haystack/encoding/brio/json_fixture_tests.rs +++ b/src/haystack/encoding/brio/json_fixture_tests.rs @@ -188,7 +188,7 @@ mod tests { // "2025-02-21T14:49:17.261337Z" — 261337 µs → 261337000 ns (non-zero nanos → I8) let dt = DateTime::parse_from_rfc3339_with_timezone("2025-02-21T14:49:17.261337Z", "UTC") .expect("parse dt"); - let v = Value::from(dt.clone()); + let v = Value::from(dt); let decoded = round_trip(&v); let decoded_dt = DateTime::try_from(&decoded).expect("expected DateTime"); assert_eq!( @@ -205,7 +205,7 @@ mod tests { // "2025-02-21T12:54:50.052481Z" — second fixture datetime let dt2 = DateTime::parse_from_rfc3339_with_timezone("2025-02-21T12:54:50.052481Z", "UTC") .expect("parse dt2"); - let v2 = Value::from(dt2.clone()); + let v2 = Value::from(dt2); let decoded2 = round_trip(&v2); let decoded_dt2 = DateTime::try_from(&decoded2).expect("expected DateTime"); assert_eq!( @@ -355,8 +355,8 @@ mod tests { use crate::haystack::val::XStr; // Build a minimal CTRL_BUF stream: ctrl | varint(3) | 0x01 0x02 0x03 - let raw: &[u8] = &[CTRL_BUF, 0x03, 0x01, 0x02, 0x03]; - let decoded = from_brio(&mut raw.as_ref()).expect("decode CTRL_BUF"); + let mut raw: &[u8] = &[CTRL_BUF, 0x03, 0x01, 0x02, 0x03]; + let decoded = from_brio(&mut raw).expect("decode CTRL_BUF"); let xs = XStr::try_from(&decoded).expect("XStr"); assert_eq!(xs.r#type, "Bin"); assert_eq!(xs.value, "010203"); @@ -368,8 +368,8 @@ mod tests { use crate::encoding::brio::encode::CTRL_BUF; use crate::haystack::val::XStr; - let raw: &[u8] = &[CTRL_BUF, 0x00]; - let decoded = from_brio(&mut raw.as_ref()).expect("decode empty CTRL_BUF"); + let mut raw: &[u8] = &[CTRL_BUF, 0x00]; + let decoded = from_brio(&mut raw).expect("decode empty CTRL_BUF"); let xs = XStr::try_from(&decoded).expect("XStr"); assert_eq!(xs.r#type, "Bin"); assert_eq!(xs.value, ""); diff --git a/src/haystack/util.rs b/src/haystack/util.rs new file mode 100644 index 0000000..93820c0 --- /dev/null +++ b/src/haystack/util.rs @@ -0,0 +1,151 @@ +// Copyright (C) 2020 - 2022, J2 Innovations + +//! Haystack tag name utilities. +//! +//! Provides functions for validating and converting arbitrary strings into +//! valid Haystack tag names. +//! +//! A valid tag name must match the grammar: +//! ```text +//! ( | | | '_')* +//! ``` +//! +//! For more information see + +use std::borrow::Cow; + +/// Returns `true` if `name` is already a valid Haystack tag name. +/// +/// A valid tag name starts with a lowercase ASCII letter followed by zero or +/// more ASCII letters, digits, or underscores. +/// +/// # Examples +/// ``` +/// use libhaystack::util::is_valid_tag_name; +/// +/// assert!(is_valid_tag_name("aValidTag123")); +/// assert!(!is_valid_tag_name("AValidTag")); +/// assert!(!is_valid_tag_name("1invalid")); +/// assert!(!is_valid_tag_name("")); +/// ``` +pub fn is_valid_tag_name(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(first) if first.is_ascii_lowercase() => { + chars.all(|c| c.is_ascii_alphanumeric() || c == '_') + } + _ => false, + } +} + +/// Converts an arbitrary string into a valid Haystack tag name. +/// +/// The conversion follows the same rules as the TypeScript `toTagName` utility: +/// +/// 1. If the string is already a valid tag name, it is returned unchanged. +/// 2. `.`, `-`, and `/` are replaced with `_`; if at position 0 they become +/// `v`; if at the last position they are dropped. +/// 3. A leading digit or `_` is prefixed with `v`. +/// 4. All remaining invalid characters are stripped. +/// 5. Spaces trigger camelCase conversion: each word after the first has its +/// first letter uppercased (provided it is a lowercase letter). +/// 6. A leading run of uppercase letters on the first word is lowercased. +/// 7. Returns `"empty"` if no valid characters remain. +/// +/// # Examples +/// ``` +/// use libhaystack::util::to_tag_name; +/// +/// assert_eq!(to_tag_name("oh what a time to be alive"), "ohWhatATimeToBeAlive"); +/// assert_eq!(to_tag_name("AIR TEMP"), "airTEMP"); +/// assert_eq!(to_tag_name("1test"), "v1test"); +/// assert_eq!(to_tag_name(""), "empty"); +/// assert_eq!(to_tag_name("alreadyValid"), "alreadyValid"); +/// ``` +pub fn to_tag_name(name: &str) -> Cow<'_, str> { + if is_valid_tag_name(name) { + return Cow::Borrowed(name); + } + + // Step 1: Replace `.`, `-`, `/` with `_`, `v` (at pos 0), or drop (at last pos). + let char_count = name.chars().count(); + let last_idx = char_count.saturating_sub(1); + + let mut step1 = String::with_capacity(name.len()); + for (i, c) in name.chars().enumerate() { + match c { + '.' | '-' | '/' => { + if i == 0 { + step1.push('v'); + } else if i != last_idx { + step1.push('_'); + } + // last position: drop the character + } + _ => step1.push(c), + } + } + + // Step 2: Prefix with `v` when the string starts with a digit or `_`. + let step2 = if step1.starts_with(|c: char| c.is_ascii_digit() || c == '_') { + let mut s = String::with_capacity(step1.len() + 1); + s.push('v'); + s.push_str(&step1); + s + } else { + step1 + }; + + // Step 3: Remove all characters that are not ASCII alphanumeric, `_`, or space; trim. + let step3 = step2 + .chars() + .filter(|&c| c.is_ascii_alphanumeric() || c == '_' || c == ' ') + .collect::(); + let step3 = step3.trim(); + + // Step 4: Split on spaces, camelCase conversion. + let result: String = step3 + .split(' ') + .filter(|part| !part.is_empty()) + .enumerate() + .map(|(i, part)| { + let mut chars = part.chars(); + let start = chars.next().expect("part is non-empty after filter"); + + if i == 0 { + // Lowercase the leading run of uppercase letters on the first word. + if start.is_ascii_uppercase() { + let mut new_part = String::with_capacity(part.len()); + let mut caps_prefix = true; + for ch in part.chars() { + if caps_prefix && ch.is_ascii_uppercase() { + new_part.push(ch.to_ascii_lowercase()); + } else { + caps_prefix = false; + new_part.push(ch); + } + } + new_part + } else { + part.to_string() + } + } else { + // Capitalize the first letter of subsequent words only when it is lowercase. + if start.is_ascii_alphabetic() && start.is_ascii_lowercase() { + let mut new_part = String::with_capacity(part.len()); + new_part.push(start.to_ascii_uppercase()); + new_part.push_str(&part[1..]); + new_part + } else { + part.to_string() + } + } + }) + .collect(); + + if result.is_empty() { + Cow::Borrowed("empty") + } else { + Cow::Owned(result) + } +} diff --git a/src/haystack/val/dict.rs b/src/haystack/val/dict.rs index 9d18245..468103c 100644 --- a/src/haystack/val/dict.rs +++ b/src/haystack/val/dict.rs @@ -1429,7 +1429,7 @@ mod test { for i in (0..8_usize).rev() { d.insert(format!("k{i:02}"), Value::from(i as i32)); } - let keys: Vec = d.keys().map(|k| k.clone()).collect(); + let keys: Vec = d.keys().cloned().collect(); let mut expected = keys.clone(); expected.sort(); assert_eq!(keys, expected); @@ -1438,7 +1438,7 @@ mod test { #[test] fn hybrid_tree_iteration_order_is_sorted() { let d = make_hybrid(16, 4); // threshold=4, so spills - let keys: Vec = d.keys().map(|k| k.clone()).collect(); + let keys: Vec = d.keys().cloned().collect(); let mut expected = keys.clone(); expected.sort(); assert_eq!(keys, expected); diff --git a/tests/test_tag_name.rs b/tests/test_tag_name.rs new file mode 100644 index 0000000..1148fbd --- /dev/null +++ b/tests/test_tag_name.rs @@ -0,0 +1,173 @@ +// Copyright (C) 2020 - 2022, J2 Innovations + +//! Test tag name utilities (to_tag_name, is_valid_tag_name) +//! +//! All test cases are ported from the TypeScript sister library +//! haystack-core/spec/core/Util.spec.ts + +#[cfg(test)] +use libhaystack::util::{is_valid_tag_name, to_tag_name}; +use std::borrow::Cow; + +// --------------------------------------------------------------------------- +// to_tag_name +// --------------------------------------------------------------------------- + +#[test] +fn test_to_tag_name_empty_string_returns_empty() { + assert_eq!(to_tag_name(""), "empty"); +} + +#[test] +fn test_to_tag_name_all_illegal_chars_returns_empty() { + assert_eq!(to_tag_name("!\"£$%^"), "empty"); +} + +#[test] +fn test_to_tag_name_sentence_to_camel_case() { + assert_eq!( + to_tag_name("oh what a time to be alive"), + "ohWhatATimeToBeAlive" + ); +} + +#[test] +fn test_to_tag_name_snake_case_unchanged() { + assert_eq!( + to_tag_name("oh_what_a_time_to_be_alive"), + "oh_what_a_time_to_be_alive" + ); +} + +#[test] +fn test_to_tag_name_removes_illegal_characters() { + assert_eq!(to_tag_name("£$%test&*( this!"), "testThis"); +} + +#[test] +fn test_to_tag_name_replaces_dot_with_underscore() { + assert_eq!(to_tag_name("test.me"), "test_me"); +} + +#[test] +fn test_to_tag_name_replaces_hyphen_with_underscore() { + assert_eq!(to_tag_name("test-me"), "test_me"); +} + +#[test] +fn test_to_tag_name_replaces_slash_with_underscore() { + assert_eq!(to_tag_name("test/me"), "test_me"); +} + +#[test] +fn test_to_tag_name_air_temp() { + assert_eq!(to_tag_name("AIR TEMP"), "airTEMP"); +} + +#[test] +fn test_to_tag_name_air_temp_mixed_1() { + assert_eq!(to_tag_name("AiR TEMP"), "aiRTEMP"); +} + +#[test] +fn test_to_tag_name_air_temp_mixed_2() { + assert_eq!(to_tag_name("aIR TEMP"), "aIRTEMP"); +} + +#[test] +fn test_to_tag_name_air_temp_mixed_3() { + assert_eq!(to_tag_name("AIrR TEMP"), "airRTEMP"); +} + +#[test] +fn test_to_tag_name_single_hyphen_becomes_v() { + assert_eq!(to_tag_name("-"), "v"); +} + +#[test] +fn test_to_tag_name_trailing_hyphen_dropped() { + assert_eq!(to_tag_name("v-"), "v"); +} + +#[test] +fn test_to_tag_name_trailing_hyphen_in_sentence_dropped() { + assert_eq!(to_tag_name("this is a test -"), "thisIsATest"); +} + +#[test] +fn test_to_tag_name_first_char_uppercase_lowercased() { + assert_eq!(to_tag_name("Hello"), "hello"); +} + +#[test] +fn test_to_tag_name_leading_digit_prefixed_with_v() { + assert_eq!(to_tag_name("1test"), "v1test"); +} + +#[test] +fn test_to_tag_name_all_digits_prefixed_with_v() { + assert_eq!(to_tag_name("0123456789"), "v0123456789"); +} + +#[test] +fn test_to_tag_name_leading_underscore_prefixed_with_v() { + assert_eq!(to_tag_name("_foo"), "v_foo"); +} + +// --------------------------------------------------------------------------- +// is_valid_tag_name +// --------------------------------------------------------------------------- + +#[test] +fn test_is_valid_tag_name_empty_string_returns_false() { + assert!(!is_valid_tag_name("")); +} + +#[test] +fn test_is_valid_tag_name_single_char_returns_true() { + assert!(is_valid_tag_name("a")); +} + +#[test] +fn test_is_valid_tag_name_valid_tag_returns_true() { + assert!(is_valid_tag_name("aValidTag123")); +} + +#[test] +fn test_is_valid_tag_name_spaces_returns_false() { + assert!(!is_valid_tag_name("what a wonderful world")); +} + +#[test] +fn test_is_valid_tag_name_uppercase_first_char_returns_false() { + assert!(!is_valid_tag_name("AValidTag")); +} + +#[test] +fn test_is_valid_tag_name_digit_first_char_returns_false() { + assert!(!is_valid_tag_name("1ValidTag")); +} + +#[test] +fn test_is_valid_tag_name_illegal_chars_returns_false() { + assert!(!is_valid_tag_name("aTa£$%g")); +} + +// --------------------------------------------------------------------------- +// Cow allocation guarantees +// --------------------------------------------------------------------------- + +#[test] +fn test_to_tag_name_already_valid_returns_borrowed() { + assert!(matches!(to_tag_name("alreadyValid"), Cow::Borrowed(_))); +} + +#[test] +fn test_to_tag_name_empty_fallback_returns_borrowed() { + assert!(matches!(to_tag_name(""), Cow::Borrowed(_))); +} + +#[test] +fn test_to_tag_name_transformation_returns_owned() { + assert!(matches!(to_tag_name("needs transformation"), Cow::Owned(_))); +} diff --git a/tests/tests.rs b/tests/tests.rs index 4d3a036..05f9a1a 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -10,5 +10,6 @@ extern crate libhaystack; mod defs; mod filter; mod json; +mod test_tag_name; mod values; mod zinc;