1use thiserror::Error;
7
8pub const GTS_PREFIX: &str = "gts.";
10
11pub const GTS_MAX_LENGTH: usize = 1024;
13
14#[derive(Debug, Error)]
16pub enum GtsIdError {
17 #[error("Segment #{num}: {cause}")]
19 Segment {
20 num: usize,
22 offset: usize,
24 segment: String,
26 cause: String,
28 },
29
30 #[error("Invalid GTS ID: {cause}")]
32 Id {
33 id: String,
35 cause: String,
37 },
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42#[allow(clippy::struct_excessive_bools)]
43pub struct ParsedSegment {
44 pub raw: String,
46 pub offset: usize,
48 pub vendor: String,
50 pub package: String,
52 pub namespace: String,
54 pub type_name: String,
56 pub ver_major: u32,
58 pub ver_minor: Option<u32>,
60 pub is_type: bool,
62 pub is_wildcard: bool,
64 pub is_uuid_tail: bool,
66}
67
68#[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#[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#[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#[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
123pub 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 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 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 for (i, token) in tokens.iter().take(4).enumerate() {
192 if allow_wildcards && *token == "*" {
193 if i == tokens.len() - 1 {
194 break; }
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 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
295pub 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 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 let segments_portion = match uuid_tail {
349 Some(uuid) => &raw[..raw.len() - uuid.len() - 1], 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 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 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 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 #[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 #[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 #[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 #[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 #[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 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 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 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 #[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 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 #[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")); assert!(!is_uuid("7a1d2f34-5678-49ab-9012-abcdef1234567")); assert!(!is_uuid("7a1d2f34-5678-49ab-9012-abcdef12345g")); assert!(!is_uuid("7a1d2f3405678-49ab-9012-abcdef123456")); }
761
762 #[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 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 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}