Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 <radur@j2inn.com>"]
edition = "2024"
Expand Down
1 change: 1 addition & 0 deletions src/haystack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
12 changes: 6 additions & 6 deletions src/haystack/encoding/brio/json_fixture_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand All @@ -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!(
Expand Down Expand Up @@ -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");
Expand All @@ -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, "");
Expand Down
151 changes: 151 additions & 0 deletions src/haystack/util.rs
Original file line number Diff line number Diff line change
@@ -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
//! <alphaLo> (<alphaLo> | <alphaHi> | <digit> | '_')*
//! ```
//!
//! For more information see <https://project-haystack.org/doc/docHaystack/Kinds>

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::<String>();
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)
}
}
4 changes: 2 additions & 2 deletions src/haystack/val/dict.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = d.keys().map(|k| k.clone()).collect();
let keys: Vec<String> = d.keys().cloned().collect();
let mut expected = keys.clone();
expected.sort();
assert_eq!(keys, expected);
Expand All @@ -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<String> = d.keys().map(|k| k.clone()).collect();
let keys: Vec<String> = d.keys().cloned().collect();
let mut expected = keys.clone();
expected.sort();
assert_eq!(keys, expected);
Expand Down
Loading
Loading