Skip to content

Commit 80989b9

Browse files
committed
Add tag name support
1 parent 47c542f commit 80989b9

6 files changed

Lines changed: 314 additions & 8 deletions

File tree

src/haystack.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,6 @@ pub mod filter;
1919
pub mod timezone;
2020
#[cfg(feature = "units")]
2121
pub mod units;
22+
pub mod util;
2223
#[cfg(feature = "value")]
2324
pub mod val;

src/haystack/encoding/brio/json_fixture_tests.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ mod tests {
188188
// "2025-02-21T14:49:17.261337Z" — 261337 µs → 261337000 ns (non-zero nanos → I8)
189189
let dt = DateTime::parse_from_rfc3339_with_timezone("2025-02-21T14:49:17.261337Z", "UTC")
190190
.expect("parse dt");
191-
let v = Value::from(dt.clone());
191+
let v = Value::from(dt);
192192
let decoded = round_trip(&v);
193193
let decoded_dt = DateTime::try_from(&decoded).expect("expected DateTime");
194194
assert_eq!(
@@ -205,7 +205,7 @@ mod tests {
205205
// "2025-02-21T12:54:50.052481Z" — second fixture datetime
206206
let dt2 = DateTime::parse_from_rfc3339_with_timezone("2025-02-21T12:54:50.052481Z", "UTC")
207207
.expect("parse dt2");
208-
let v2 = Value::from(dt2.clone());
208+
let v2 = Value::from(dt2);
209209
let decoded2 = round_trip(&v2);
210210
let decoded_dt2 = DateTime::try_from(&decoded2).expect("expected DateTime");
211211
assert_eq!(
@@ -355,8 +355,8 @@ mod tests {
355355
use crate::haystack::val::XStr;
356356

357357
// Build a minimal CTRL_BUF stream: ctrl | varint(3) | 0x01 0x02 0x03
358-
let raw: &[u8] = &[CTRL_BUF, 0x03, 0x01, 0x02, 0x03];
359-
let decoded = from_brio(&mut raw.as_ref()).expect("decode CTRL_BUF");
358+
let mut raw: &[u8] = &[CTRL_BUF, 0x03, 0x01, 0x02, 0x03];
359+
let decoded = from_brio(&mut raw).expect("decode CTRL_BUF");
360360
let xs = XStr::try_from(&decoded).expect("XStr");
361361
assert_eq!(xs.r#type, "Bin");
362362
assert_eq!(xs.value, "010203");
@@ -368,8 +368,8 @@ mod tests {
368368
use crate::encoding::brio::encode::CTRL_BUF;
369369
use crate::haystack::val::XStr;
370370

371-
let raw: &[u8] = &[CTRL_BUF, 0x00];
372-
let decoded = from_brio(&mut raw.as_ref()).expect("decode empty CTRL_BUF");
371+
let mut raw: &[u8] = &[CTRL_BUF, 0x00];
372+
let decoded = from_brio(&mut raw).expect("decode empty CTRL_BUF");
373373
let xs = XStr::try_from(&decoded).expect("XStr");
374374
assert_eq!(xs.r#type, "Bin");
375375
assert_eq!(xs.value, "");

src/haystack/util.rs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Copyright (C) 2020 - 2022, J2 Innovations
2+
3+
//! Haystack tag name utilities.
4+
//!
5+
//! Provides functions for validating and converting arbitrary strings into
6+
//! valid Haystack tag names.
7+
//!
8+
//! A valid tag name must match the grammar:
9+
//! ```text
10+
//! <alphaLo> (<alphaLo> | <alphaHi> | <digit> | '_')*
11+
//! ```
12+
//!
13+
//! For more information see <https://project-haystack.org/doc/docHaystack/Kinds>
14+
15+
/// Returns `true` if `name` is already a valid Haystack tag name.
16+
///
17+
/// A valid tag name starts with a lowercase ASCII letter followed by zero or
18+
/// more ASCII letters, digits, or underscores.
19+
///
20+
/// # Examples
21+
/// ```
22+
/// use libhaystack::util::is_valid_tag_name;
23+
///
24+
/// assert!(is_valid_tag_name("aValidTag123"));
25+
/// assert!(!is_valid_tag_name("AValidTag"));
26+
/// assert!(!is_valid_tag_name("1invalid"));
27+
/// assert!(!is_valid_tag_name(""));
28+
/// ```
29+
pub fn is_valid_tag_name(name: &str) -> bool {
30+
let mut chars = name.chars();
31+
match chars.next() {
32+
Some(first) if first.is_ascii_lowercase() => {
33+
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
34+
}
35+
_ => false,
36+
}
37+
}
38+
39+
/// Converts an arbitrary string into a valid Haystack tag name.
40+
///
41+
/// The conversion follows the same rules as the TypeScript `toTagName` utility:
42+
///
43+
/// 1. If the string is already a valid tag name, it is returned unchanged.
44+
/// 2. `.`, `-`, and `/` are replaced with `_`; if at position 0 they become
45+
/// `v`; if at the last position they are dropped.
46+
/// 3. A leading digit or `_` is prefixed with `v`.
47+
/// 4. All remaining invalid characters are stripped.
48+
/// 5. Spaces trigger camelCase conversion: each word after the first has its
49+
/// first letter uppercased (provided it is a lowercase letter).
50+
/// 6. A leading run of uppercase letters on the first word is lowercased.
51+
/// 7. Returns `"empty"` if no valid characters remain.
52+
///
53+
/// # Examples
54+
/// ```
55+
/// use libhaystack::util::to_tag_name;
56+
///
57+
/// assert_eq!(to_tag_name("oh what a time to be alive"), "ohWhatATimeToBeAlive");
58+
/// assert_eq!(to_tag_name("AIR TEMP"), "airTEMP");
59+
/// assert_eq!(to_tag_name("1test"), "v1test");
60+
/// assert_eq!(to_tag_name(""), "empty");
61+
/// ```
62+
pub fn to_tag_name(name: &str) -> String {
63+
if is_valid_tag_name(name) {
64+
return name.to_string();
65+
}
66+
67+
// Step 1: Replace `.`, `-`, `/` with `_`, `v` (at pos 0), or drop (at last pos).
68+
let char_count = name.chars().count();
69+
let last_idx = char_count.saturating_sub(1);
70+
71+
let mut step1 = String::with_capacity(name.len());
72+
for (i, c) in name.chars().enumerate() {
73+
match c {
74+
'.' | '-' | '/' => {
75+
if i == 0 {
76+
step1.push('v');
77+
} else if i != last_idx {
78+
step1.push('_');
79+
}
80+
// last position: drop the character
81+
}
82+
_ => step1.push(c),
83+
}
84+
}
85+
86+
// Step 2: Prefix with `v` when the string starts with a digit or `_`.
87+
let step2 = if step1.starts_with(|c: char| c.is_ascii_digit() || c == '_') {
88+
let mut s = String::with_capacity(step1.len() + 1);
89+
s.push('v');
90+
s.push_str(&step1);
91+
s
92+
} else {
93+
step1
94+
};
95+
96+
// Step 3: Remove all characters that are not ASCII alphanumeric, `_`, or space; trim.
97+
let step3 = step2
98+
.chars()
99+
.filter(|&c| c.is_ascii_alphanumeric() || c == '_' || c == ' ')
100+
.collect::<String>();
101+
let step3 = step3.trim();
102+
103+
// Step 4: Split on spaces, camelCase conversion.
104+
let result: String = step3
105+
.split(' ')
106+
.filter(|part| !part.is_empty())
107+
.enumerate()
108+
.map(|(i, part)| {
109+
let mut chars = part.chars();
110+
let start = match chars.next() {
111+
Some(c) => c,
112+
None => return String::new(),
113+
};
114+
115+
if i == 0 {
116+
// Lowercase the leading run of uppercase letters on the first word.
117+
if start.is_ascii_uppercase() {
118+
let mut new_part = String::with_capacity(part.len());
119+
let mut caps_prefix = true;
120+
for ch in part.chars() {
121+
if caps_prefix && ch.is_ascii_uppercase() {
122+
new_part.push(ch.to_ascii_lowercase());
123+
} else {
124+
caps_prefix = false;
125+
new_part.push(ch);
126+
}
127+
}
128+
new_part
129+
} else {
130+
part.to_string()
131+
}
132+
} else {
133+
// Capitalize the first letter of subsequent words only when it is lowercase.
134+
if start.is_ascii_alphabetic() && start.is_ascii_lowercase() {
135+
let mut new_part = String::with_capacity(part.len());
136+
new_part.push(start.to_ascii_uppercase());
137+
new_part.push_str(&part[start.len_utf8()..]);
138+
new_part
139+
} else {
140+
part.to_string()
141+
}
142+
}
143+
})
144+
.collect();
145+
146+
if result.is_empty() {
147+
"empty".to_string()
148+
} else {
149+
result
150+
}
151+
}

src/haystack/val/dict.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1429,7 +1429,7 @@ mod test {
14291429
for i in (0..8_usize).rev() {
14301430
d.insert(format!("k{i:02}"), Value::from(i as i32));
14311431
}
1432-
let keys: Vec<String> = d.keys().map(|k| k.clone()).collect();
1432+
let keys: Vec<String> = d.keys().cloned().collect();
14331433
let mut expected = keys.clone();
14341434
expected.sort();
14351435
assert_eq!(keys, expected);
@@ -1438,7 +1438,7 @@ mod test {
14381438
#[test]
14391439
fn hybrid_tree_iteration_order_is_sorted() {
14401440
let d = make_hybrid(16, 4); // threshold=4, so spills
1441-
let keys: Vec<String> = d.keys().map(|k| k.clone()).collect();
1441+
let keys: Vec<String> = d.keys().cloned().collect();
14421442
let mut expected = keys.clone();
14431443
expected.sort();
14441444
assert_eq!(keys, expected);

tests/test_tag_name.rs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright (C) 2020 - 2022, J2 Innovations
2+
3+
//! Test tag name utilities (to_tag_name, is_valid_tag_name)
4+
//!
5+
//! All test cases are ported from the TypeScript sister library
6+
//! haystack-core/spec/core/Util.spec.ts
7+
8+
#[cfg(test)]
9+
use libhaystack::util::{is_valid_tag_name, to_tag_name};
10+
11+
// ---------------------------------------------------------------------------
12+
// to_tag_name
13+
// ---------------------------------------------------------------------------
14+
15+
#[test]
16+
fn test_to_tag_name_empty_string_returns_empty() {
17+
assert_eq!(to_tag_name(""), "empty");
18+
}
19+
20+
#[test]
21+
fn test_to_tag_name_all_illegal_chars_returns_empty() {
22+
assert_eq!(to_tag_name("!\"£$%^"), "empty");
23+
}
24+
25+
#[test]
26+
fn test_to_tag_name_sentence_to_camel_case() {
27+
assert_eq!(
28+
to_tag_name("oh what a time to be alive"),
29+
"ohWhatATimeToBeAlive"
30+
);
31+
}
32+
33+
#[test]
34+
fn test_to_tag_name_snake_case_unchanged() {
35+
assert_eq!(
36+
to_tag_name("oh_what_a_time_to_be_alive"),
37+
"oh_what_a_time_to_be_alive"
38+
);
39+
}
40+
41+
#[test]
42+
fn test_to_tag_name_removes_illegal_characters() {
43+
assert_eq!(to_tag_name("£$%test&*( this!"), "testThis");
44+
}
45+
46+
#[test]
47+
fn test_to_tag_name_replaces_dot_with_underscore() {
48+
assert_eq!(to_tag_name("test.me"), "test_me");
49+
}
50+
51+
#[test]
52+
fn test_to_tag_name_replaces_hyphen_with_underscore() {
53+
assert_eq!(to_tag_name("test-me"), "test_me");
54+
}
55+
56+
#[test]
57+
fn test_to_tag_name_replaces_slash_with_underscore() {
58+
assert_eq!(to_tag_name("test/me"), "test_me");
59+
}
60+
61+
#[test]
62+
fn test_to_tag_name_air_temp() {
63+
assert_eq!(to_tag_name("AIR TEMP"), "airTEMP");
64+
}
65+
66+
#[test]
67+
fn test_to_tag_name_air_temp_mixed_1() {
68+
assert_eq!(to_tag_name("AiR TEMP"), "aiRTEMP");
69+
}
70+
71+
#[test]
72+
fn test_to_tag_name_air_temp_mixed_2() {
73+
assert_eq!(to_tag_name("aIR TEMP"), "aIRTEMP");
74+
}
75+
76+
#[test]
77+
fn test_to_tag_name_air_temp_mixed_3() {
78+
assert_eq!(to_tag_name("AIrR TEMP"), "airRTEMP");
79+
}
80+
81+
#[test]
82+
fn test_to_tag_name_single_hyphen_becomes_v() {
83+
assert_eq!(to_tag_name("-"), "v");
84+
}
85+
86+
#[test]
87+
fn test_to_tag_name_trailing_hyphen_dropped() {
88+
assert_eq!(to_tag_name("v-"), "v");
89+
}
90+
91+
#[test]
92+
fn test_to_tag_name_trailing_hyphen_in_sentence_dropped() {
93+
assert_eq!(to_tag_name("this is a test -"), "thisIsATest");
94+
}
95+
96+
#[test]
97+
fn test_to_tag_name_first_char_uppercase_lowercased() {
98+
assert_eq!(to_tag_name("Hello"), "hello");
99+
}
100+
101+
#[test]
102+
fn test_to_tag_name_leading_digit_prefixed_with_v() {
103+
assert_eq!(to_tag_name("1test"), "v1test");
104+
}
105+
106+
#[test]
107+
fn test_to_tag_name_all_digits_prefixed_with_v() {
108+
assert_eq!(to_tag_name("0123456789"), "v0123456789");
109+
}
110+
111+
#[test]
112+
fn test_to_tag_name_leading_underscore_prefixed_with_v() {
113+
assert_eq!(to_tag_name("_foo"), "v_foo");
114+
}
115+
116+
// ---------------------------------------------------------------------------
117+
// is_valid_tag_name
118+
// ---------------------------------------------------------------------------
119+
120+
#[test]
121+
fn test_is_valid_tag_name_empty_string_returns_false() {
122+
assert!(!is_valid_tag_name(""));
123+
}
124+
125+
#[test]
126+
fn test_is_valid_tag_name_single_char_returns_true() {
127+
assert!(is_valid_tag_name("a"));
128+
}
129+
130+
#[test]
131+
fn test_is_valid_tag_name_valid_tag_returns_true() {
132+
assert!(is_valid_tag_name("aValidTag123"));
133+
}
134+
135+
#[test]
136+
fn test_is_valid_tag_name_spaces_returns_false() {
137+
assert!(!is_valid_tag_name("what a wonderful world"));
138+
}
139+
140+
#[test]
141+
fn test_is_valid_tag_name_uppercase_first_char_returns_false() {
142+
assert!(!is_valid_tag_name("AValidTag"));
143+
}
144+
145+
#[test]
146+
fn test_is_valid_tag_name_digit_first_char_returns_false() {
147+
assert!(!is_valid_tag_name("1ValidTag"));
148+
}
149+
150+
#[test]
151+
fn test_is_valid_tag_name_illegal_chars_returns_false() {
152+
assert!(!is_valid_tag_name("aTa£$%g"));
153+
}

tests/tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@ extern crate libhaystack;
1010
mod defs;
1111
mod filter;
1212
mod json;
13+
mod test_tag_name;
1314
mod values;
1415
mod zinc;

0 commit comments

Comments
 (0)