Skip to main content

gts_id/
lib.rs

1//! Shared GTS ID validation and parsing primitives.
2//!
3//! This crate provides the single source of truth for GTS identifier validation,
4//! used by both the `gts` runtime library and the `gts-macros` proc-macro crate.
5
6use thiserror::Error;
7
8/// The required prefix for all GTS identifiers.
9pub const GTS_PREFIX: &str = "gts.";
10
11/// Maximum allowed length for a GTS identifier string.
12pub const GTS_MAX_LENGTH: usize = 1024;
13
14/// Errors from GTS ID validation.
15#[derive(Debug, Error)]
16pub enum GtsIdError {
17    /// A specific segment within the ID is invalid.
18    #[error("Segment #{num}: {cause}")]
19    Segment {
20        /// 1-based segment number.
21        num: usize,
22        /// Byte offset of this segment within the full ID string.
23        offset: usize,
24        /// The raw segment string that failed validation.
25        segment: String,
26        /// Human-readable description of the problem.
27        cause: String,
28    },
29
30    /// The ID as a whole is invalid (prefix, case, length, etc.).
31    #[error("Invalid GTS ID: {cause}")]
32    Id {
33        /// The raw ID string that failed validation.
34        id: String,
35        /// Human-readable description of the problem.
36        cause: String,
37    },
38}
39
40/// Result of successfully parsing a single GTS segment.
41#[derive(Debug, Clone, PartialEq, Eq)]
42#[allow(clippy::struct_excessive_bools)]
43pub struct ParsedSegment {
44    /// The raw segment string (including trailing `~` if present).
45    pub raw: String,
46    /// Byte offset of this segment within the full ID string.
47    pub offset: usize,
48    /// Vendor token (1st dot-separated token).
49    pub vendor: String,
50    /// Package token (2nd dot-separated token).
51    pub package: String,
52    /// Namespace token (3rd dot-separated token).
53    pub namespace: String,
54    /// Type name token (4th dot-separated token).
55    pub type_name: String,
56    /// Major version number.
57    pub ver_major: u32,
58    /// Optional minor version number.
59    pub ver_minor: Option<u32>,
60    /// Whether this segment ends with `~` (type marker).
61    pub is_type: bool,
62    /// Whether this segment contains a wildcard `*` token.
63    pub is_wildcard: bool,
64    /// Whether this segment is a UUID tail (combined anonymous instance).
65    pub is_uuid_tail: bool,
66}
67
68/// Expected format string for segment error messages.
69///
70/// Segment #1 shows the `gts.` prefix because the user writes
71/// `gts.vendor.package...`; segments #2+ omit it because they
72/// come after a `~` delimiter.
73#[must_use]
74fn expected_format(segment_num: usize) -> &'static str {
75    if segment_num == 1 {
76        "gts.vendor.package.namespace.type.vMAJOR[.MINOR]"
77    } else {
78        "vendor.package.namespace.type.vMAJOR[.MINOR]"
79    }
80}
81
82/// Checks whether a string matches the UUID format
83/// `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` (hex digits and dashes only).
84#[inline]
85#[must_use]
86pub fn is_uuid(s: &str) -> bool {
87    s.len() == 36
88        && s.char_indices().all(|(i, c)| match i {
89            8 | 13 | 18 | 23 => c == '-',
90            _ => c.is_ascii_hexdigit(),
91        })
92}
93
94/// Validates a GTS segment token without regex.
95///
96/// Valid tokens: start with `[a-z_]`, followed by `[a-z0-9_]*`.
97#[inline]
98#[must_use]
99pub fn is_valid_segment_token(token: &str) -> bool {
100    if token.is_empty() {
101        return false;
102    }
103    let mut chars = token.chars();
104    match chars.next() {
105        Some(c) if c.is_ascii_lowercase() || c == '_' => {}
106        _ => return false,
107    }
108    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
109}
110
111/// Parse a `u32` and reject leading zeros (except `"0"` itself).
112#[inline]
113#[must_use]
114pub fn parse_u32_exact(value: &str) -> Option<u32> {
115    let parsed = value.parse::<u32>().ok()?;
116    if parsed.to_string() == value {
117        Some(parsed)
118    } else {
119        None
120    }
121}
122
123/// Validate and parse a single GTS segment (the part between `~` markers).
124///
125/// # Arguments
126/// * `segment_num` - 1-based segment number (used in error messages and format hints)
127/// * `segment` - The raw segment string, possibly including a trailing `~`
128/// * `allow_wildcards` - If `true`, a trailing wildcard `*` token is accepted as the final token
129///
130/// # Errors
131/// Returns a human-readable error message if the segment is invalid.
132pub fn validate_segment(
133    segment_num: usize,
134    segment: &str,
135    allow_wildcards: bool,
136) -> Result<ParsedSegment, String> {
137    let mut seg = segment.to_owned();
138    let mut is_type = false;
139
140    // Check for type marker (~)
141    if seg.contains('~') {
142        let tilde_count = seg.matches('~').count();
143        if tilde_count > 1 {
144            return Err("Too many '~' characters".to_owned());
145        }
146        if seg.ends_with('~') {
147            is_type = true;
148            seg.pop();
149        } else {
150            return Err("'~' must be at the end".to_owned());
151        }
152    }
153
154    let tokens: Vec<&str> = seg.split('.').collect();
155    let fmt = expected_format(segment_num);
156
157    if tokens.len() > 6 {
158        return Err(format!(
159            "Too many tokens (got {}, max 6). Expected format: {fmt}",
160            tokens.len()
161        ));
162    }
163
164    let ends_with_wildcard = allow_wildcards && seg.ends_with('*');
165
166    if !ends_with_wildcard && tokens.len() < 5 {
167        return Err(format!(
168            "Too few tokens (got {}, min 5). Expected format: {fmt}",
169            tokens.len()
170        ));
171    }
172
173    // Detect extra name token before version (e.g., vendor.package.namespace.type.extra.v1)
174    if !ends_with_wildcard && tokens.len() == 6 {
175        let has_wildcard = allow_wildcards && tokens.contains(&"*");
176        if !has_wildcard
177            && !tokens[4].starts_with('v')
178            && tokens[5].starts_with('v')
179            && is_valid_segment_token(tokens[4])
180        {
181            return Err(format!(
182                "Too many name tokens before version (got 5, expected 4). Expected format: {fmt}"
183            ));
184        }
185    }
186
187    // Validate first 4 tokens (vendor, package, namespace, type).
188    // A trailing '*' wildcard is allowed as the final token, but all tokens
189    // before it must still pass validation. Wildcards in the middle
190    // (e.g., "x.*.ns.type.v1") are rejected because '*' fails is_valid_segment_token.
191    for (i, token) in tokens.iter().take(4).enumerate() {
192        if allow_wildcards && *token == "*" {
193            if i == tokens.len() - 1 {
194                break; // '*' as final token is handled in the parsing section below
195            }
196            return Err("Wildcard '*' is only allowed as the final token".to_owned());
197        }
198        if !is_valid_segment_token(token) {
199            let token_name = match i {
200                0 => "vendor",
201                1 => "package",
202                2 => "namespace",
203                3 => "type",
204                _ => "token",
205            };
206            return Err(format!(
207                "Invalid {token_name} token '{token}'. \
208                 Must start with [a-z_] and contain only [a-z0-9_]"
209            ));
210        }
211    }
212
213    // Build the result, parsing tokens progressively.
214    // Offset is set to 0 here; callers like validate_gts_id() override it
215    // with the actual position within the full ID string.
216    let mut result = ParsedSegment {
217        raw: segment.to_owned(),
218        offset: 0,
219        vendor: String::new(),
220        package: String::new(),
221        namespace: String::new(),
222        type_name: String::new(),
223        ver_major: 0,
224        ver_minor: None,
225        is_type,
226        is_wildcard: false,
227        is_uuid_tail: false,
228    };
229
230    if !tokens.is_empty() {
231        if allow_wildcards && tokens[0] == "*" {
232            result.is_wildcard = true;
233            return Ok(result);
234        }
235        tokens[0].clone_into(&mut result.vendor);
236    }
237
238    if tokens.len() > 1 {
239        if allow_wildcards && tokens[1] == "*" {
240            result.is_wildcard = true;
241            return Ok(result);
242        }
243        tokens[1].clone_into(&mut result.package);
244    }
245
246    if tokens.len() > 2 {
247        if allow_wildcards && tokens[2] == "*" {
248            result.is_wildcard = true;
249            return Ok(result);
250        }
251        tokens[2].clone_into(&mut result.namespace);
252    }
253
254    if tokens.len() > 3 {
255        if allow_wildcards && tokens[3] == "*" {
256            result.is_wildcard = true;
257            return Ok(result);
258        }
259        tokens[3].clone_into(&mut result.type_name);
260    }
261
262    if tokens.len() > 4 {
263        if allow_wildcards && tokens[4] == "*" {
264            if 4 != tokens.len() - 1 {
265                return Err("Wildcard '*' is only allowed as the final token".to_owned());
266            }
267            result.is_wildcard = true;
268            return Ok(result);
269        }
270
271        if !tokens[4].starts_with('v') {
272            return Err("Major version must start with 'v'".to_owned());
273        }
274
275        let major_str = &tokens[4][1..];
276        result.ver_major = parse_u32_exact(major_str)
277            .ok_or_else(|| format!("Major version must be an integer, got '{major_str}'"))?;
278    }
279
280    if tokens.len() > 5 {
281        if allow_wildcards && tokens[5] == "*" {
282            result.is_wildcard = true;
283            return Ok(result);
284        }
285
286        result.ver_minor = Some(
287            parse_u32_exact(tokens[5])
288                .ok_or_else(|| format!("Minor version must be an integer, got '{}'", tokens[5]))?,
289        );
290    }
291
292    Ok(result)
293}
294
295/// Validate a full GTS identifier string.
296///
297/// Checks the `gts.` prefix, lowercase, length, then splits by `~` and
298/// validates each segment via [`validate_segment`]. Hyphens are rejected
299/// in the GTS segments portion but permitted in a trailing UUID
300/// (combined anonymous instance, e.g. `gts.type.v1~schema.v1.0~<uuid>`).
301///
302/// # Arguments
303/// * `id` - The raw GTS identifier string
304/// * `allow_wildcards` - If `true`, wildcard `*` tokens are accepted
305///
306/// # Errors
307/// Returns [`GtsIdError`] on validation failure.
308pub fn validate_gts_id(id: &str, allow_wildcards: bool) -> Result<Vec<ParsedSegment>, GtsIdError> {
309    let raw = id.trim();
310
311    if !raw.starts_with(GTS_PREFIX) {
312        return Err(GtsIdError::Id {
313            id: id.to_owned(),
314            cause: format!("must start with '{GTS_PREFIX}'"),
315        });
316    }
317
318    if raw != raw.to_lowercase() {
319        return Err(GtsIdError::Id {
320            id: id.to_owned(),
321            cause: "must be lowercase".to_owned(),
322        });
323    }
324
325    if raw.len() > GTS_MAX_LENGTH {
326        return Err(GtsIdError::Id {
327            id: id.to_owned(),
328            cause: format!("too long ({} chars, max {GTS_MAX_LENGTH})", raw.len()),
329        });
330    }
331
332    let remainder = &raw[GTS_PREFIX.len()..];
333    let tilde_parts: Vec<&str> = remainder.split('~').collect();
334
335    // Detect combined anonymous instance: last tilde-part is a UUID.
336    // e.g. "gts.type.v1~schema.v1.0~7a1d2f34-5678-49ab-9012-abcdef123456"
337    // The UUID tail is only valid when preceded by at least one type segment (ending with ~).
338    let uuid_tail: Option<&str> = {
339        let last = tilde_parts.last().copied().unwrap_or("");
340        if is_uuid(last) && tilde_parts.len() >= 2 {
341            Some(last)
342        } else {
343            None
344        }
345    };
346
347    // Reject hyphens in the GTS segments portion (hyphens are only allowed in the UUID tail).
348    let segments_portion = match uuid_tail {
349        Some(uuid) => &raw[..raw.len() - uuid.len() - 1], // strip "~<uuid>"
350        None => raw,
351    };
352    if segments_portion.contains('-') {
353        return Err(GtsIdError::Id {
354            id: id.to_owned(),
355            cause: "must not contain '-'".to_owned(),
356        });
357    }
358
359    // Build the list of raw segment strings, excluding the UUID tail.
360    // When a UUID tail is present, every preceding tilde-part was followed by '~'
361    // in the original string, so each is a type segment — append '~' to all of them.
362    // Otherwise use the standard reconstruction (last part may or may not have '~').
363    let seg_count = tilde_parts.len() - usize::from(uuid_tail.is_some());
364    let mut segments_raw: Vec<String> = Vec::new();
365    for (i, &part) in tilde_parts.iter().enumerate().take(seg_count) {
366        let is_last = i == seg_count - 1;
367        if part.is_empty() {
368            // The only allowed empty part is the single trailing one produced by a
369            // type-marker `~` at the end (e.g. "gts.v.p.n.t.v1~"). Any other empty
370            // part means consecutive tildes (e.g. "~~") or a leading tilde, which
371            // are invalid.
372            if !(is_last && uuid_tail.is_none()) {
373                return Err(GtsIdError::Id {
374                    id: id.to_owned(),
375                    cause: format!("empty segment at tilde-part #{}", i + 1),
376                });
377            }
378        } else if is_last && uuid_tail.is_none() {
379            segments_raw.push(part.to_owned());
380        } else {
381            segments_raw.push(format!("{part}~"));
382        }
383    }
384
385    if segments_raw.is_empty() {
386        return Err(GtsIdError::Id {
387            id: id.to_owned(),
388            cause: "no segments found".to_owned(),
389        });
390    }
391
392    let mut parsed_segments = Vec::new();
393    let mut offset = GTS_PREFIX.len();
394    for (i, seg) in segments_raw.iter().enumerate() {
395        if seg.is_empty() || seg == "~" {
396            return Err(GtsIdError::Id {
397                id: id.to_owned(),
398                cause: format!("segment #{} @ offset {offset} is empty", i + 1),
399            });
400        }
401
402        let mut parsed =
403            validate_segment(i + 1, seg, allow_wildcards).map_err(|cause| GtsIdError::Segment {
404                num: i + 1,
405                offset,
406                segment: seg.clone(),
407                cause,
408            })?;
409        parsed.offset = offset;
410        offset += seg.len();
411        parsed_segments.push(parsed);
412    }
413
414    // Append the UUID tail as a special ParsedSegment if present.
415    // All preceding segments are guaranteed to be type segments because we
416    // appended '~' to every gts_part in the uuid_tail branch above.
417    if let Some(uuid) = uuid_tail {
418        parsed_segments.push(ParsedSegment {
419            raw: uuid.to_owned(),
420            offset,
421            vendor: String::new(),
422            package: String::new(),
423            namespace: String::new(),
424            type_name: String::new(),
425            ver_major: 0,
426            ver_minor: None,
427            is_type: false,
428            is_wildcard: false,
429            is_uuid_tail: true,
430        });
431    }
432
433    Ok(parsed_segments)
434}
435
436#[cfg(test)]
437#[allow(clippy::unwrap_used, clippy::expect_used)]
438mod tests {
439    use super::*;
440
441    // ---- is_valid_segment_token ----
442
443    #[test]
444    fn test_valid_tokens() {
445        assert!(is_valid_segment_token("abc"));
446        assert!(is_valid_segment_token("a1b2"));
447        assert!(is_valid_segment_token("_private"));
448        assert!(is_valid_segment_token("a_b_c"));
449    }
450
451    #[test]
452    fn test_invalid_tokens() {
453        assert!(!is_valid_segment_token(""));
454        assert!(!is_valid_segment_token("1abc"));
455        assert!(!is_valid_segment_token("ABC"));
456        assert!(!is_valid_segment_token("a-b"));
457        assert!(!is_valid_segment_token("a.b"));
458    }
459
460    // ---- parse_u32_exact ----
461
462    #[test]
463    fn test_parse_u32_exact_valid() {
464        assert_eq!(parse_u32_exact("0"), Some(0));
465        assert_eq!(parse_u32_exact("1"), Some(1));
466        assert_eq!(parse_u32_exact("42"), Some(42));
467    }
468
469    #[test]
470    fn test_parse_u32_exact_rejects_leading_zeros() {
471        assert_eq!(parse_u32_exact("01"), None);
472        assert_eq!(parse_u32_exact("007"), None);
473    }
474
475    #[test]
476    fn test_parse_u32_exact_rejects_non_numeric() {
477        assert_eq!(parse_u32_exact("abc"), None);
478        assert_eq!(parse_u32_exact(""), None);
479    }
480
481    // ---- validate_segment ----
482
483    #[test]
484    fn test_valid_segment_basic() {
485        let parsed = validate_segment(1, "x.core.events.event.v1~", false).unwrap();
486        assert_eq!(parsed.vendor, "x");
487        assert_eq!(parsed.package, "core");
488        assert_eq!(parsed.namespace, "events");
489        assert_eq!(parsed.type_name, "event");
490        assert_eq!(parsed.ver_major, 1);
491        assert_eq!(parsed.ver_minor, None);
492        assert!(parsed.is_type);
493        assert!(!parsed.is_wildcard);
494    }
495
496    #[test]
497    fn test_valid_segment_with_minor() {
498        let parsed = validate_segment(1, "x.core.events.event.v1.2~", false).unwrap();
499        assert_eq!(parsed.ver_major, 1);
500        assert_eq!(parsed.ver_minor, Some(2));
501    }
502
503    #[test]
504    fn test_segment_too_many_tildes() {
505        let err = validate_segment(1, "x.core.events.event.v1~~", false).unwrap_err();
506        assert!(err.contains("Too many '~' characters"), "got: {err}");
507    }
508
509    #[test]
510    fn test_segment_tilde_not_at_end() {
511        let err = validate_segment(1, "x.core~mid.events.event.v1", false).unwrap_err();
512        assert!(err.contains("'~' must be at the end"), "got: {err}");
513    }
514
515    #[test]
516    fn test_segment_too_many_tokens() {
517        let err = validate_segment(1, "x.core.events.event.v1.2.extra~", false).unwrap_err();
518        assert!(err.contains("Too many tokens"), "got: {err}");
519    }
520
521    #[test]
522    fn test_segment_too_few_tokens() {
523        let err = validate_segment(1, "x.core.events.event~", false).unwrap_err();
524        assert!(err.contains("Too few tokens"), "got: {err}");
525    }
526
527    #[test]
528    fn test_segment_too_many_name_tokens() {
529        let err = validate_segment(2, "x.core.ns.type.extra.v1~", false).unwrap_err();
530        assert!(
531            err.contains("Too many name tokens before version"),
532            "got: {err}"
533        );
534    }
535
536    #[test]
537    fn test_segment_version_without_v() {
538        let err = validate_segment(1, "x.core.events.event.1~", false).unwrap_err();
539        assert!(
540            err.contains("Major version must start with 'v'"),
541            "got: {err}"
542        );
543    }
544
545    #[test]
546    fn test_segment_version_not_integer() {
547        let err = validate_segment(1, "x.core.events.event.vX~", false).unwrap_err();
548        assert!(
549            err.contains("Major version must be an integer"),
550            "got: {err}"
551        );
552    }
553
554    #[test]
555    fn test_segment_version_leading_zeros() {
556        let err = validate_segment(1, "x.core.events.event.v01~", false).unwrap_err();
557        assert!(
558            err.contains("Major version must be an integer"),
559            "got: {err}"
560        );
561    }
562
563    #[test]
564    fn test_segment_invalid_vendor_token() {
565        let err = validate_segment(1, "1bad.core.events.event.v1~", false).unwrap_err();
566        assert!(err.contains("Invalid vendor token"), "got: {err}");
567    }
568
569    // ---- expected_format ----
570
571    #[test]
572    fn test_segment1_format_has_gts_prefix() {
573        let err = validate_segment(1, "x.core.events.event~", false).unwrap_err();
574        assert!(
575            err.contains("gts.vendor.package.namespace.type.vMAJOR"),
576            "segment #1 format should include gts. prefix, got: {err}"
577        );
578    }
579
580    #[test]
581    fn test_segment2_format_no_gts_prefix() {
582        let err = validate_segment(2, "x.core.events.event~", false).unwrap_err();
583        assert!(
584            !err.contains("gts.vendor"),
585            "segment #2 format should NOT include gts. prefix, got: {err}"
586        );
587        assert!(
588            err.contains("vendor.package.namespace.type.vMAJOR"),
589            "segment #2 should show vendor.package format, got: {err}"
590        );
591    }
592
593    // ---- wildcards ----
594
595    #[test]
596    fn test_wildcard_at_vendor() {
597        let parsed = validate_segment(1, "*", true).unwrap();
598        assert!(parsed.is_wildcard);
599    }
600
601    #[test]
602    fn test_wildcard_at_package() {
603        let parsed = validate_segment(1, "x.*", true).unwrap();
604        assert!(parsed.is_wildcard);
605        assert_eq!(parsed.vendor, "x");
606    }
607
608    #[test]
609    fn test_wildcard_invalid_token_before_star() {
610        // Tokens before '*' must still be validated
611        let err = validate_segment(1, "1bad.*", true).unwrap_err();
612        assert!(err.contains("Invalid vendor token"), "got: {err}");
613    }
614
615    #[test]
616    fn test_wildcard_in_middle_rejected() {
617        // '*' in a non-final position must be rejected
618        let err = validate_segment(1, "x.*.ns.type.v1", true).unwrap_err();
619        assert!(
620            err.contains("only allowed as the final token"),
621            "got: {err}"
622        );
623    }
624
625    #[test]
626    fn test_wildcard_at_version_position_not_final() {
627        // '*' at version position (4) with extra token after it must be rejected
628        let err = validate_segment(1, "x.pkg.ns.type.*.extra", true).unwrap_err();
629        assert!(
630            err.contains("only allowed as the final token"),
631            "got: {err}"
632        );
633    }
634
635    #[test]
636    fn test_wildcard_rejected_without_flag() {
637        let err = validate_segment(1, "x.*", false).unwrap_err();
638        assert!(err.contains("Too few tokens"), "got: {err}");
639    }
640
641    // ---- validate_gts_id ----
642
643    #[test]
644    fn test_valid_gts_id() {
645        let segments = validate_gts_id("gts.x.core.events.event.v1~", false).unwrap();
646        assert_eq!(segments.len(), 1);
647        assert_eq!(segments[0].vendor, "x");
648        assert!(segments[0].is_type);
649    }
650
651    #[test]
652    fn test_valid_gts_id_chained() {
653        let segments = validate_gts_id(
654            "gts.x.core.events.type.v1~vendor.app._.custom_event.v1~",
655            false,
656        )
657        .unwrap();
658        assert_eq!(segments.len(), 2);
659        assert_eq!(segments[0].vendor, "x");
660        assert_eq!(segments[1].vendor, "vendor");
661    }
662
663    #[test]
664    fn test_gts_id_missing_prefix() {
665        let err = validate_gts_id("x.core.events.event.v1~", false).unwrap_err();
666        match err {
667            GtsIdError::Id { cause, .. } => {
668                assert!(cause.contains("must start with 'gts.'"), "got: {cause}");
669            }
670            GtsIdError::Segment { .. } => panic!("expected Id error, got: {err}"),
671        }
672    }
673
674    #[test]
675    fn test_gts_id_uppercase() {
676        let err = validate_gts_id("gts.X.core.events.event.v1~", false).unwrap_err();
677        match err {
678            GtsIdError::Id { cause, .. } => {
679                assert!(cause.contains("lowercase"), "got: {cause}");
680            }
681            GtsIdError::Segment { .. } => panic!("expected Id error, got: {err}"),
682        }
683    }
684
685    #[test]
686    fn test_gts_id_hyphen() {
687        let err = validate_gts_id("gts.x-vendor.core.events.event.v1~", false).unwrap_err();
688        match err {
689            GtsIdError::Id { cause, .. } => {
690                assert!(cause.contains("'-'"), "got: {cause}");
691            }
692            GtsIdError::Segment { .. } => panic!("expected Id error, got: {err}"),
693        }
694    }
695
696    #[test]
697    fn test_gts_id_segment_error_carries_num_and_offset() {
698        let err = validate_gts_id(
699            "gts.x.core.modkit.plugin.v1~x.core.license_enforcer.integration.plugin.v1~",
700            false,
701        )
702        .unwrap_err();
703        match err {
704            GtsIdError::Segment {
705                num, offset, cause, ..
706            } => {
707                assert_eq!(num, 2);
708                // offset = "gts.".len() + "x.core.modkit.plugin.v1~".len() = 4 + 24 = 28
709                assert_eq!(offset, 28);
710                assert!(
711                    cause.contains("Too many name tokens before version"),
712                    "got: {cause}"
713                );
714            }
715            GtsIdError::Id { .. } => panic!("expected Segment error, got: {err}"),
716        }
717    }
718
719    #[test]
720    fn test_gts_id_instance_no_tilde_end() {
721        let segments = validate_gts_id("gts.x.core.events.event.v1~a.b.c.d.v1.0", false).unwrap();
722        assert_eq!(segments.len(), 2);
723        assert!(segments[0].is_type);
724        assert!(!segments[1].is_type);
725    }
726
727    #[test]
728    fn test_gts_id_double_tilde_rejected() {
729        let err = validate_gts_id("gts.x.test1.events.type.v1.0~~", false).unwrap_err();
730        match err {
731            GtsIdError::Id { cause, .. } => {
732                assert!(cause.contains("empty segment"), "got: {cause}");
733            }
734            GtsIdError::Segment { .. } => panic!("expected Id error, got: {err}"),
735        }
736    }
737
738    #[test]
739    fn test_gts_id_whitespace_trimmed() {
740        let segments = validate_gts_id("  gts.x.core.events.event.v1~  ", false).unwrap();
741        assert_eq!(segments.len(), 1);
742    }
743
744    // ---- is_uuid ----
745
746    #[test]
747    fn test_is_uuid_valid() {
748        assert!(is_uuid("7a1d2f34-5678-49ab-9012-abcdef123456"));
749        assert!(is_uuid("00000000-0000-0000-0000-000000000000"));
750        assert!(is_uuid("ffffffff-ffff-ffff-ffff-ffffffffffff"));
751    }
752
753    #[test]
754    fn test_is_uuid_invalid() {
755        assert!(!is_uuid("not-a-uuid"));
756        assert!(!is_uuid("7a1d2f34-5678-49ab-9012-abcdef12345")); // too short
757        assert!(!is_uuid("7a1d2f34-5678-49ab-9012-abcdef1234567")); // too long
758        assert!(!is_uuid("7a1d2f34-5678-49ab-9012-abcdef12345g")); // non-hex char
759        assert!(!is_uuid("7a1d2f3405678-49ab-9012-abcdef123456")); // dash in wrong place
760    }
761
762    // ---- combined anonymous instance ----
763
764    #[test]
765    fn test_combined_anonymous_instance_valid() {
766        let segments = validate_gts_id(
767            "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~7a1d2f34-5678-49ab-9012-abcdef123456",
768            false,
769        )
770        .unwrap();
771        assert_eq!(segments.len(), 3);
772        assert!(segments[0].is_type);
773        assert!(segments[1].is_type);
774        assert!(segments[2].is_uuid_tail);
775        assert!(!segments[2].is_type);
776        assert_eq!(segments[2].raw, "7a1d2f34-5678-49ab-9012-abcdef123456");
777    }
778
779    #[test]
780    fn test_combined_anonymous_instance_single_prefix_valid() {
781        let segments = validate_gts_id(
782            "gts.x.core.events.type.v1~7a1d2f34-5678-49ab-9012-abcdef123456",
783            false,
784        )
785        .unwrap();
786        assert_eq!(segments.len(), 2);
787        assert!(segments[0].is_type);
788        assert!(segments[1].is_uuid_tail);
789    }
790
791    #[test]
792    fn test_combined_anonymous_instance_hyphen_in_segments_rejected() {
793        let err = validate_gts_id(
794            "gts.x-vendor.core.events.type.v1~x.commerce.orders.order_placed.v1.0~7a1d2f34-5678-49ab-9012-abcdef123456",
795            false,
796        )
797        .unwrap_err();
798        match err {
799            GtsIdError::Id { cause, .. } => {
800                assert!(cause.contains("'-'"), "got: {cause}");
801            }
802            GtsIdError::Segment { .. } => panic!("expected Id error, got: {err}"),
803        }
804    }
805
806    #[test]
807    fn test_uuid_alone_without_prefix_rejected() {
808        // A bare UUID with no GTS prefix is not a valid GTS ID
809        let err = validate_gts_id("7a1d2f34-5678-49ab-9012-abcdef123456", false).unwrap_err();
810        match err {
811            GtsIdError::Id { cause, .. } => {
812                assert!(cause.contains("must start with 'gts.'"), "got: {cause}");
813            }
814            GtsIdError::Segment { .. } => panic!("expected Id error, got: {err}"),
815        }
816    }
817
818    #[test]
819    fn test_uuid_tail_without_preceding_tilde_rejected() {
820        // UUID as the only segment (no preceding ~) must be rejected
821        // "gts." + UUID has no tilde_parts.len() >= 2
822        let err = validate_gts_id("gts.7a1d2f34-5678-49ab-9012-abcdef123456", false).unwrap_err();
823        match err {
824            GtsIdError::Id { cause, .. } => {
825                assert!(cause.contains("'-'"), "got: {cause}");
826            }
827            GtsIdError::Segment { .. } => panic!("expected Id error, got: {err}"),
828        }
829    }
830}