diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 45a0b52..4dab0af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ -# CLAUDE.md +# AI Agent Guidance -Guidance for Claude Code when working in this repository. +Guidance for AI Agents when working in this repository. ## Project Overview diff --git a/Cargo.lock b/Cargo.lock index b1e7841..1a8c0c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -540,7 +540,7 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "gts" -version = "0.10.1" +version = "0.11.0" dependencies = [ "gts-id", "jsonschema", @@ -552,13 +552,12 @@ dependencies = [ "tempfile", "thiserror", "tracing", - "uuid", "walkdir", ] [[package]] name = "gts-cli" -version = "0.10.1" +version = "0.11.0" dependencies = [ "anyhow", "axum", @@ -578,14 +577,15 @@ dependencies = [ [[package]] name = "gts-id" -version = "0.10.1" +version = "0.11.0" dependencies = [ "thiserror", + "uuid", ] [[package]] name = "gts-macros" -version = "0.10.1" +version = "0.11.0" dependencies = [ "gts", "gts-id", @@ -602,7 +602,7 @@ dependencies = [ [[package]] name = "gts-macros-cli" -version = "0.10.1" +version = "0.11.0" dependencies = [ "anyhow", "clap", @@ -617,7 +617,7 @@ dependencies = [ [[package]] name = "gts-validator" -version = "0.10.1" +version = "0.11.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index e8465df..351a404 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -147,12 +147,12 @@ verbose_file_reads = "deny" module_name_repetitions = "allow" [workspace.dependencies] -gts = { version = "0.10.1", path = "gts" } -gts-cli = { version = "0.10.1", path = "gts-cli" } -gts-id = { version = "0.10.1", path = "gts-id" } -gts-macros = { version = "0.10.1", path = "gts-macros" } -gts-macros-cli = { version = "0.10.1", path = "gts-macros-cli" } -gts-validator = { version = "0.10.1", path = "gts-validator" } +gts = { version = "0.11.0", path = "gts" } +gts-cli = { version = "0.11.0", path = "gts-cli" } +gts-id = { version = "0.11.0", path = "gts-id" } +gts-macros = { version = "0.11.0", path = "gts-macros" } +gts-macros-cli = { version = "0.11.0", path = "gts-macros-cli" } +gts-validator = { version = "0.11.0", path = "gts-validator" } # Core dependencies serde = { version = "1.0", features = ["derive"] } diff --git a/README.md b/README.md index d1f1731..845d4b0 100644 --- a/README.md +++ b/README.md @@ -54,13 +54,25 @@ Technical Backlog: ## Architecture -The project is organized as a Cargo workspace with two crates: +The project is organized as a Cargo workspace. The three primary crates are: + +### `gts-id` (Library Crate) + +Standalone GTS identifier crate (no dependency on `gts`) — GTS ID parsing, +validation, and wildcard matching: + +- **gts_id.rs** - `GtsId` parsing and validation +- **gts_id_segment.rs** - parsed identifier segments +- **gts_id_pattern.rs** - `GtsIdPattern` pattern matching +- **parse.rs** - the shared identifier parser + +See [`gts-id/README.md`](gts-id/README.md) for usage examples. ### `gts` (Library Crate) -Core library providing all GTS functionality: +Core library providing all GTS functionality (re-exports the `gts-id` types): -- **gts.rs** - GTS ID parsing, validation, wildcard matching +- **gts.rs** - typed `GtsTypeId` / `GtsInstanceId` schema-aware wrappers and `gts-id` re-exports - **entities.rs** - JSON entities, configuration, validation - **path_resolver.rs** - JSON path resolution - **schema_cast.rs** - Schema compatibility and casting @@ -489,7 +501,7 @@ All operations are available through the `GtsOps` API. #### Setup ```rust -use gts::{GtsID, GtsOps, GtsConfig, GtsWildcard}; +use gts::{GtsId, GtsOps, GtsConfig, GtsIdPattern}; use serde_json::json; // Initialize GTS operations with data paths @@ -513,7 +525,7 @@ assert!(!result.valid); assert!(!result.error.is_empty()); // Direct validation without ops -let is_valid = GtsID::is_valid("gts.x.core.events.event.v1~"); +let is_valid = GtsId::is_valid("gts.x.core.events.event.v1~"); assert!(is_valid); ``` @@ -565,8 +577,8 @@ let result = ops.parse_id("gts.x.core.events.event.v1~vendor.app._.custom.v2~"); assert_eq!(result.segments.len(), 2); // Direct parsing -let id = GtsID::new("gts.x.core.events.event.v1~")?; -assert_eq!(id.gts_id_segments.len(), 1); +let id = GtsId::try_new("gts.x.core.events.event.v1~")?; +assert_eq!(id.segments().len(), 1); ``` #### OP#4 - ID Pattern Matching @@ -587,9 +599,9 @@ let result = ops.match_id_pattern( assert!(!result.is_match); // Direct wildcard matching -let pattern = GtsWildcard::new("gts.x.*.events.*")?; -let id = GtsID::new("gts.x.core.events.event.v1~")?; -assert!(pattern.matches(&id)); +let pattern = GtsIdPattern::try_new("gts.x.core.events.*")?; +let id = GtsId::try_new("gts.x.core.events.event.v1~")?; +assert!(id.matches_pattern(&pattern)); ``` #### OP#5 - ID to UUID Mapping @@ -603,13 +615,13 @@ assert!(!result.uuid.is_empty()); let result = ops.uuid("gts.x.core.events.event.v1.0", "minor"); // Direct UUID generation -let id = GtsID::new("gts.x.core.events.event.v1~")?; +let id = GtsId::try_new("gts.x.core.events.event.v1~")?; let uuid = id.to_uuid(); println!("UUID: {}", uuid); // Same major version produces same UUID -let id1 = GtsID::new("gts.x.core.events.event.v1.0")?; -let id2 = GtsID::new("gts.x.core.events.event.v1.5")?; +let id1 = GtsId::try_new("gts.x.core.events.event.v1.0")?; +let id2 = GtsId::try_new("gts.x.core.events.event.v1.5")?; assert_eq!(id1.to_uuid(), id2.to_uuid()); ``` diff --git a/gts-cli/Cargo.toml b/gts-cli/Cargo.toml index a2b0ab9..fb3e822 100644 --- a/gts-cli/Cargo.toml +++ b/gts-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gts-cli" -version = "0.10.1" +version = "0.11.0" edition.workspace = true authors.workspace = true license.workspace = true diff --git a/gts-id/Cargo.toml b/gts-id/Cargo.toml index f85da85..8a77299 100644 --- a/gts-id/Cargo.toml +++ b/gts-id/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gts-id" -version = "0.10.1" +version = "0.11.0" edition.workspace = true authors.workspace = true license.workspace = true @@ -15,5 +15,12 @@ publish = true [lints] workspace = true +[features] +# Opt-in deterministic UUID v5 derivation (`GtsID::to_uuid`). Off by default so +# parsing-only consumers — notably the `gts-macros` proc-macro crate — don't pull +# `uuid` into their build graph or couple their semver to it. +uuid = ["dep:uuid"] + [dependencies] thiserror.workspace = true +uuid = { workspace = true, optional = true } diff --git a/gts-id/README.md b/gts-id/README.md new file mode 100644 index 0000000..ae4b8ae --- /dev/null +++ b/gts-id/README.md @@ -0,0 +1,160 @@ +# gts-id + +Validation and parsing primitives for [GTS](https://github.com/GlobalTypeSystem/gts-spec) (Global Type System) identifiers. + +This crate is the single source of truth for GTS identifier parsing in [`gts-rust`](https://github.com/GlobalTypeSystem/gts-rust): it is shared by the `gts` runtime library and the `gts-macros` proc-macro crate. It has no runtime dependencies beyond `thiserror` (and optionally `uuid`). + +## Identifier shape + +A GTS identifier is a `~`-chained sequence of segments under the `gts.` prefix: + +```text +gts.....v[.] +``` + +* A **type** identifier ends with a `~` marker: `gts.x.core.events.topic.v1~` +* An **instance** identifier does not: `gts.x.core.events.topic.v1~acme.shop.orders.order.v1.0` +* A **combined anonymous instance** ends with a UUID tail: `gts.x.core.events.topic.v1~7a1d2f34-5678-49ab-9012-abcdef123456` + +## Types + +| Type | Purpose | +|------|---------| +| `GtsId` | A validated, concrete identifier. | +| `GtsIdPattern` | A match pattern — an identifier that may end in a single trailing `*`, or be fully concrete. | +| `GtsIdSegment` | One concrete segment of a `GtsId` (`Concrete` or `UuidTail`). | +| `GtsIdPatternSegment` | One segment of a pattern (`Segment` or `Wildcard`). | +| `GtsUuidTail` | An anonymous-instance UUID tail (guaranteed well-formed). | +| `GtsIdError` | The error returned by all parsing entry points. | + +Every value is produced only by the validating constructors, so a parsed value is always well-formed and its invariants cannot be forged. + +## Examples + +The snippets below use `?` for brevity; assume they run inside a function that returns `Result<_, gts_id::GtsIdError>`. + +### Validate and parse a concrete identifier + +```rust +use gts_id::GtsId; + +// Fallible constructor. +let id = GtsId::try_new("gts.x.core.events.topic.v1~")?; +assert_eq!(id.id(), "gts.x.core.events.topic.v1~"); +assert!(id.is_type()); // ends with '~' + +// `FromStr` is also available. +let id: GtsId = "gts.x.core.events.topic.v1~".parse()?; + +// Cheap validity check that doesn't keep the parsed value. +assert!(GtsId::is_valid("gts.x.core.events.topic.v1~")); +assert!(!GtsId::is_valid("gts.x.Core.events.topic.v1~")); // uppercase rejected +``` + +### Inspect segments + +```rust +use gts_id::GtsId; + +let id = GtsId::try_new("gts.x.core.events.topic.v1.2~")?; +let seg = &id.segments()[0]; +assert_eq!(seg.vendor(), "x"); +assert_eq!(seg.package(), "core"); +assert_eq!(seg.namespace(), "events"); +assert_eq!(seg.type_name(), "topic"); +assert_eq!(seg.ver_major(), 1); +assert_eq!(seg.ver_minor(), Some(2)); +assert!(seg.is_type()); +``` + +### Type vs. instance, and the parent type of a chain + +```rust +use gts_id::GtsId; + +let instance = GtsId::try_new("gts.x.core.events.topic.v1~acme.shop.orders.order.v1.0")?; +assert!(!instance.is_type()); + +// The parent type id (every segment but the last). +assert_eq!( + instance.get_type_id().as_deref(), + Some("gts.x.core.events.topic.v1~"), +); +``` + +### Wildcard patterns and matching + +A `GtsIdPattern` may contain a single trailing `*` (e.g. `gts.x.core.*` or `gts.x.core.events.topic.v1~*`), or be fully concrete. Concrete identifiers are validated with `GtsId`, which rejects wildcards. + +```rust +use gts_id::{GtsId, GtsIdPattern}; + +let pattern = GtsIdPattern::try_new("gts.x.core.*")?; + +let id = GtsId::try_new("gts.x.core.events.topic.v1~")?; +assert!(id.matches_pattern(&pattern)); + +let other = GtsId::try_new("gts.y.core.events.topic.v1~")?; +assert!(!other.matches_pattern(&pattern)); + +// A concrete `GtsId` never accepts a wildcard: +assert!(GtsId::try_new("gts.x.core.*").is_err()); +``` + +### Pattern coverage + +`covers` answers whether one pattern is broader than another — every identifier matched by `other` is also matched by `self`: + +```rust +use gts_id::GtsIdPattern; + +let broad = GtsIdPattern::try_new("gts.x.core.events.topic.v1~*")?; +let narrow = GtsIdPattern::try_new("gts.x.core.events.topic.v1~acme.*")?; +assert!(broad.covers(&narrow)); +assert!(!narrow.covers(&broad)); +``` + +### Anonymous instances (UUID tail) + +```rust +use gts_id::{GtsId, GtsIdSegment}; + +let id = GtsId::try_new("gts.x.core.events.topic.v1~7a1d2f34-5678-49ab-9012-abcdef123456")?; + +// The last segment is a UUID tail; match on it to read the UUID. +if let Some(GtsIdSegment::UuidTail(tail)) = id.segments().last() { + assert_eq!(tail.as_str(), "7a1d2f34-5678-49ab-9012-abcdef123456"); +} +``` + +### Error handling + +```rust +use gts_id::GtsId; + +let err = GtsId::try_new("gts.x.core.events.topic").unwrap_err(); +// `GtsIdError` implements `Display` with a human-readable cause. +println!("{err}"); +``` + +## Feature flags + +* **`uuid`** — enables `GtsId::to_uuid()`, a deterministic UUID v5 derivation (the same identifier always maps to the same UUID). Off by default so parsing-only consumers don't pull in the `uuid` crate. + +```toml +[dependencies] +gts-id = { version = "0.11", features = ["uuid"] } +``` + +```rust +// with the `uuid` feature enabled: +use gts_id::GtsId; + +let id = GtsId::try_new("gts.x.core.events.topic.v1~")?; +let uuid = id.to_uuid(); +assert_eq!(id.to_uuid(), uuid); // deterministic +``` + +## License + +Apache-2.0 diff --git a/gts-id/src/error.rs b/gts-id/src/error.rs new file mode 100644 index 0000000..879b4d3 --- /dev/null +++ b/gts-id/src/error.rs @@ -0,0 +1,71 @@ +//! The error type shared across all GTS identifier and wildcard parsing. + +use std::fmt; + +/// Pinpoints the `~`-delimited segment at fault within a GTS identifier. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GtsIdSegmentError { + /// 1-based segment number. + pub num: usize, + /// Byte offset of this segment within the full ID string. + pub offset: usize, + /// The raw segment string that failed parsing. + pub segment: String, +} + +/// Error from GTS identifier / wildcard parsing. +/// +/// There is a single failure category — "this GTS string is invalid" — +/// described by [`cause`](Self::cause). [`segment`](Self::segment) is present +/// when the failure could be pinned to a specific `~`-delimited segment; +/// otherwise it is an identifier-level problem (prefix, case, length, wildcard +/// placement, the single-segment-instance rule, …). +/// +/// The `gts` crate re-exports this type under its own name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GtsIdError { + /// The full GTS string (identifier or wildcard pattern) that failed. + pub input: String, + /// Human-readable description of the problem. + pub cause: String, + /// Set when a specific segment is at fault. + pub segment: Option, +} + +impl GtsIdError { + /// Build an identifier-level error (no specific segment located). + #[must_use] + pub fn new(input: impl Into, cause: impl Into) -> Self { + Self { + input: input.into(), + cause: cause.into(), + segment: None, + } + } + + /// Attach the location of the offending `~`-segment. + #[must_use] + pub fn with_segment(mut self, num: usize, offset: usize, segment: impl Into) -> Self { + self.segment = Some(GtsIdSegmentError { + num, + offset, + segment: segment.into(), + }); + self + } +} + +impl fmt::Display for GtsIdError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.segment { + Some(s) => write!( + f, + "Invalid GTS segment #{} @ offset {}: '{}': {}", + s.num, s.offset, s.segment, self.cause + ), + None => write!(f, "Invalid GTS identifier: {}: {}", self.input, self.cause), + } + } +} + +impl std::error::Error for GtsIdError {} diff --git a/gts-id/src/gts_id.rs b/gts-id/src/gts_id.rs new file mode 100644 index 0000000..fcc1b3f --- /dev/null +++ b/gts-id/src/gts_id.rs @@ -0,0 +1,548 @@ +//! The validated GTS identifier type. +//! +//! [`GtsId`] parses and validates a full GTS identifier string into its +//! constituent [`GtsIdSegment`]s. It also exposes the matching logic used to +//! test an ID against a [`GtsIdPattern`] pattern. + +use std::fmt; +use std::str::FromStr; + +use crate::parse::parse_id; +use crate::{GTS_PREFIX, GtsIdError, GtsIdPattern, GtsIdSegment}; + +/// GTS ID - a validated Global Type System identifier. +/// +/// GTS IDs follow the format: `gts.....[~]` +/// where `~` suffix indicates a type definition. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct GtsId { + id: String, + segments: Vec, +} + +impl GtsId { + /// Parse and validate a concrete GTS identifier string. + /// + /// Wildcards are **not** accepted here — a `GtsId` is always a concrete + /// identifier. Parse wildcard patterns with [`GtsIdPattern::try_new`] instead. + /// + /// # Errors + /// Returns `GtsIdError` if the string is not a valid concrete GTS identifier. + pub fn try_new(id: &str) -> Result { + let raw = id.trim(); + + // Delegate to the concrete parser (single source of truth). A `GtsId` + // is always concrete, so its segments are `GtsIdSegment` (never wildcard). + let segments = parse_id(raw)?; + + Ok(GtsId { + id: raw.to_owned(), + segments, + }) + } + + /// The validated identifier string. + #[must_use] + pub fn id(&self) -> &str { + &self.id + } + + /// The parsed segments of this identifier. + #[must_use] + pub fn segments(&self) -> &[GtsIdSegment] { + &self.segments + } + + /// Consumes the identifier, returning its parsed segments. + #[must_use] + pub fn into_segments(self) -> Vec { + self.segments + } + + #[must_use] + pub fn is_type(&self) -> bool { + self.id.ends_with('~') + } + + #[must_use] + pub fn get_type_id(&self) -> Option { + if self.segments.len() < 2 { + return None; + } + let segments: String = self.segments[..self.segments.len() - 1] + .iter() + .map(GtsIdSegment::raw) + .collect::>() + .join(""); + Some(format!("{GTS_PREFIX}{segments}")) + } + + /// Generate a deterministic UUID v5 from this GTS ID. + /// + /// The UUID is derived from the validated identifier string under a fixed + /// GTS namespace, so it is stable across processes and runs: the same ID + /// always maps to the same UUID. + /// + /// Requires the `uuid` feature. + #[cfg(feature = "uuid")] + #[must_use] + pub fn to_uuid(&self) -> uuid::Uuid { + use std::sync::LazyLock; + + /// UUID v5 namespace for deterministic GTS identifier UUIDs. + static GTS_NS: LazyLock = + LazyLock::new(|| uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, b"gts")); + + uuid::Uuid::new_v5(>S_NS, self.id.as_bytes()) + } + + /// Check if a string is a valid GTS identifier. + #[must_use] + pub fn is_valid(s: &str) -> bool { + Self::try_new(s).is_ok() + } + + /// Check if this GTS ID matches a wildcard pattern. + #[must_use] + pub fn matches_pattern(&self, pattern: &GtsIdPattern) -> bool { + pattern.matches_views(&self.segments) + } + + /// Converts this concrete identifier into an equivalent zero-wildcard + /// [`GtsIdPattern`]. + /// + /// The conversion reuses the already validated segments, so it never + /// re-parses and cannot fail. This is the ergonomic borrowing form of + /// [`From<&GtsId>`](GtsIdPattern); the consuming form is + /// `GtsIdPattern::from(id)`. + /// + /// The resulting pattern matches this id *and* everything derived from it + /// down the chain: a base type id `gts.a.b.c.d.v1~` behaves as the implicit + /// envelope `gts.a.b.c.d.v1~*` (GTS spec §3.6, "implicit derived-type + /// coverage"), with the usual minor-version flexibility. + #[must_use] + pub fn to_pattern(&self) -> GtsIdPattern { + self.into() + } + + /// Splits a GTS ID with an optional attribute path. + /// + /// # Errors + /// Returns `GtsIdError` if the path is empty after the `@` separator. + pub fn split_at_path(gts_with_path: &str) -> Result<(String, Option), GtsIdError> { + if !gts_with_path.contains('@') { + return Ok((gts_with_path.to_owned(), None)); + } + + let parts: Vec<&str> = gts_with_path.splitn(2, '@').collect(); + let gts = parts[0].to_owned(); + let path = parts.get(1).map(|s| (*s).to_owned()); + + if let Some(ref p) = path + && p.is_empty() + { + return Err(GtsIdError::new( + gts_with_path, + "Attribute path cannot be empty", + )); + } + + Ok((gts, path)) + } +} + +impl fmt::Display for GtsId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.id) + } +} + +impl FromStr for GtsId { + type Err = GtsIdError; + + fn from_str(s: &str) -> Result { + Self::try_new(s) + } +} + +impl AsRef for GtsId { + fn as_ref(&self) -> &str { + &self.id + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + #[test] + fn test_gts_id_valid() { + let id = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + assert_eq!(id.id, "gts.x.core.events.event.v1~"); + assert!(id.is_type()); + assert_eq!(id.segments.len(), 1); + } + + #[test] + fn test_gts_id_with_minor_version() { + let id = GtsId::try_new("gts.x.core.events.event.v1.2~").expect("test"); + assert_eq!(id.id, "gts.x.core.events.event.v1.2~"); + assert!(id.is_type()); + let seg = &id.segments[0]; + assert_eq!(seg.vendor(), "x"); + assert_eq!(seg.package(), "core"); + assert_eq!(seg.namespace(), "events"); + assert_eq!(seg.type_name(), "event"); + assert_eq!(seg.ver_major(), 1); + assert_eq!(seg.ver_minor(), Some(2)); + } + + #[test] + fn test_gts_id_instance() { + let id = GtsId::try_new("gts.x.core.events.event.v1~a.b.c.d.v1.0").expect("test"); + assert_eq!(id.id, "gts.x.core.events.event.v1~a.b.c.d.v1.0"); + assert!(!id.is_type()); + } + + #[test] + fn test_gts_id_invalid_uppercase() { + let result = GtsId::try_new("gts.X.core.events.event.v1~"); + assert!(result.is_err()); + } + + #[test] + fn test_gts_id_invalid_no_prefix() { + let result = GtsId::try_new("x.core.events.event.v1~"); + assert!(result.is_err()); + } + + #[test] + fn test_gts_id_invalid_hyphen() { + let result = GtsId::try_new("gts.x-vendor.core.events.event.v1~"); + assert!(result.is_err()); + } + + #[test] + fn test_get_type_id() { + // get_type_id is for chained IDs - returns None for single segment + let id = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + let type_id = id.get_type_id(); + assert!(type_id.is_none()); + + // For chained IDs, it returns the base type + let chained = + GtsId::try_new("gts.x.core.events.type.v1~vendor.app._.custom.v1~").expect("test"); + let base_type = chained.get_type_id(); + assert!(base_type.is_some()); + assert_eq!(base_type.expect("test"), "gts.x.core.events.type.v1~"); + } + + #[test] + fn test_split_at_path() { + let (gts, path) = + GtsId::split_at_path("gts.x.core.events.event.v1~@field.subfield").expect("test"); + assert_eq!(gts, "gts.x.core.events.event.v1~"); + assert_eq!(path, Some("field.subfield".to_owned())); + } + + #[test] + fn test_split_at_path_no_path() { + let (gts, path) = GtsId::split_at_path("gts.x.core.events.event.v1~").expect("test"); + assert_eq!(gts, "gts.x.core.events.event.v1~"); + assert_eq!(path, None); + } + + #[test] + fn test_split_at_path_empty_path_error() { + let result = GtsId::split_at_path("gts.x.core.events.event.v1~@"); + assert!(result.is_err()); + } + + #[test] + fn test_is_valid() { + assert!(GtsId::is_valid("gts.x.core.events.event.v1~")); + assert!(!GtsId::is_valid("invalid")); + assert!(!GtsId::is_valid("gts.X.core.events.event.v1~")); + } + + #[test] + fn test_chained_identifiers() { + let id = GtsId::try_new("gts.x.core.events.type.v1~vendor.app._.custom_event.v1~") + .expect("test"); + assert_eq!(id.segments.len(), 2); + assert_eq!(id.segments[0].vendor(), "x"); + assert_eq!(id.segments[1].vendor(), "vendor"); + } + + #[test] + fn test_gts_id_with_underscore() { + // Underscores are allowed in namespace + let id = GtsId::try_new("gts.x.core._.event.v1~").expect("test"); + assert_eq!(id.segments[0].namespace(), "_"); + } + + #[test] + fn test_gts_id_invalid_version_format() { + let result = GtsId::try_new("gts.x.core.events.event.vX~"); + assert!(result.is_err()); + } + + #[test] + fn test_gts_id_missing_segments() { + let result = GtsId::try_new("gts.x.core~"); + assert!(result.is_err()); + } + + #[test] + fn test_gts_id_empty_segment() { + let result = GtsId::try_new("gts.x..events.event.v1~"); + assert!(result.is_err()); + } + + #[test] + fn test_split_at_path_multiple_at_signs() { + // Should only split at first @ + let (gts, path) = + GtsId::split_at_path("gts.x.core.events.event.v1~@field@subfield").expect("test"); + assert_eq!(gts, "gts.x.core.events.event.v1~"); + assert_eq!(path, Some("field@subfield".to_owned())); + } + + #[test] + fn test_gts_id_whitespace_trimming() { + let id = GtsId::try_new(" gts.x.core.events.event.v1~ ").expect("test"); + assert_eq!(id.id, "gts.x.core.events.event.v1~"); + } + + #[test] + fn test_gts_id_long_chain() { + let id = GtsId::try_new("gts.a.b.c.d.v1~e.f.g.h.v2~i.j.k.l.v3~").expect("test"); + assert_eq!(id.segments.len(), 3); + } + + #[test] + fn test_gts_id_version_without_minor() { + let id = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + assert_eq!(id.segments[0].ver_major(), 1); + assert_eq!(id.segments[0].ver_minor(), None); + } + + #[test] + fn test_gts_id_version_with_large_numbers() { + let id = GtsId::try_new("gts.x.core.events.event.v99.999~").expect("test"); + assert_eq!(id.segments[0].ver_major(), 99); + assert_eq!(id.segments[0].ver_minor(), Some(999)); + } + + #[test] + fn test_gts_id_invalid_double_tilde() { + let result = GtsId::try_new("gts.x.core.events.event.v1~~"); + assert!(result.is_err()); + } + + #[test] + fn test_split_at_path_with_hash() { + // Hash is not a separator, should be part of the ID + let (gts, path) = GtsId::split_at_path("gts.x.core.events.event.v1~#field").expect("test"); + assert_eq!(gts, "gts.x.core.events.event.v1~#field"); + assert_eq!(path, None); + } + + #[test] + fn test_gts_id_display_trait() { + let id = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + assert_eq!(format!("{id}"), "gts.x.core.events.event.v1~"); + } + + #[test] + fn test_gts_id_from_str_trait() { + let id: GtsId = "gts.x.core.events.event.v1~".parse().expect("test"); + assert_eq!(id.id, "gts.x.core.events.event.v1~"); + } + + #[test] + fn test_gts_id_as_ref_trait() { + let id = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + let s: &str = id.as_ref(); + assert_eq!(s, "gts.x.core.events.event.v1~"); + } + + #[test] + fn test_gts_id_new_with_uri_prefix() { + // Should reject gts:// prefix + assert!(GtsId::try_new("gts://x.core.v1~").is_err()); + } + + #[test] + fn test_gts_id_minimum_segments() { + // Too few segments + assert!(GtsId::try_new("gts~").is_err()); + assert!(GtsId::try_new("gts.x~").is_err()); + assert!(GtsId::try_new("gts.x.pkg~").is_err()); + assert!(GtsId::try_new("gts.x.pkg.ns~").is_err()); + + // Minimum valid (vendor.package.namespace.type.version) + assert!(GtsId::try_new("gts.x.pkg.ns.type.v1~").is_ok()); + } + + #[test] + fn test_gts_id_invalid_characters() { + // Full IDs with only the target token malformed, so the assertions + // exercise per-token character validation rather than failing earlier + // on the segment-count check. + assert!(GtsId::try_new("gts.x.core.events.event!.v1~").is_err()); + assert!(GtsId::try_new("gts.x.core.events.ev$ent.v1~").is_err()); + assert!(GtsId::try_new("gts.x.core.events.ev ent.v1~").is_err()); + } + + #[test] + fn test_gts_id_uppercase_rejected() { + assert!(GtsId::try_new("gts.x.core.events.Test.v1~").is_err()); + assert!(GtsId::try_new("gts.X.core.events.test.v1~").is_err()); + } + + #[test] + fn test_gts_id_hyphen_rejected() { + assert!(GtsId::try_new("gts.x.core.events.test-name.v1~").is_err()); + } + + #[test] + fn test_gts_id_digit_start_segment() { + // A token starting with a digit is invalid; use a full ID so the + // start-character rule is reached rather than the segment-count check. + assert!(GtsId::try_new("gts.x.core.events.9test.v1~").is_err()); + } + + #[test] + fn test_gts_id_with_numbers_midword() { + // Numbers in middle of segment are OK + assert!(GtsId::try_new("gts.x.test2name.ns.type.v1~").is_ok()); + assert!(GtsId::try_new("gts.x.pkg.item3.type.v1~").is_ok()); + } + + #[test] + fn test_split_at_path_valid_json_pointer() { + let (gts, path) = GtsId::split_at_path("gts.x.test.v1~@/properties/field").expect("test"); + assert_eq!(gts, "gts.x.test.v1~"); + assert_eq!(path, Some("/properties/field".to_owned())); + } + + #[test] + fn test_gts_id_segment_start_underscore() { + // A leading underscore is a *valid* token start ([a-z_]), so a full ID + // whose type token begins with '_' parses successfully. (The previous + // input "gts.x._private.event.v1~" only "passed" by failing the + // segment-count check, masking this allowed-by-design behavior.) + assert!(GtsId::try_new("gts.x.core.events._private.v1~").is_ok()); + } + + #[test] + fn test_gts_id_multi_digit_versions() { + // Multi-digit version numbers + assert!(GtsId::try_new("gts.x.pkg.ns.event.v10~").is_ok()); + assert!(GtsId::try_new("gts.x.pkg.ns.event.v1.20~").is_ok()); + } + + #[test] + fn test_gts_id_rejects_wildcards() { + // A concrete `GtsId` never accepts wildcard patterns — those parse only + // through `GtsIdPattern`. This is a deliberate tightening over the old + // `gts::GtsID`, which delegated to `validate_gts_id(.., true)` and so + // treated wildcard patterns as valid. + assert!(GtsId::try_new("gts.x.core.*").is_err()); + assert!(GtsId::try_new("gts.x.core.events.topic.v1~*").is_err()); + assert!(!GtsId::is_valid("gts.x.core.*")); + assert!(!GtsId::is_valid("gts.x.core.events.topic.v1~*")); + + // The same strings are valid as wildcard patterns. + assert!(GtsIdPattern::try_new("gts.x.core.*").is_ok()); + assert!(GtsIdPattern::try_new("gts.x.core.events.topic.v1~*").is_ok()); + } + + #[test] + fn test_wildcard_must_be_terminal_and_not_a_type() { + // `*` is only ever the last token of a pattern, and a wildcard is never + // a type segment: `*~` is rejected (it neither ends in `.*` nor `~*`). + for pattern in [ + "gts.x.core.*~", + "gts.x.core.events.topic.v1.*~", + "gts.x.*.events.topic.v1~", // `*` not terminal + ] { + assert!( + GtsIdPattern::try_new(pattern).is_err(), + "pattern must be rejected: {pattern}" + ); + } + } + + #[test] + fn test_get_type_id_multi_segment_chain() { + // Regression: reconstructing the parent type id must join the raw + // segments (which already carry their trailing `~`) directly, never + // re-inserting `~` between them. A three-segment chain has two parent + // segments, which is exactly where a `join("~")` would produce `~~`. + let id = GtsId::try_new( + "gts.x.core.events.topic.v1~vendor.app.orders.thing.v1~acme.shop.checkout.item.v1.0", + ) + .expect("valid three-segment chain"); + + let parent = id.get_type_id().expect("chain has a parent type id"); + assert_eq!( + parent, + "gts.x.core.events.topic.v1~vendor.app.orders.thing.v1~" + ); + assert!(!parent.contains("~~"), "parent id must not contain '~~'"); + + // A two-segment chain has a single parent segment. + let id = GtsId::try_new("gts.x.core.events.topic.v1~vendor.app.orders.thing.v1.0") + .expect("valid two-segment chain"); + assert_eq!( + id.get_type_id().expect("parent"), + "gts.x.core.events.topic.v1~" + ); + + // A single segment has no parent. + let id = GtsId::try_new("gts.x.core.events.topic.v1~").expect("single type segment"); + assert_eq!(id.get_type_id(), None); + } + + #[test] + fn test_to_pattern_roundtrip() { + let id = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + let pattern = id.to_pattern(); + assert_eq!(pattern.pattern(), id.id()); + // The id at minimum matches the pattern derived from itself (it also + // covers derived chains — see gts_id_pattern's coverage test). + assert!(id.matches_pattern(&pattern)); + } + + #[test] + fn test_to_pattern_instance_id() { + // Works for a chained instance id too — every segment is carried over. + let id = GtsId::try_new("gts.x.core.events.event.v1~a.b.c.d.v1.0").expect("test"); + let pattern = id.to_pattern(); + assert_eq!(pattern.pattern(), id.id()); + assert_eq!(pattern.segments().len(), id.segments().len()); + assert!(id.matches_pattern(&pattern)); + } + + #[cfg(feature = "uuid")] + #[test] + fn test_uuid_generation() { + let id = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + let uuid1 = id.to_uuid(); + let uuid2 = id.to_uuid(); + // UUIDs should be deterministic + assert_eq!(uuid1, uuid2); + assert_eq!(uuid1.to_string(), "154302ad-df5c-56e6-97d4-f87c5faca44b"); + } + + #[cfg(feature = "uuid")] + #[test] + fn test_uuid_different_ids() { + let id1 = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + let id2 = GtsId::try_new("gts.x.core.events.event.v2~").expect("test"); + assert_ne!(id1.to_uuid(), id2.to_uuid()); + } +} diff --git a/gts-id/src/gts_id_pattern.rs b/gts-id/src/gts_id_pattern.rs new file mode 100644 index 0000000..54bf3d7 --- /dev/null +++ b/gts-id/src/gts_id_pattern.rs @@ -0,0 +1,680 @@ +//! GTS identifier match patterns. +//! +//! [`GtsIdPattern`] is a validated GTS identifier that may end in a single +//! trailing `*` wildcard token, but may also be fully concrete. A zero-wildcard +//! pattern is not exact-match: a base type id used as a pattern also matches +//! everything derived from it down the chain — `gts.a.b.c.d.v1~` behaves as the +//! implicit envelope `gts.a.b.c.d.v1~*` (GTS spec §3.6, "implicit derived-type +//! coverage"), with minor-version flexibility. It supports segment-wise coverage +//! reasoning via [`covers`](GtsIdPattern::covers); actual ID-vs-pattern matching +//! lives on [`GtsId::matches_pattern`]. + +use std::fmt; +use std::str::FromStr; + +use crate::gts_id_segment::SegmentView; +use crate::{GtsId, GtsIdError, GtsIdPatternSegment}; + +/// A GTS identifier match pattern (exact or trailing-`*` wildcard). +#[derive(Debug, Clone, PartialEq)] +pub struct GtsIdPattern { + pattern: String, + segments: Vec, +} + +impl GtsIdPattern { + /// Creates a new GTS identifier match pattern. + /// + /// The pattern may be fully concrete or end in a single trailing `*` + /// wildcard. All validation is delegated to the parser + /// ([`crate::parse::parse_pattern`]), so a pattern is + /// parsed identically to the same string passed through any other entry + /// point. + /// + /// # Errors + /// Returns `GtsIdError` if the string is not a valid GTS identifier pattern. + pub fn try_new(pattern: &str) -> Result { + let p = pattern.trim(); + + // Parse with the pattern parser (not GtsId::try_new, which rejects + // wildcards) since a pattern is an identifier-shaped string that may + // legitimately contain a trailing '*'. + let segments = crate::parse::parse_pattern(p)?; + + Ok(GtsIdPattern { + pattern: p.to_owned(), + segments, + }) + } + + /// The validated pattern string. + #[must_use] + pub fn pattern(&self) -> &str { + &self.pattern + } + + /// The parsed segments of this pattern. + #[must_use] + pub fn segments(&self) -> &[GtsIdPatternSegment] { + &self.segments + } + + /// Consumes the pattern, returning its parsed segments. + #[must_use] + pub fn into_segments(self) -> Vec { + self.segments + } + + /// Returns `true` if `self` (used as a candidate) matches `pattern`. + /// + /// Mirrors [`GtsId::matches_pattern`] for the case where the candidate is + /// itself a pattern: the candidate's fixed prefix is matched field-by-field + /// against `pattern` (with minor-version flexibility). + #[must_use] + pub fn matches_pattern(&self, pattern: &GtsIdPattern) -> bool { + pattern.matches_views(self.segments()) + } + + /// Returns `true` if this pattern matches the given candidate segments. + /// + /// This is the core matching primitive; [`GtsId::matches_pattern`] and + /// [`GtsIdPattern::matches_pattern`] both delegate to it. A non-wildcard + /// pattern segment must match exactly (with minor-version flexibility); a + /// wildcard segment accepts anything from that point on. The candidate's + /// segments are read through [`SegmentView`], so they may be concrete + /// ([`GtsIdSegment`](crate::GtsIdSegment)) or pattern segments. + /// + /// [`GtsId::matches_pattern`]: crate::GtsId::matches_pattern + pub(crate) fn matches_views(&self, candidate: &[C]) -> bool { + let pattern_segs = &self.segments; + // If pattern is longer than candidate, no match + if pattern_segs.len() > candidate.len() { + return false; + } + + for (i, p_seg) in pattern_segs.iter().enumerate() { + let c_seg = &candidate[i]; + + // If pattern segment is a wildcard, only its specified (non-empty) + // prefix fields must match; it then accepts anything after this point. + if p_seg.is_wildcard() { + if !p_seg.vendor().is_empty() && p_seg.vendor() != c_seg.vendor() { + return false; + } + if !p_seg.package().is_empty() && p_seg.package() != c_seg.package() { + return false; + } + if !p_seg.namespace().is_empty() && p_seg.namespace() != c_seg.namespace() { + return false; + } + if !p_seg.type_name().is_empty() && p_seg.type_name() != c_seg.type_name() { + return false; + } + if p_seg.ver_major() != 0 && p_seg.ver_major() != c_seg.ver_major() { + return false; + } + if let Some(p_minor) = p_seg.ver_minor() + && Some(p_minor) != c_seg.ver_minor() + { + return false; + } + // No `is_type` check here: a wildcard segment never carries a + // type marker. `parse_pattern` only accepts `*` as the final + // token of a pattern ending in `.*`/`~*`, so a `*` tilde-part is + // always the last one and never gets a trailing `~` appended. + // A wildcard therefore matches a candidate position regardless + // of whether that candidate segment is a type or an instance. + return true; + } + + // Non-wildcard UUID tail - compare raw segment string (the actual UUID) + if p_seg.uuid_tail().is_some() && p_seg.uuid_tail() != c_seg.uuid_tail() { + return false; + } + + // Non-wildcard segment - all fields must match exactly + if p_seg.vendor() != c_seg.vendor() { + return false; + } + if p_seg.package() != c_seg.package() { + return false; + } + if p_seg.namespace() != c_seg.namespace() { + return false; + } + if p_seg.type_name() != c_seg.type_name() { + return false; + } + + // Check version matching + if p_seg.ver_major() != c_seg.ver_major() { + return false; + } + + // Minor version: if pattern has no minor version, accept any minor in candidate + if let Some(p_minor) = p_seg.ver_minor() + && Some(p_minor) != c_seg.ver_minor() + { + return false; + } + + // Check is_type flag matches + if p_seg.is_type() != c_seg.is_type() { + return false; + } + } + + true + } + + /// Returns `true` if `self` covers `other`: every GTS ID matched by the + /// `other` pattern is also matched by `self`. + /// + /// In other words `self` is the broader (less specific) pattern and `other` + /// is contained within it. Coverage is decided segment-by-segment with the + /// same field logic as matching — minor-version flexibility, wildcard + /// widening, and the implicit derived-type coverage of a bare type id — so + /// `…event.v1~*` covers `…event.v1.0~*` (any-minor covers a specific minor) + /// and `…event.v1~` covers `…event.v1.0~`, which a naive string-prefix test + /// would miss. + /// + /// # Examples + /// + /// ```ignore + /// let broad = GtsIdPattern::try_new("gts.x.core.srr.resource.v1~*")?; + /// let narrow = GtsIdPattern::try_new("gts.x.core.srr.resource.v1~acme.*")?; + /// assert!(broad.covers(&narrow)); // "*" covers "acme.*" + /// assert!(!narrow.covers(&broad)); // but not the other way round + /// + /// let other = GtsIdPattern::try_new("gts.x.core.other.resource.v1~*")?; + /// assert!(!broad.covers(&other)); // different base type — no coverage + /// ``` + #[must_use] + pub fn covers(&self, other: &GtsIdPattern) -> bool { + // `self` covers `other` iff `self`, used as a pattern, matches `other`'s + // segments taken as the candidate: where `self` is concrete the fields + // must agree, and where `self` widens (wildcard / omitted minor) it + // accepts whatever `other` fixes at that position. + self.matches_views(other.segments()) + } + + /// Checks if a string is a valid GTS identifier pattern. + #[must_use] + pub fn is_valid(s: &str) -> bool { + Self::try_new(s).is_ok() + } +} + +impl From<&GtsId> for GtsIdPattern { + /// A concrete [`GtsId`] is always a valid zero-wildcard pattern. The + /// conversion reuses the id's already-validated segments (wrapping each in + /// [`GtsIdPatternSegment::Segment`]) instead of re-parsing, so it is cheap + /// and cannot fail. + /// + /// Note this is not an "exact-match" pattern: a base type id used as a + /// pattern also matches everything derived from it down the chain — a type + /// id `gts.a.b.c.d.v1~` behaves as the implicit envelope `gts.a.b.c.d.v1~*` + /// (GTS spec §3.6, "implicit derived-type coverage"). + fn from(id: &GtsId) -> Self { + GtsIdPattern { + pattern: id.id().to_owned(), + segments: id + .segments() + .iter() + .cloned() + .map(GtsIdPatternSegment::Segment) + .collect(), + } + } +} + +impl From for GtsIdPattern { + /// Consuming variant of [`From<&GtsId>`](GtsIdPattern). Reuses the id's owned + /// segments via [`GtsId::into_segments`], avoiding the per-segment clone. + fn from(id: GtsId) -> Self { + let pattern = id.id().to_owned(); + GtsIdPattern { + pattern, + segments: id + .into_segments() + .into_iter() + .map(GtsIdPatternSegment::Segment) + .collect(), + } + } +} + +impl fmt::Display for GtsIdPattern { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.pattern) + } +} + +impl FromStr for GtsIdPattern { + type Err = GtsIdError; + + fn from_str(s: &str) -> Result { + Self::try_new(s) + } +} + +impl AsRef for GtsIdPattern { + fn as_ref(&self) -> &str { + &self.pattern + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + #[test] + fn test_gts_wildcard_simple() { + let pattern = GtsIdPattern::try_new("gts.x.core.events.*").expect("test"); + let id = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + assert!(id.matches_pattern(&pattern)); + } + + #[test] + fn test_gts_wildcard_no_match() { + let pattern = GtsIdPattern::try_new("gts.x.core.events.*").expect("test"); + let id = GtsId::try_new("gts.y.core.events.event.v1~").expect("test"); + assert!(!id.matches_pattern(&pattern)); + } + + #[test] + fn test_trailing_wildcard_ignores_type_marker() { + // A trailing `*` matches the candidate position whether the candidate + // segment there is a type (`~`) or an instance. A wildcard segment never + // carries its own type marker, so it imposes no `is_type` constraint. + let pattern = GtsIdPattern::try_new("gts.x.core.events.topic.v1~*").expect("test"); + + let type_candidate = + GtsId::try_new("gts.x.core.events.topic.v1~vendor.app.orders.thing.v1~").expect("test"); + let instance_candidate = + GtsId::try_new("gts.x.core.events.topic.v1~vendor.app.orders.thing.v1.0") + .expect("test"); + + assert!(type_candidate.matches_pattern(&pattern)); + assert!(instance_candidate.matches_pattern(&pattern)); + } + + #[test] + fn test_gts_wildcard_type_suffix() { + // Wildcard after ~ should match type IDs + let pattern = GtsIdPattern::try_new("gts.x.core.events.*").expect("test"); + let id = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + assert!(id.matches_pattern(&pattern)); + } + + #[test] + fn test_version_flexibility_in_matching() { + // Pattern without minor version should match any minor version + let pattern = GtsIdPattern::try_new("gts.x.core.events.event.v1~").expect("test"); + let id_no_minor = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + let id_with_minor = GtsId::try_new("gts.x.core.events.event.v1.0~").expect("test"); + + assert!(id_no_minor.matches_pattern(&pattern)); + assert!(id_with_minor.matches_pattern(&pattern)); + } + + #[test] + fn test_gts_wildcard_exact_match() { + let pattern = GtsIdPattern::try_new("gts.x.core.events.event.v1~").expect("test"); + let id = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + assert!(id.matches_pattern(&pattern)); + } + + #[test] + fn test_gts_wildcard_version_mismatch() { + let pattern = GtsIdPattern::try_new("gts.x.core.events.event.v2~").expect("test"); + let id = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + assert!(!id.matches_pattern(&pattern)); + } + + #[test] + fn test_pattern_longer_than_candidate_does_not_match() { + let pattern = + GtsIdPattern::try_new("gts.x.core.events.topic.v1~vendor.app.orders.order.v1~") + .expect("test"); + let id = GtsId::try_new("gts.x.core.events.topic.v1~").expect("test"); + assert!(!id.matches_pattern(&pattern)); + } + + #[test] + fn test_uuid_tail_mismatch_does_not_match() { + let pattern = GtsIdPattern::try_new( + "gts.x.core.events.topic.v1~7a1d2f34-5678-49ab-9012-abcdef123456", + ) + .expect("test"); + let id = GtsId::try_new("gts.x.core.events.topic.v1~7a1d2f34-5678-49ab-9012-abcdef123457") + .expect("test"); + assert!(!id.matches_pattern(&pattern)); + } + + #[test] + fn test_gts_wildcard_with_minor_version() { + let pattern = GtsIdPattern::try_new("gts.x.core.events.event.v1.0~").expect("test"); + let id = GtsId::try_new("gts.x.core.events.event.v1.0~").expect("test"); + assert!(id.matches_pattern(&pattern)); + } + + #[test] + fn test_gts_wildcard_invalid_pattern() { + let result = GtsIdPattern::try_new("invalid"); + assert!(result.is_err()); + } + + #[test] + fn test_gts_wildcard_multiple_wildcards_error() { + let result = GtsIdPattern::try_new("gts.*.*.*.*"); + assert!(result.is_err()); + } + + #[test] + fn test_gts_wildcard_instance_match() { + let pattern = GtsIdPattern::try_new("gts.x.core.events.*").expect("test"); + let id = GtsId::try_new("gts.x.core.events.event.v1~a.b.c.d.v1.0").expect("test"); + assert!(id.matches_pattern(&pattern)); + } + + #[test] + fn test_gts_wildcard_whitespace_trimming() { + let pattern = GtsIdPattern::try_new(" gts.x.core.events.* ").expect("test"); + assert_eq!(pattern.pattern(), "gts.x.core.events.*"); + } + + #[test] + fn test_gts_wildcard_only_at_end() { + // Wildcard in middle should fail + let result1 = GtsIdPattern::try_new("gts.*.core.events.event.v1~"); + assert!(result1.is_err()); + + // Wildcard at end should work + let pattern2 = GtsIdPattern::try_new("gts.x.core.events.*").expect("test"); + let id2 = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + assert!(id2.matches_pattern(&pattern2)); + } + + #[test] + fn test_gts_wildcard_no_wildcard_different_vendor() { + let pattern = GtsIdPattern::try_new("gts.x.core.events.event.v1~").expect("test"); + let id = GtsId::try_new("gts.y.core.events.event.v1~").expect("test"); + assert!(!id.matches_pattern(&pattern)); + } + + #[test] + fn test_gts_wildcard_display_trait() { + let pattern = GtsIdPattern::try_new("gts.x.core.events.*").expect("test"); + assert_eq!(format!("{pattern}"), "gts.x.core.events.*"); + } + + #[test] + fn test_gts_wildcard_from_str_trait() { + let pattern: GtsIdPattern = "gts.x.core.events.*".parse().expect("test"); + assert_eq!(pattern.pattern(), "gts.x.core.events.*"); + } + + #[test] + fn test_gts_wildcard_as_ref_trait() { + let pattern = GtsIdPattern::try_new("gts.x.core.events.*").expect("test"); + let s: &str = pattern.as_ref(); + assert_eq!(s, "gts.x.core.events.*"); + } + + #[test] + fn test_gts_wildcard_type_suffix_match() { + // Wildcard after type suffix + let pattern = GtsIdPattern::try_new("gts.x.pkg.ns.type.v1~*").expect("test"); + let id1 = GtsId::try_new("gts.x.pkg.ns.type.v1~a.b.c.child.v1~").expect("test"); + let id2 = GtsId::try_new("gts.x.pkg.ns.type.v2~a.b.c.child.v1~").expect("test"); + assert!(id1.matches_pattern(&pattern)); + assert!(!id2.matches_pattern(&pattern)); + } + + #[test] + fn test_gts_wildcard_at_various_positions() { + // Wildcard at vendor position + let result = GtsIdPattern::try_new("gts.*"); + assert!(result.is_ok()); + + // Wildcard at package position + let result = GtsIdPattern::try_new("gts.x.*"); + assert!(result.is_ok()); + + // Wildcard at namespace position + let result = GtsIdPattern::try_new("gts.x.pkg.*"); + assert!(result.is_ok()); + + // Wildcard at type position + let result = GtsIdPattern::try_new("gts.x.pkg.ns.*"); + assert!(result.is_ok()); + + // Wildcard at version position + let result = GtsIdPattern::try_new("gts.x.pkg.ns.type.*"); + assert!(result.is_ok()); + } + + // ---- covers ---- + + #[test] + fn test_covers_broad_covers_narrow() { + let broad = GtsIdPattern::try_new("gts.x.core.srr.resource.v1~*").expect("test"); + let narrow = GtsIdPattern::try_new("gts.x.core.srr.resource.v1~acme.*").expect("test"); + // Coverage is directional: the broad pattern covers the narrow one, + // never the reverse. + assert!(broad.covers(&narrow)); + assert!(!narrow.covers(&broad)); + } + + #[test] + fn test_covers_disjoint_types() { + let a = GtsIdPattern::try_new("gts.x.core.srr.resource.v1~*").expect("test"); + let b = GtsIdPattern::try_new("gts.x.core.other.resource.v1~*").expect("test"); + assert!(!a.covers(&b)); + assert!(!b.covers(&a)); + } + + #[test] + fn test_covers_identical_patterns() { + let a = GtsIdPattern::try_new("gts.x.core.srr.resource.v1~*").expect("test"); + let b = GtsIdPattern::try_new("gts.x.core.srr.resource.v1~*").expect("test"); + // A pattern covers an identical one (both directions). + assert!(a.covers(&b)); + assert!(b.covers(&a)); + } + + #[test] + fn test_covers_wildcard_covers_exact() { + let exact = GtsIdPattern::try_new("gts.x.core.srr.resource.v1~acme.crm._.contact.v1~") + .expect("test"); + let broad = GtsIdPattern::try_new("gts.x.core.srr.resource.v1~*").expect("test"); + assert!(broad.covers(&exact)); + assert!(!exact.covers(&broad)); + } + + #[test] + fn test_covers_three_levels() { + let l1 = GtsIdPattern::try_new("gts.x.core.srr.resource.v1~*").expect("test"); + let l2 = GtsIdPattern::try_new("gts.x.core.srr.resource.v1~acme.*").expect("test"); + let l3 = GtsIdPattern::try_new("gts.x.core.srr.resource.v1~acme.crm.*").expect("test"); + // Broader patterns cover narrower ones, transitively. + assert!(l1.covers(&l2)); + assert!(l1.covers(&l3)); + assert!(l2.covers(&l3)); + assert!(!l2.covers(&l1)); + assert!(!l3.covers(&l1)); + assert!(!l3.covers(&l2)); + } + + // ---- glued version wildcard `v*` ---- + + #[test] + fn test_version_wildcard_valid_and_is_wildcard() { + let pattern = GtsIdPattern::try_new("gts.x.llm.chat.message.v*").expect("test"); + assert_eq!(pattern.segments().len(), 1); + assert!(pattern.segments()[0].is_wildcard()); + assert!(GtsIdPattern::is_valid("gts.x.llm.chat.message.v*")); + } + + #[test] + fn test_version_wildcard_matches_any_version_and_chain() { + let pattern = GtsIdPattern::try_new("gts.x.llm.chat.message.v*").expect("test"); + for id in [ + "gts.x.llm.chat.message.v1.0~", + "gts.x.llm.chat.message.v1.1~", + "gts.x.llm.chat.message.v2~", + "gts.x.llm.chat.message.v1.0~acme.app.ns.derived.v1~", + ] { + let candidate = GtsId::try_new(id).expect("test"); + assert!(candidate.matches_pattern(&pattern), "should match: {id}"); + } + // Different type is not matched. + let other = GtsId::try_new("gts.x.llm.chat.other.v1~").expect("test"); + assert!(!other.matches_pattern(&pattern)); + } + + #[test] + fn test_version_wildcard_equivalent_to_version_position_star() { + // `message.v*` and `message.*` match the same set: the `v` marker adds no + // constraint because every version token starts with `v`. + let v_star = GtsIdPattern::try_new("gts.x.llm.chat.message.v*").expect("test"); + let dot_star = GtsIdPattern::try_new("gts.x.llm.chat.message.*").expect("test"); + for id in [ + "gts.x.llm.chat.message.v1~", + "gts.x.llm.chat.message.v9.9~", + "gts.x.llm.chat.message.v1.0~acme.app.ns.derived.v1~", + ] { + let candidate = GtsId::try_new(id).expect("test"); + assert_eq!( + candidate.matches_pattern(&v_star), + candidate.matches_pattern(&dot_star), + "match divergence for {id}" + ); + } + } + + #[test] + fn test_version_wildcard_rejections() { + // `*` after `v*` — two wildcards. + assert!(!GtsIdPattern::is_valid("gts.x.llm.chat.message.v*~*")); + // A stray `~` after the wildcard — `*` is not the final character. + assert!(!GtsIdPattern::is_valid("gts.x.llm.chat.message.v1.*~")); + // Partial (non-version) token wildcard. + assert!(!GtsIdPattern::is_valid("gts.x.llm.chat.msg*")); + } + + // ---- covers: minor-version flexibility (segment-based) ---- + + #[test] + fn test_covers_any_minor_covers_specific_minor() { + // A pattern pinned to a major (no minor) is broader than one pinned to a + // specific minor — segment-based coverage captures this; string prefixes + // would not (`…v1~` is not a string prefix of `…v1.0~`). + let any_minor = GtsIdPattern::try_new("gts.x.core.events.event.v1~*").expect("test"); + let specific = GtsIdPattern::try_new("gts.x.core.events.event.v1.0~*").expect("test"); + assert!(any_minor.covers(&specific)); + assert!(!specific.covers(&any_minor)); + } + + #[test] + fn test_covers_bare_type_minor_flexibility() { + let any_minor = GtsIdPattern::try_new("gts.x.core.events.event.v1~").expect("test"); + let specific = GtsIdPattern::try_new("gts.x.core.events.event.v1.0~").expect("test"); + assert!(any_minor.covers(&specific)); + assert!(!specific.covers(&any_minor)); + } + + #[test] + fn test_covers_major_version_mismatch() { + let v1 = GtsIdPattern::try_new("gts.x.core.events.event.v1~*").expect("test"); + let v2 = GtsIdPattern::try_new("gts.x.core.events.event.v2~*").expect("test"); + assert!(!v1.covers(&v2)); + assert!(!v2.covers(&v1)); + } + + // ---- From conversion ---- + + #[test] + fn test_from_gts_id_ref() { + let id = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + let pattern = GtsIdPattern::from(&id); + // The pattern string is the id verbatim. + assert_eq!(pattern.pattern(), id.id()); + // A concrete id maps to a concrete pattern — no wildcard segments. + assert!(pattern.segments().iter().all(|s| !s.is_wildcard())); + // The id at minimum matches the pattern derived from itself. + assert!(id.matches_pattern(&pattern)); + } + + #[test] + fn test_concrete_pattern_covers_derived_chain() { + // Per GTS spec §3.6 "implicit derived-type coverage": a base type id used + // as a pattern is treated as the implicit envelope `…~*`, so it matches + // not only itself but every type/instance derived from it down the chain. + let pattern = GtsId::try_new("gts.a.b.c.d.v1~") + .expect("test") + .to_pattern(); + + // Exact and derived candidates both match. + let exact = GtsId::try_new("gts.a.b.c.d.v1~").expect("test"); + let derived = GtsId::try_new("gts.a.b.c.d.v1~w.x.y.z.v1").expect("test"); + assert!(exact.matches_pattern(&pattern)); + assert!(derived.matches_pattern(&pattern)); + + // A different base type is not covered. + let other_base = GtsId::try_new("gts.a.b.c.other.v1~w.x.y.z.v1").expect("test"); + assert!(!other_base.matches_pattern(&pattern)); + } + + #[test] + fn test_from_gts_id_owned() { + let id = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + let expected = id.id().to_owned(); + let pattern: GtsIdPattern = id.into(); + assert_eq!(pattern.pattern(), expected); + assert!(pattern.segments().iter().all(|s| !s.is_wildcard())); + } + + #[test] + fn test_from_gts_id_chained_preserves_segments() { + let id = GtsId::try_new("gts.x.core.events.topic.v1~vendor.app.orders.thing.v1.0") + .expect("test"); + let pattern = GtsIdPattern::from(&id); + assert_eq!(pattern.segments().len(), id.segments().len()); + assert_eq!(pattern.pattern(), id.id()); + assert!(id.matches_pattern(&pattern)); + } + + #[test] + fn test_from_ref_and_owned_agree() { + let id = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + let from_ref = GtsIdPattern::from(&id); + let from_owned = GtsIdPattern::from(id); + // Borrowing and consuming conversions produce the same pattern. + assert_eq!(from_ref, from_owned); + } + + #[test] + fn test_from_gts_id_matches_to_pattern() { + let id = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + // The inherent `to_pattern` is just the ergonomic form of `From<&GtsId>`. + assert_eq!(GtsIdPattern::from(&id), id.to_pattern()); + } + + // ---- is_valid ---- + + #[test] + fn test_is_valid() { + // Exact ids and trailing-`*` patterns are valid. + assert!(GtsIdPattern::is_valid("gts.x.core.events.event.v1~")); + assert!(GtsIdPattern::is_valid("gts.x.core.events.*")); + assert!(GtsIdPattern::is_valid("gts.x.core.events.topic.v1~*")); + + // Malformed strings and misplaced wildcards are not. + assert!(!GtsIdPattern::is_valid("not-a-gts-id")); + assert!(!GtsIdPattern::is_valid("gts.x.*.events.event.v1~")); + assert!(!GtsIdPattern::is_valid("gts.x.core.events.topic.v1.*~")); + } +} diff --git a/gts-id/src/gts_id_segment.rs b/gts-id/src/gts_id_segment.rs new file mode 100644 index 0000000..f6e2fcd --- /dev/null +++ b/gts-id/src/gts_id_segment.rs @@ -0,0 +1,890 @@ +//! Parsed segments of a GTS identifier. +//! +//! A segment is the part between `~` markers. There are two segment types: +//! +//! * [`GtsIdSegment`] — a **concrete** segment (a +//! `vendor.package.namespace.type.version` segment or a trailing +//! anonymous-instance UUID). This is what a concrete [`GtsId`](crate::GtsId) +//! is made of, so it has no notion of a wildcard. +//! * [`GtsIdPatternSegment`] — what a [`GtsIdPattern`](crate::GtsIdPattern) is +//! made of: either a concrete [`GtsIdSegment`] or a trailing `*` wildcard. +//! +//! Both are enums whose variant payloads ([`GtsIdSegmentParts`], [`GtsUuidTail`]) +//! have no public constructors, so the only way to obtain a segment is through +//! validated parsing: the parser's invariants (validated tokens, canonical +//! versions, well-formed UUID tails) always hold and cannot be forged by +//! downstream crates. Inspect a segment through its accessor methods or by +//! matching. + +use crate::parse::{expected_format, is_valid_segment_token, parse_u32_exact}; + +/// The parsed name and version components shared by concrete and wildcard +/// segments. +/// +/// For a wildcard segment these fields hold the (possibly partial) prefix that +/// precedes the `*` token — e.g. `x.core.*` fills `vendor` and `package` and +/// leaves the rest empty. Empty strings, a zero `ver_major`, and a `None` +/// `ver_minor` therefore mean "unspecified" in the wildcard case. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct GtsIdSegmentParts { + /// The raw segment string as it appeared in the source (including any + /// trailing `~` for a type segment). + raw: String, + vendor: String, + package: String, + namespace: String, + type_name: String, + ver_major: u32, + ver_minor: Option, +} + +impl GtsIdSegmentParts { + /// The raw segment string as it appeared in the source. + /// + /// This includes any trailing `~` for a type segment. + #[must_use] + pub fn raw(&self) -> &str { + &self.raw + } + + /// The vendor token, or `""` when unspecified in a wildcard segment. + #[must_use] + pub fn vendor(&self) -> &str { + &self.vendor + } + + /// The package token, or `""` when unspecified in a wildcard segment. + #[must_use] + pub fn package(&self) -> &str { + &self.package + } + + /// The namespace token, or `""` when unspecified in a wildcard segment. + #[must_use] + pub fn namespace(&self) -> &str { + &self.namespace + } + + /// The type token, or `""` when unspecified in a wildcard segment. + #[must_use] + pub fn type_name(&self) -> &str { + &self.type_name + } + + /// The major version, or `0` when unspecified in a wildcard segment. + #[must_use] + pub fn ver_major(&self) -> u32 { + self.ver_major + } + + /// The minor version, when present. + #[must_use] + pub fn ver_minor(&self) -> Option { + self.ver_minor + } + + /// `true` when the segment is a type definition (ended with `~`). + #[must_use] + pub fn is_type(&self) -> bool { + self.raw.ends_with('~') + } +} + +/// A read-only view of a segment's parsed fields. +/// +/// Implemented by both [`GtsIdSegment`] and [`GtsIdPatternSegment`] so the +/// pattern matcher can compare a pattern against a candidate of either kind +/// (a concrete id or another pattern) with the same field-level logic. +pub trait SegmentView { + fn vendor(&self) -> &str; + fn package(&self) -> &str; + fn namespace(&self) -> &str; + fn type_name(&self) -> &str; + fn ver_major(&self) -> u32; + fn ver_minor(&self) -> Option; + fn is_type(&self) -> bool; + fn uuid_tail(&self) -> Option<&str>; +} + +/// A trailing anonymous-instance UUID (combined anonymous instance). +/// +/// Opaque: the inner string is guaranteed to be a well-formed lowercase UUID +/// because the only constructor is the validated parser. Downstream crates can +/// match on [`GtsIdSegment::UuidTail`] but cannot forge one with a non-UUID +/// string. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct GtsUuidTail(String); + +impl GtsUuidTail { + /// The UUID string (a well-formed lowercase UUID). + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// The parsed UUID. The string was validated at parse time, so this is + /// `Some` for any value obtained from the parser. + /// + /// Requires the `uuid` feature. + #[cfg(feature = "uuid")] + #[must_use] + pub fn uuid(&self) -> Option { + uuid::Uuid::parse_str(&self.0).ok() + } +} + +/// A single concrete `~`-delimited segment of a parsed GTS identifier. +/// +/// A concrete segment is either a `vendor.package.namespace.type.version` +/// segment or a trailing anonymous-instance UUID — never a wildcard. This is +/// what [`GtsId`](crate::GtsId) is composed of. +/// +/// Both payloads ([`GtsIdSegmentParts`] and [`GtsUuidTail`]) are unconstructable +/// outside this crate, so a segment can only be produced by the validated +/// parser and its invariants always hold. Inspect a segment through its accessor +/// methods or by matching. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum GtsIdSegment { + /// A concrete vendor.package.namespace.type.version segment. + Concrete(GtsIdSegmentParts), + /// A trailing anonymous-instance UUID. + UuidTail(GtsUuidTail), +} + +impl GtsIdSegment { + /// The parsed name/version parts, or `None` for a UUID-tail segment. + #[must_use] + fn parts(&self) -> Option<&GtsIdSegmentParts> { + match self { + GtsIdSegment::Concrete(p) => Some(p), + GtsIdSegment::UuidTail(_) => None, + } + } + + /// The raw segment string as it appeared in the source. + /// + /// For a concrete segment this includes any trailing `~`; for a UUID tail it + /// is the UUID itself. + #[must_use] + pub fn raw(&self) -> &str { + match self { + GtsIdSegment::Concrete(p) => &p.raw, + GtsIdSegment::UuidTail(uuid) => uuid.as_str(), + } + } + + /// The vendor token, or `""` for a UUID tail. + #[must_use] + pub fn vendor(&self) -> &str { + self.parts().map_or("", |p| &p.vendor) + } + + /// The package token, or `""` when unspecified. + #[must_use] + pub fn package(&self) -> &str { + self.parts().map_or("", |p| &p.package) + } + + /// The namespace token, or `""` when unspecified. + #[must_use] + pub fn namespace(&self) -> &str { + self.parts().map_or("", |p| &p.namespace) + } + + /// The type token, or `""` when unspecified. + #[must_use] + pub fn type_name(&self) -> &str { + self.parts().map_or("", |p| &p.type_name) + } + + /// The major version, or `0` for a UUID tail. + #[must_use] + pub fn ver_major(&self) -> u32 { + self.parts().map_or(0, |p| p.ver_major) + } + + /// The minor version, when present. + #[must_use] + pub fn ver_minor(&self) -> Option { + self.parts().and_then(|p| p.ver_minor) + } + + /// `true` when the segment is a type definition (ended with `~`). + #[must_use] + pub fn is_type(&self) -> bool { + // Derived from the raw string: a type segment ends with a `~` marker (a + // UUID tail never does). + self.raw().ends_with('~') + } + + /// The UUID string when this is a UUID-tail segment, else `None`. + #[must_use] + pub fn uuid_tail(&self) -> Option<&str> { + match self { + GtsIdSegment::UuidTail(uuid) => Some(uuid.as_str()), + GtsIdSegment::Concrete(_) => None, + } + } + + /// The deterministic UUID parsed from a UUID-tail segment. + /// + /// Returns `None` for any other segment kind. The stored string was + /// validated as a well-formed UUID when the segment was parsed, so this + /// never fails for a UUID tail. + /// + /// Requires the `uuid` feature. + #[cfg(feature = "uuid")] + #[must_use] + pub fn uuid(&self) -> Option { + self.uuid_tail().and_then(|s| uuid::Uuid::parse_str(s).ok()) + } + + /// Construct a UUID-tail segment from an already-validated UUID string. + pub(crate) fn uuid_tail_segment(uuid: &str) -> Self { + GtsIdSegment::UuidTail(GtsUuidTail(uuid.to_owned())) + } + + /// Parse a single **concrete** GTS segment (the part between `~` markers). + /// + /// Wildcards are rejected: a concrete identifier never contains `*`. Use + /// [`GtsIdPatternSegment::parse`] for pattern segments. + /// + /// # Arguments + /// * `num` - 1-based segment number (used in error messages and format hints) + /// * `segment` - The raw segment string, possibly including a trailing `~` + /// + /// # Errors + /// Returns a human-readable error message if the segment is invalid. + pub(crate) fn parse(num: usize, segment: &str) -> Result { + // `allow_wildcards = false` guarantees a concrete result. + let (parts, _is_wildcard) = parse_segment_parts(num, segment, false)?; + Ok(GtsIdSegment::Concrete(parts)) + } +} + +/// A single segment of a parsed GTS identifier **pattern**. +/// +/// A pattern segment is either a concrete [`GtsIdSegment`] or a trailing `*` +/// wildcard carrying the (possibly partial) prefix that precedes the `*`. This +/// is what [`GtsIdPattern`](crate::GtsIdPattern) is composed of; the wildcard +/// can only ever be the final segment. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum GtsIdPatternSegment { + /// A concrete (non-wildcard) segment. + Segment(GtsIdSegment), + /// A trailing `*` wildcard with its (possibly partial) prefix constraints. + Wildcard(GtsIdSegmentParts), +} + +impl GtsIdPatternSegment { + /// `true` when this is a wildcard segment. + #[must_use] + pub fn is_wildcard(&self) -> bool { + matches!(self, GtsIdPatternSegment::Wildcard(_)) + } + + /// The raw segment string as it appeared in the source. + #[must_use] + pub fn raw(&self) -> &str { + match self { + GtsIdPatternSegment::Segment(s) => s.raw(), + GtsIdPatternSegment::Wildcard(p) => &p.raw, + } + } + + /// The vendor token, or `""` when unspecified. + #[must_use] + pub fn vendor(&self) -> &str { + match self { + GtsIdPatternSegment::Segment(s) => s.vendor(), + GtsIdPatternSegment::Wildcard(p) => &p.vendor, + } + } + + /// The package token, or `""` when unspecified. + #[must_use] + pub fn package(&self) -> &str { + match self { + GtsIdPatternSegment::Segment(s) => s.package(), + GtsIdPatternSegment::Wildcard(p) => &p.package, + } + } + + /// The namespace token, or `""` when unspecified. + #[must_use] + pub fn namespace(&self) -> &str { + match self { + GtsIdPatternSegment::Segment(s) => s.namespace(), + GtsIdPatternSegment::Wildcard(p) => &p.namespace, + } + } + + /// The type token, or `""` when unspecified. + #[must_use] + pub fn type_name(&self) -> &str { + match self { + GtsIdPatternSegment::Segment(s) => s.type_name(), + GtsIdPatternSegment::Wildcard(p) => &p.type_name, + } + } + + /// The major version, or `0` when unspecified. + #[must_use] + pub fn ver_major(&self) -> u32 { + match self { + GtsIdPatternSegment::Segment(s) => s.ver_major(), + GtsIdPatternSegment::Wildcard(p) => p.ver_major, + } + } + + /// The minor version, when present. + #[must_use] + pub fn ver_minor(&self) -> Option { + match self { + GtsIdPatternSegment::Segment(s) => s.ver_minor(), + GtsIdPatternSegment::Wildcard(p) => p.ver_minor, + } + } + + /// `true` when the segment is a type definition (ended with `~`). + /// Always `false` for a wildcard segment (a wildcard never carries a `~`). + #[must_use] + pub fn is_type(&self) -> bool { + self.raw().ends_with('~') + } + + /// The UUID string when this wraps a UUID-tail segment, else `None`. + #[must_use] + pub fn uuid_tail(&self) -> Option<&str> { + match self { + GtsIdPatternSegment::Segment(s) => s.uuid_tail(), + GtsIdPatternSegment::Wildcard(_) => None, + } + } + + /// Construct a UUID-tail pattern segment from an already-validated UUID. + pub(crate) fn uuid_tail_segment(uuid: &str) -> Self { + GtsIdPatternSegment::Segment(GtsIdSegment::uuid_tail_segment(uuid)) + } + + /// Parse a single GTS **pattern** segment (the part between `~` markers). + /// + /// A trailing `*` wildcard is accepted as the final token; otherwise the + /// segment is concrete. + /// + /// # Arguments + /// * `num` - 1-based segment number (used in error messages and format hints) + /// * `segment` - The raw segment string, possibly including a trailing `~` + /// + /// # Errors + /// Returns a human-readable error message if the segment is invalid. + pub(crate) fn parse(num: usize, segment: &str) -> Result { + let (parts, is_wildcard) = parse_segment_parts(num, segment, true)?; + if is_wildcard { + Ok(GtsIdPatternSegment::Wildcard(parts)) + } else { + Ok(GtsIdPatternSegment::Segment(GtsIdSegment::Concrete(parts))) + } + } +} + +/// Parse a segment's tokens into [`GtsIdSegmentParts`], reporting whether it is +/// a wildcard. +/// +/// This is the shared token-validation logic backing both +/// [`GtsIdSegment::parse`] (`allow_wildcards = false`) and +/// [`GtsIdPatternSegment::parse`] (`allow_wildcards = true`). When +/// `allow_wildcards` is `false` the returned flag is always `false`. +fn parse_segment_parts( + num: usize, + segment: &str, + allow_wildcards: bool, +) -> Result<(GtsIdSegmentParts, bool), String> { + let mut seg = segment.to_owned(); + + // Strip the type marker (~) for tokenization. It stays in `raw` (the + // original `segment`), so `is_type` is derived from there — no stored flag. + if seg.contains('~') { + let tilde_count = seg.matches('~').count(); + if tilde_count > 1 { + return Err("Too many '~' characters".to_owned()); + } + if seg.ends_with('~') { + seg.pop(); + } else { + return Err("'~' must be at the end".to_owned()); + } + } + + let tokens: Vec<&str> = seg.split('.').collect(); + let fmt = expected_format(num); + + if tokens.len() > 6 { + return Err(format!( + "Too many tokens (got {}, max 6). Expected format: {fmt}", + tokens.len() + )); + } + + let ends_with_wildcard = allow_wildcards && seg.ends_with('*'); + + if !ends_with_wildcard && tokens.len() < 5 { + return Err(format!( + "Too few tokens (got {}, min 5). Expected format: {fmt}", + tokens.len() + )); + } + + // Detect extra name token before version (e.g., vendor.package.namespace.type.extra.v1) + if !ends_with_wildcard && tokens.len() == 6 { + let has_wildcard = allow_wildcards && tokens.contains(&"*"); + if !has_wildcard + && !tokens[4].starts_with('v') + && tokens[5].starts_with('v') + && is_valid_segment_token(tokens[4]) + { + return Err(format!( + "Too many name tokens before version (got 5, expected 4). Expected format: {fmt}" + )); + } + } + + // Validate first 4 tokens (vendor, package, namespace, type). + // A trailing '*' wildcard is allowed as the final token, but all tokens + // before it must still pass validation. Wildcards in the middle + // (e.g., "x.*.ns.type.v1") are rejected because '*' fails is_valid_segment_token. + for (i, token) in tokens.iter().take(4).enumerate() { + if allow_wildcards && *token == "*" { + if i == tokens.len() - 1 { + break; // '*' as final token is handled in the parsing section below + } + return Err("Wildcard '*' is only allowed as the final token".to_owned()); + } + if !is_valid_segment_token(token) { + let token_name = match i { + 0 => "vendor", + 1 => "package", + 2 => "namespace", + 3 => "type", + _ => "token", + }; + return Err(format!( + "Invalid {token_name} token '{token}'. \ + Must start with [a-z_] and contain only [a-z0-9_]" + )); + } + } + + // Build the parts, parsing tokens progressively. A `*` token at any + // position turns the segment into a wildcard and ends parsing there. + let mut parts = GtsIdSegmentParts { + raw: segment.to_owned(), + vendor: String::new(), + package: String::new(), + namespace: String::new(), + type_name: String::new(), + ver_major: 0, + ver_minor: None, + }; + + if !tokens.is_empty() { + if allow_wildcards && tokens[0] == "*" { + return Ok((parts, true)); + } + tokens[0].clone_into(&mut parts.vendor); + } + + if tokens.len() > 1 { + if allow_wildcards && tokens[1] == "*" { + return Ok((parts, true)); + } + tokens[1].clone_into(&mut parts.package); + } + + if tokens.len() > 2 { + if allow_wildcards && tokens[2] == "*" { + return Ok((parts, true)); + } + tokens[2].clone_into(&mut parts.namespace); + } + + if tokens.len() > 3 { + if allow_wildcards && tokens[3] == "*" { + return Ok((parts, true)); + } + tokens[3].clone_into(&mut parts.type_name); + } + + if tokens.len() > 4 { + if allow_wildcards && tokens[4] == "*" { + if 4 != tokens.len() - 1 { + return Err("Wildcard '*' is only allowed as the final token".to_owned()); + } + return Ok((parts, true)); + } + + // Glued version wildcard `v*` — the only wildcard form not standing as + // its own `*` token. The `v` is the mandatory marker that begins every + // version, so `v*` means "any version" (GTS spec §10 rule 4) and is + // equivalent to a `*` at this position. Only valid as the final token. + if allow_wildcards && tokens[4] == "v*" { + if 4 != tokens.len() - 1 { + return Err("Wildcard '*' is only allowed as the final token".to_owned()); + } + return Ok((parts, true)); + } + + if !tokens[4].starts_with('v') { + return Err("Major version must start with 'v'".to_owned()); + } + + let major_str = &tokens[4][1..]; + parts.ver_major = parse_u32_exact(major_str) + .ok_or_else(|| format!("Major version must be an integer, got '{major_str}'"))?; + } + + if tokens.len() > 5 { + if allow_wildcards && tokens[5] == "*" { + return Ok((parts, true)); + } + + parts.ver_minor = Some( + parse_u32_exact(tokens[5]) + .ok_or_else(|| format!("Minor version must be an integer, got '{}'", tokens[5]))?, + ); + } + + Ok((parts, false)) +} + +// Field views delegate to each type's inherent accessors (inherent methods take +// priority over trait methods in method-call resolution, so there is no +// recursion here). +impl SegmentView for GtsIdSegment { + fn vendor(&self) -> &str { + self.vendor() + } + fn package(&self) -> &str { + self.package() + } + fn namespace(&self) -> &str { + self.namespace() + } + fn type_name(&self) -> &str { + self.type_name() + } + fn ver_major(&self) -> u32 { + self.ver_major() + } + fn ver_minor(&self) -> Option { + self.ver_minor() + } + fn is_type(&self) -> bool { + self.is_type() + } + fn uuid_tail(&self) -> Option<&str> { + self.uuid_tail() + } +} + +impl SegmentView for GtsIdPatternSegment { + fn vendor(&self) -> &str { + self.vendor() + } + fn package(&self) -> &str { + self.package() + } + fn namespace(&self) -> &str { + self.namespace() + } + fn type_name(&self) -> &str { + self.type_name() + } + fn ver_major(&self) -> u32 { + self.ver_major() + } + fn ver_minor(&self) -> Option { + self.ver_minor() + } + fn is_type(&self) -> bool { + self.is_type() + } + fn uuid_tail(&self) -> Option<&str> { + self.uuid_tail() + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + // ---- concrete segments ---- + + #[test] + fn test_valid_segment_basic() { + let parsed = GtsIdSegment::parse(1, "x.core.events.event.v1~").unwrap(); + assert_eq!(parsed.vendor(), "x"); + assert_eq!(parsed.package(), "core"); + assert_eq!(parsed.namespace(), "events"); + assert_eq!(parsed.type_name(), "event"); + assert_eq!(parsed.ver_major(), 1); + assert_eq!(parsed.ver_minor(), None); + assert!(parsed.is_type()); + } + + #[test] + fn test_valid_segment_with_minor() { + let parsed = GtsIdSegment::parse(1, "x.core.events.event.v1.2~").unwrap(); + assert_eq!(parsed.ver_major(), 1); + assert_eq!(parsed.ver_minor(), Some(2)); + } + + #[test] + fn test_segment_too_many_tildes() { + let err = GtsIdSegment::parse(1, "x.core.events.event.v1~~").unwrap_err(); + assert!(err.contains("Too many '~' characters"), "got: {err}"); + } + + #[test] + fn test_segment_tilde_not_at_end() { + let err = GtsIdSegment::parse(1, "x.core~mid.events.event.v1").unwrap_err(); + assert!(err.contains("'~' must be at the end"), "got: {err}"); + } + + #[test] + fn test_segment_too_many_tokens() { + let err = GtsIdSegment::parse(1, "x.core.events.event.v1.2.extra~").unwrap_err(); + assert!(err.contains("Too many tokens"), "got: {err}"); + } + + #[test] + fn test_segment_too_few_tokens() { + let err = GtsIdSegment::parse(1, "x.core.events.event~").unwrap_err(); + assert!(err.contains("Too few tokens"), "got: {err}"); + } + + #[test] + fn test_segment_too_many_name_tokens() { + let err = GtsIdSegment::parse(2, "x.core.ns.type.extra.v1~").unwrap_err(); + assert!( + err.contains("Too many name tokens before version"), + "got: {err}" + ); + } + + #[test] + fn test_segment_version_without_v() { + let err = GtsIdSegment::parse(1, "x.core.events.event.1~").unwrap_err(); + assert!( + err.contains("Major version must start with 'v'"), + "got: {err}" + ); + } + + #[test] + fn test_segment_version_not_integer() { + let err = GtsIdSegment::parse(1, "x.core.events.event.vX~").unwrap_err(); + assert!( + err.contains("Major version must be an integer"), + "got: {err}" + ); + } + + #[test] + fn test_segment_version_leading_zeros() { + let err = GtsIdSegment::parse(1, "x.core.events.event.v01~").unwrap_err(); + assert!( + err.contains("Major version must be an integer"), + "got: {err}" + ); + } + + #[test] + fn test_segment_invalid_vendor_token() { + let err = GtsIdSegment::parse(1, "1bad.core.events.event.v1~").unwrap_err(); + assert!(err.contains("Invalid vendor token"), "got: {err}"); + } + + #[test] + fn test_concrete_parse_rejects_wildcard() { + // `GtsIdSegment` is concrete only: a `*` is just an invalid token here. + let err = GtsIdSegment::parse(1, "x.*").unwrap_err(); + assert!(err.contains("Too few tokens"), "got: {err}"); + } + + // ---- expected_format (surfaced through segment parsing) ---- + + #[test] + fn test_segment1_format_has_gts_prefix() { + let err = GtsIdSegment::parse(1, "x.core.events.event~").unwrap_err(); + assert!( + err.contains("gts.vendor.package.namespace.type.vMAJOR"), + "segment #1 format should include gts. prefix, got: {err}" + ); + } + + #[test] + fn test_segment2_format_no_gts_prefix() { + let err = GtsIdSegment::parse(2, "x.core.events.event~").unwrap_err(); + assert!( + !err.contains("gts.vendor"), + "segment #2 format should NOT include gts. prefix, got: {err}" + ); + assert!( + err.contains("vendor.package.namespace.type.vMAJOR"), + "segment #2 should show vendor.package format, got: {err}" + ); + } + + // ---- pattern segments / wildcards ---- + + #[test] + fn test_wildcard_at_vendor() { + let parsed = GtsIdPatternSegment::parse(1, "*").unwrap(); + assert!(parsed.is_wildcard()); + } + + #[test] + fn test_wildcard_at_package() { + let parsed = GtsIdPatternSegment::parse(1, "x.*").unwrap(); + assert!(parsed.is_wildcard()); + assert_eq!(parsed.vendor(), "x"); + } + + #[test] + fn test_pattern_concrete_segment_is_not_wildcard() { + let parsed = GtsIdPatternSegment::parse(1, "x.core.events.event.v1~").unwrap(); + assert!(!parsed.is_wildcard()); + assert_eq!(parsed.vendor(), "x"); + assert!(parsed.is_type()); + } + + #[test] + fn test_wildcard_invalid_token_before_star() { + // Tokens before '*' must still be validated + let err = GtsIdPatternSegment::parse(1, "1bad.*").unwrap_err(); + assert!(err.contains("Invalid vendor token"), "got: {err}"); + } + + #[test] + fn test_wildcard_in_middle_rejected() { + // '*' in a non-final position must be rejected + let err = GtsIdPatternSegment::parse(1, "x.*.ns.type.v1").unwrap_err(); + assert!( + err.contains("only allowed as the final token"), + "got: {err}" + ); + } + + #[test] + fn test_wildcard_at_version_position_not_final() { + // '*' at version position (4) with extra token after it must be rejected + let err = GtsIdPatternSegment::parse(1, "x.pkg.ns.type.*.extra").unwrap_err(); + assert!( + err.contains("only allowed as the final token"), + "got: {err}" + ); + } + + #[test] + fn test_glued_version_wildcard() { + // `v*` is the one wildcard form glued to a token: the version marker `v` + // plus `*` means "any version". It parses as a wildcard segment carrying + // the vendor..type prefix and no version. + let parsed = GtsIdPatternSegment::parse(1, "x.pkg.ns.type.v*").unwrap(); + assert!(parsed.is_wildcard()); + assert_eq!(parsed.vendor(), "x"); + assert_eq!(parsed.package(), "pkg"); + assert_eq!(parsed.namespace(), "ns"); + assert_eq!(parsed.type_name(), "type"); + assert_eq!(parsed.ver_major(), 0); + assert_eq!(parsed.ver_minor(), None); + } + + #[test] + fn test_glued_version_wildcard_only_v_star() { + // Only the bare `v*` is the glued form. A partial major like `v1*` is + // not a wildcard — it fails as a malformed version. + let err = GtsIdPatternSegment::parse(1, "x.pkg.ns.type.v1*").unwrap_err(); + assert!( + err.contains("Major version must be an integer"), + "got: {err}" + ); + } + + #[test] + fn test_glued_version_wildcard_rejected_at_minor() { + // `v*` is only the major-version wildcard; at the minor position it is a + // malformed minor, not a wildcard. + let err = GtsIdPatternSegment::parse(1, "x.pkg.ns.type.v1.v*").unwrap_err(); + assert!( + err.contains("Minor version must be an integer"), + "got: {err}" + ); + } + + #[test] + fn test_glued_version_wildcard_rejected_for_concrete() { + // A concrete segment never accepts `v*` — wildcards need `allow_wildcards`. + let err = GtsIdSegment::parse(1, "x.pkg.ns.type.v*").unwrap_err(); + assert!( + err.contains("Major version must be an integer"), + "got: {err}" + ); + } + + // ---- UUID tail ---- + + #[test] + fn test_uuid_tail_segment_accessors() { + const UUID_TAIL: &str = "7a1d2f34-5678-49ab-9012-abcdef123456"; + + let seg = GtsIdSegment::uuid_tail_segment(UUID_TAIL); + assert_eq!(seg.uuid_tail(), Some(UUID_TAIL)); + assert_eq!(seg.raw(), UUID_TAIL); + assert!(!seg.is_type()); + assert_eq!(seg.vendor(), ""); + assert_eq!(seg.ver_major(), 0); + assert_eq!(seg.ver_minor(), None); + + #[cfg(feature = "uuid")] + { + let expected = uuid::Uuid::parse_str(UUID_TAIL).ok(); + assert_eq!(seg.uuid(), expected); + + let GtsIdSegment::UuidTail(tail) = &seg else { + panic!("expected uuid-tail segment"); + }; + assert_eq!(tail.uuid(), expected); + } + } + + #[test] + fn test_concrete_and_wildcard_have_no_uuid_tail() { + let concrete = GtsIdSegment::parse(1, "x.core.events.event.v1~").unwrap(); + assert_eq!(concrete.uuid_tail(), None); + #[cfg(feature = "uuid")] + assert_eq!(concrete.uuid(), None); + + let wildcard = GtsIdPatternSegment::parse(1, "x.*").unwrap(); + assert_eq!(wildcard.uuid_tail(), None); + } + + #[test] + fn test_segment_parts_accessors() { + let concrete = GtsIdSegment::parse(1, "x.core.events.event.v1.2~").unwrap(); + let GtsIdSegment::Concrete(parts) = concrete else { + panic!("expected concrete segment"); + }; + + assert_eq!(parts.raw(), "x.core.events.event.v1.2~"); + assert_eq!(parts.vendor(), "x"); + assert_eq!(parts.package(), "core"); + assert_eq!(parts.namespace(), "events"); + assert_eq!(parts.type_name(), "event"); + assert_eq!(parts.ver_major(), 1); + assert_eq!(parts.ver_minor(), Some(2)); + assert!(parts.is_type()); + } +} diff --git a/gts-id/src/lib.rs b/gts-id/src/lib.rs index 7eafdd5..c6e188a 100644 --- a/gts-id/src/lib.rs +++ b/gts-id/src/lib.rs @@ -1,830 +1,17 @@ -//! Shared GTS ID validation and parsing primitives. +//! Shared GTS ID parsing primitives. //! -//! This crate provides the single source of truth for GTS identifier validation, -//! used by both the `gts` runtime library and the `gts-macros` proc-macro crate. - -use thiserror::Error; - -/// The required prefix for all GTS identifiers. -pub const GTS_PREFIX: &str = "gts."; - -/// Maximum allowed length for a GTS identifier string. -pub const GTS_MAX_LENGTH: usize = 1024; - -/// Errors from GTS ID validation. -#[derive(Debug, Error)] -pub enum GtsIdError { - /// A specific segment within the ID is invalid. - #[error("Segment #{num}: {cause}")] - Segment { - /// 1-based segment number. - num: usize, - /// Byte offset of this segment within the full ID string. - offset: usize, - /// The raw segment string that failed validation. - segment: String, - /// Human-readable description of the problem. - cause: String, - }, - - /// The ID as a whole is invalid (prefix, case, length, etc.). - #[error("Invalid GTS ID: {cause}")] - Id { - /// The raw ID string that failed validation. - id: String, - /// Human-readable description of the problem. - cause: String, - }, -} - -/// Result of successfully parsing a single GTS segment. -#[derive(Debug, Clone, PartialEq, Eq)] -#[allow(clippy::struct_excessive_bools)] -pub struct ParsedSegment { - /// The raw segment string (including trailing `~` if present). - pub raw: String, - /// Byte offset of this segment within the full ID string. - pub offset: usize, - /// Vendor token (1st dot-separated token). - pub vendor: String, - /// Package token (2nd dot-separated token). - pub package: String, - /// Namespace token (3rd dot-separated token). - pub namespace: String, - /// Type name token (4th dot-separated token). - pub type_name: String, - /// Major version number. - pub ver_major: u32, - /// Optional minor version number. - pub ver_minor: Option, - /// Whether this segment ends with `~` (type marker). - pub is_type: bool, - /// Whether this segment contains a wildcard `*` token. - pub is_wildcard: bool, - /// Whether this segment is a UUID tail (combined anonymous instance). - pub is_uuid_tail: bool, -} - -/// Expected format string for segment error messages. -/// -/// Segment #1 shows the `gts.` prefix because the user writes -/// `gts.vendor.package...`; segments #2+ omit it because they -/// come after a `~` delimiter. -#[must_use] -fn expected_format(segment_num: usize) -> &'static str { - if segment_num == 1 { - "gts.vendor.package.namespace.type.vMAJOR[.MINOR]" - } else { - "vendor.package.namespace.type.vMAJOR[.MINOR]" - } -} - -/// Checks whether a string matches the UUID format -/// `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` (hex digits and dashes only). -#[inline] -#[must_use] -pub fn is_uuid(s: &str) -> bool { - s.len() == 36 - && s.char_indices().all(|(i, c)| match i { - 8 | 13 | 18 | 23 => c == '-', - _ => c.is_ascii_hexdigit(), - }) -} - -/// Validates a GTS segment token without regex. -/// -/// Valid tokens: start with `[a-z_]`, followed by `[a-z0-9_]*`. -#[inline] -#[must_use] -pub fn is_valid_segment_token(token: &str) -> bool { - if token.is_empty() { - return false; - } - let mut chars = token.chars(); - match chars.next() { - Some(c) if c.is_ascii_lowercase() || c == '_' => {} - _ => return false, - } - chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') -} - -/// Parse a `u32` and reject leading zeros (except `"0"` itself). -#[inline] -#[must_use] -pub fn parse_u32_exact(value: &str) -> Option { - let parsed = value.parse::().ok()?; - if parsed.to_string() == value { - Some(parsed) - } else { - None - } -} - -/// Validate and parse a single GTS segment (the part between `~` markers). -/// -/// # Arguments -/// * `segment_num` - 1-based segment number (used in error messages and format hints) -/// * `segment` - The raw segment string, possibly including a trailing `~` -/// * `allow_wildcards` - If `true`, a trailing wildcard `*` token is accepted as the final token -/// -/// # Errors -/// Returns a human-readable error message if the segment is invalid. -pub fn validate_segment( - segment_num: usize, - segment: &str, - allow_wildcards: bool, -) -> Result { - let mut seg = segment.to_owned(); - let mut is_type = false; - - // Check for type marker (~) - if seg.contains('~') { - let tilde_count = seg.matches('~').count(); - if tilde_count > 1 { - return Err("Too many '~' characters".to_owned()); - } - if seg.ends_with('~') { - is_type = true; - seg.pop(); - } else { - return Err("'~' must be at the end".to_owned()); - } - } - - let tokens: Vec<&str> = seg.split('.').collect(); - let fmt = expected_format(segment_num); - - if tokens.len() > 6 { - return Err(format!( - "Too many tokens (got {}, max 6). Expected format: {fmt}", - tokens.len() - )); - } - - let ends_with_wildcard = allow_wildcards && seg.ends_with('*'); - - if !ends_with_wildcard && tokens.len() < 5 { - return Err(format!( - "Too few tokens (got {}, min 5). Expected format: {fmt}", - tokens.len() - )); - } - - // Detect extra name token before version (e.g., vendor.package.namespace.type.extra.v1) - if !ends_with_wildcard && tokens.len() == 6 { - let has_wildcard = allow_wildcards && tokens.contains(&"*"); - if !has_wildcard - && !tokens[4].starts_with('v') - && tokens[5].starts_with('v') - && is_valid_segment_token(tokens[4]) - { - return Err(format!( - "Too many name tokens before version (got 5, expected 4). Expected format: {fmt}" - )); - } - } - - // Validate first 4 tokens (vendor, package, namespace, type). - // A trailing '*' wildcard is allowed as the final token, but all tokens - // before it must still pass validation. Wildcards in the middle - // (e.g., "x.*.ns.type.v1") are rejected because '*' fails is_valid_segment_token. - for (i, token) in tokens.iter().take(4).enumerate() { - if allow_wildcards && *token == "*" { - if i == tokens.len() - 1 { - break; // '*' as final token is handled in the parsing section below - } - return Err("Wildcard '*' is only allowed as the final token".to_owned()); - } - if !is_valid_segment_token(token) { - let token_name = match i { - 0 => "vendor", - 1 => "package", - 2 => "namespace", - 3 => "type", - _ => "token", - }; - return Err(format!( - "Invalid {token_name} token '{token}'. \ - Must start with [a-z_] and contain only [a-z0-9_]" - )); - } - } - - // Build the result, parsing tokens progressively. - // Offset is set to 0 here; callers like validate_gts_id() override it - // with the actual position within the full ID string. - let mut result = ParsedSegment { - raw: segment.to_owned(), - offset: 0, - vendor: String::new(), - package: String::new(), - namespace: String::new(), - type_name: String::new(), - ver_major: 0, - ver_minor: None, - is_type, - is_wildcard: false, - is_uuid_tail: false, - }; - - if !tokens.is_empty() { - if allow_wildcards && tokens[0] == "*" { - result.is_wildcard = true; - return Ok(result); - } - tokens[0].clone_into(&mut result.vendor); - } - - if tokens.len() > 1 { - if allow_wildcards && tokens[1] == "*" { - result.is_wildcard = true; - return Ok(result); - } - tokens[1].clone_into(&mut result.package); - } - - if tokens.len() > 2 { - if allow_wildcards && tokens[2] == "*" { - result.is_wildcard = true; - return Ok(result); - } - tokens[2].clone_into(&mut result.namespace); - } - - if tokens.len() > 3 { - if allow_wildcards && tokens[3] == "*" { - result.is_wildcard = true; - return Ok(result); - } - tokens[3].clone_into(&mut result.type_name); - } - - if tokens.len() > 4 { - if allow_wildcards && tokens[4] == "*" { - if 4 != tokens.len() - 1 { - return Err("Wildcard '*' is only allowed as the final token".to_owned()); - } - result.is_wildcard = true; - return Ok(result); - } - - if !tokens[4].starts_with('v') { - return Err("Major version must start with 'v'".to_owned()); - } - - let major_str = &tokens[4][1..]; - result.ver_major = parse_u32_exact(major_str) - .ok_or_else(|| format!("Major version must be an integer, got '{major_str}'"))?; - } - - if tokens.len() > 5 { - if allow_wildcards && tokens[5] == "*" { - result.is_wildcard = true; - return Ok(result); - } - - result.ver_minor = Some( - parse_u32_exact(tokens[5]) - .ok_or_else(|| format!("Minor version must be an integer, got '{}'", tokens[5]))?, - ); - } - - Ok(result) -} - -/// Validate a full GTS identifier string. -/// -/// Checks the `gts.` prefix, lowercase, length, then splits by `~` and -/// validates each segment via [`validate_segment`]. Hyphens are rejected -/// in the GTS segments portion but permitted in a trailing UUID -/// (combined anonymous instance, e.g. `gts.type.v1~schema.v1.0~`). -/// -/// # Arguments -/// * `id` - The raw GTS identifier string -/// * `allow_wildcards` - If `true`, wildcard `*` tokens are accepted -/// -/// # Errors -/// Returns [`GtsIdError`] on validation failure. -pub fn validate_gts_id(id: &str, allow_wildcards: bool) -> Result, GtsIdError> { - let raw = id.trim(); - - if !raw.starts_with(GTS_PREFIX) { - return Err(GtsIdError::Id { - id: id.to_owned(), - cause: format!("must start with '{GTS_PREFIX}'"), - }); - } - - if raw != raw.to_lowercase() { - return Err(GtsIdError::Id { - id: id.to_owned(), - cause: "must be lowercase".to_owned(), - }); - } - - if raw.len() > GTS_MAX_LENGTH { - return Err(GtsIdError::Id { - id: id.to_owned(), - cause: format!("too long ({} chars, max {GTS_MAX_LENGTH})", raw.len()), - }); - } - - let remainder = &raw[GTS_PREFIX.len()..]; - let tilde_parts: Vec<&str> = remainder.split('~').collect(); - - // Detect combined anonymous instance: last tilde-part is a UUID. - // e.g. "gts.type.v1~schema.v1.0~7a1d2f34-5678-49ab-9012-abcdef123456" - // The UUID tail is only valid when preceded by at least one type segment (ending with ~). - let uuid_tail: Option<&str> = { - let last = tilde_parts.last().copied().unwrap_or(""); - if is_uuid(last) && tilde_parts.len() >= 2 { - Some(last) - } else { - None - } - }; - - // Reject hyphens in the GTS segments portion (hyphens are only allowed in the UUID tail). - let segments_portion = match uuid_tail { - Some(uuid) => &raw[..raw.len() - uuid.len() - 1], // strip "~" - None => raw, - }; - if segments_portion.contains('-') { - return Err(GtsIdError::Id { - id: id.to_owned(), - cause: "must not contain '-'".to_owned(), - }); - } - - // Build the list of raw segment strings, excluding the UUID tail. - // When a UUID tail is present, every preceding tilde-part was followed by '~' - // in the original string, so each is a type segment — append '~' to all of them. - // Otherwise use the standard reconstruction (last part may or may not have '~'). - let seg_count = tilde_parts.len() - usize::from(uuid_tail.is_some()); - let mut segments_raw: Vec = Vec::new(); - for (i, &part) in tilde_parts.iter().enumerate().take(seg_count) { - let is_last = i == seg_count - 1; - if part.is_empty() { - // The only allowed empty part is the single trailing one produced by a - // type-marker `~` at the end (e.g. "gts.v.p.n.t.v1~"). Any other empty - // part means consecutive tildes (e.g. "~~") or a leading tilde, which - // are invalid. - if !(is_last && uuid_tail.is_none()) { - return Err(GtsIdError::Id { - id: id.to_owned(), - cause: format!("empty segment at tilde-part #{}", i + 1), - }); - } - } else if is_last && uuid_tail.is_none() { - segments_raw.push(part.to_owned()); - } else { - segments_raw.push(format!("{part}~")); - } - } - - if segments_raw.is_empty() { - return Err(GtsIdError::Id { - id: id.to_owned(), - cause: "no segments found".to_owned(), - }); - } - - let mut parsed_segments = Vec::new(); - let mut offset = GTS_PREFIX.len(); - for (i, seg) in segments_raw.iter().enumerate() { - if seg.is_empty() || seg == "~" { - return Err(GtsIdError::Id { - id: id.to_owned(), - cause: format!("segment #{} @ offset {offset} is empty", i + 1), - }); - } - - let mut parsed = - validate_segment(i + 1, seg, allow_wildcards).map_err(|cause| GtsIdError::Segment { - num: i + 1, - offset, - segment: seg.clone(), - cause, - })?; - parsed.offset = offset; - offset += seg.len(); - parsed_segments.push(parsed); - } - - // Append the UUID tail as a special ParsedSegment if present. - // All preceding segments are guaranteed to be type segments because we - // appended '~' to every gts_part in the uuid_tail branch above. - if let Some(uuid) = uuid_tail { - parsed_segments.push(ParsedSegment { - raw: uuid.to_owned(), - offset, - vendor: String::new(), - package: String::new(), - namespace: String::new(), - type_name: String::new(), - ver_major: 0, - ver_minor: None, - is_type: false, - is_wildcard: false, - is_uuid_tail: true, - }); - } - - Ok(parsed_segments) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - - // ---- is_valid_segment_token ---- - - #[test] - fn test_valid_tokens() { - assert!(is_valid_segment_token("abc")); - assert!(is_valid_segment_token("a1b2")); - assert!(is_valid_segment_token("_private")); - assert!(is_valid_segment_token("a_b_c")); - } - - #[test] - fn test_invalid_tokens() { - assert!(!is_valid_segment_token("")); - assert!(!is_valid_segment_token("1abc")); - assert!(!is_valid_segment_token("ABC")); - assert!(!is_valid_segment_token("a-b")); - assert!(!is_valid_segment_token("a.b")); - } - - // ---- parse_u32_exact ---- - - #[test] - fn test_parse_u32_exact_valid() { - assert_eq!(parse_u32_exact("0"), Some(0)); - assert_eq!(parse_u32_exact("1"), Some(1)); - assert_eq!(parse_u32_exact("42"), Some(42)); - } - - #[test] - fn test_parse_u32_exact_rejects_leading_zeros() { - assert_eq!(parse_u32_exact("01"), None); - assert_eq!(parse_u32_exact("007"), None); - } - - #[test] - fn test_parse_u32_exact_rejects_non_numeric() { - assert_eq!(parse_u32_exact("abc"), None); - assert_eq!(parse_u32_exact(""), None); - } - - // ---- validate_segment ---- - - #[test] - fn test_valid_segment_basic() { - let parsed = validate_segment(1, "x.core.events.event.v1~", false).unwrap(); - assert_eq!(parsed.vendor, "x"); - assert_eq!(parsed.package, "core"); - assert_eq!(parsed.namespace, "events"); - assert_eq!(parsed.type_name, "event"); - assert_eq!(parsed.ver_major, 1); - assert_eq!(parsed.ver_minor, None); - assert!(parsed.is_type); - assert!(!parsed.is_wildcard); - } - - #[test] - fn test_valid_segment_with_minor() { - let parsed = validate_segment(1, "x.core.events.event.v1.2~", false).unwrap(); - assert_eq!(parsed.ver_major, 1); - assert_eq!(parsed.ver_minor, Some(2)); - } - - #[test] - fn test_segment_too_many_tildes() { - let err = validate_segment(1, "x.core.events.event.v1~~", false).unwrap_err(); - assert!(err.contains("Too many '~' characters"), "got: {err}"); - } - - #[test] - fn test_segment_tilde_not_at_end() { - let err = validate_segment(1, "x.core~mid.events.event.v1", false).unwrap_err(); - assert!(err.contains("'~' must be at the end"), "got: {err}"); - } - - #[test] - fn test_segment_too_many_tokens() { - let err = validate_segment(1, "x.core.events.event.v1.2.extra~", false).unwrap_err(); - assert!(err.contains("Too many tokens"), "got: {err}"); - } - - #[test] - fn test_segment_too_few_tokens() { - let err = validate_segment(1, "x.core.events.event~", false).unwrap_err(); - assert!(err.contains("Too few tokens"), "got: {err}"); - } - - #[test] - fn test_segment_too_many_name_tokens() { - let err = validate_segment(2, "x.core.ns.type.extra.v1~", false).unwrap_err(); - assert!( - err.contains("Too many name tokens before version"), - "got: {err}" - ); - } - - #[test] - fn test_segment_version_without_v() { - let err = validate_segment(1, "x.core.events.event.1~", false).unwrap_err(); - assert!( - err.contains("Major version must start with 'v'"), - "got: {err}" - ); - } - - #[test] - fn test_segment_version_not_integer() { - let err = validate_segment(1, "x.core.events.event.vX~", false).unwrap_err(); - assert!( - err.contains("Major version must be an integer"), - "got: {err}" - ); - } - - #[test] - fn test_segment_version_leading_zeros() { - let err = validate_segment(1, "x.core.events.event.v01~", false).unwrap_err(); - assert!( - err.contains("Major version must be an integer"), - "got: {err}" - ); - } - - #[test] - fn test_segment_invalid_vendor_token() { - let err = validate_segment(1, "1bad.core.events.event.v1~", false).unwrap_err(); - assert!(err.contains("Invalid vendor token"), "got: {err}"); - } - - // ---- expected_format ---- - - #[test] - fn test_segment1_format_has_gts_prefix() { - let err = validate_segment(1, "x.core.events.event~", false).unwrap_err(); - assert!( - err.contains("gts.vendor.package.namespace.type.vMAJOR"), - "segment #1 format should include gts. prefix, got: {err}" - ); - } - - #[test] - fn test_segment2_format_no_gts_prefix() { - let err = validate_segment(2, "x.core.events.event~", false).unwrap_err(); - assert!( - !err.contains("gts.vendor"), - "segment #2 format should NOT include gts. prefix, got: {err}" - ); - assert!( - err.contains("vendor.package.namespace.type.vMAJOR"), - "segment #2 should show vendor.package format, got: {err}" - ); - } - - // ---- wildcards ---- - - #[test] - fn test_wildcard_at_vendor() { - let parsed = validate_segment(1, "*", true).unwrap(); - assert!(parsed.is_wildcard); - } - - #[test] - fn test_wildcard_at_package() { - let parsed = validate_segment(1, "x.*", true).unwrap(); - assert!(parsed.is_wildcard); - assert_eq!(parsed.vendor, "x"); - } - - #[test] - fn test_wildcard_invalid_token_before_star() { - // Tokens before '*' must still be validated - let err = validate_segment(1, "1bad.*", true).unwrap_err(); - assert!(err.contains("Invalid vendor token"), "got: {err}"); - } - - #[test] - fn test_wildcard_in_middle_rejected() { - // '*' in a non-final position must be rejected - let err = validate_segment(1, "x.*.ns.type.v1", true).unwrap_err(); - assert!( - err.contains("only allowed as the final token"), - "got: {err}" - ); - } - - #[test] - fn test_wildcard_at_version_position_not_final() { - // '*' at version position (4) with extra token after it must be rejected - let err = validate_segment(1, "x.pkg.ns.type.*.extra", true).unwrap_err(); - assert!( - err.contains("only allowed as the final token"), - "got: {err}" - ); - } - - #[test] - fn test_wildcard_rejected_without_flag() { - let err = validate_segment(1, "x.*", false).unwrap_err(); - assert!(err.contains("Too few tokens"), "got: {err}"); - } - - // ---- validate_gts_id ---- - - #[test] - fn test_valid_gts_id() { - let segments = validate_gts_id("gts.x.core.events.event.v1~", false).unwrap(); - assert_eq!(segments.len(), 1); - assert_eq!(segments[0].vendor, "x"); - assert!(segments[0].is_type); - } - - #[test] - fn test_valid_gts_id_chained() { - let segments = validate_gts_id( - "gts.x.core.events.type.v1~vendor.app._.custom_event.v1~", - false, - ) - .unwrap(); - assert_eq!(segments.len(), 2); - assert_eq!(segments[0].vendor, "x"); - assert_eq!(segments[1].vendor, "vendor"); - } - - #[test] - fn test_gts_id_missing_prefix() { - let err = validate_gts_id("x.core.events.event.v1~", false).unwrap_err(); - match err { - GtsIdError::Id { cause, .. } => { - assert!(cause.contains("must start with 'gts.'"), "got: {cause}"); - } - GtsIdError::Segment { .. } => panic!("expected Id error, got: {err}"), - } - } - - #[test] - fn test_gts_id_uppercase() { - let err = validate_gts_id("gts.X.core.events.event.v1~", false).unwrap_err(); - match err { - GtsIdError::Id { cause, .. } => { - assert!(cause.contains("lowercase"), "got: {cause}"); - } - GtsIdError::Segment { .. } => panic!("expected Id error, got: {err}"), - } - } - - #[test] - fn test_gts_id_hyphen() { - let err = validate_gts_id("gts.x-vendor.core.events.event.v1~", false).unwrap_err(); - match err { - GtsIdError::Id { cause, .. } => { - assert!(cause.contains("'-'"), "got: {cause}"); - } - GtsIdError::Segment { .. } => panic!("expected Id error, got: {err}"), - } - } - - #[test] - fn test_gts_id_segment_error_carries_num_and_offset() { - let err = validate_gts_id( - "gts.x.core.modkit.plugin.v1~x.core.license_enforcer.integration.plugin.v1~", - false, - ) - .unwrap_err(); - match err { - GtsIdError::Segment { - num, offset, cause, .. - } => { - assert_eq!(num, 2); - // offset = "gts.".len() + "x.core.modkit.plugin.v1~".len() = 4 + 24 = 28 - assert_eq!(offset, 28); - assert!( - cause.contains("Too many name tokens before version"), - "got: {cause}" - ); - } - GtsIdError::Id { .. } => panic!("expected Segment error, got: {err}"), - } - } - - #[test] - fn test_gts_id_instance_no_tilde_end() { - let segments = validate_gts_id("gts.x.core.events.event.v1~a.b.c.d.v1.0", false).unwrap(); - assert_eq!(segments.len(), 2); - assert!(segments[0].is_type); - assert!(!segments[1].is_type); - } - - #[test] - fn test_gts_id_double_tilde_rejected() { - let err = validate_gts_id("gts.x.test1.events.type.v1.0~~", false).unwrap_err(); - match err { - GtsIdError::Id { cause, .. } => { - assert!(cause.contains("empty segment"), "got: {cause}"); - } - GtsIdError::Segment { .. } => panic!("expected Id error, got: {err}"), - } - } - - #[test] - fn test_gts_id_whitespace_trimmed() { - let segments = validate_gts_id(" gts.x.core.events.event.v1~ ", false).unwrap(); - assert_eq!(segments.len(), 1); - } - - // ---- is_uuid ---- - - #[test] - fn test_is_uuid_valid() { - assert!(is_uuid("7a1d2f34-5678-49ab-9012-abcdef123456")); - assert!(is_uuid("00000000-0000-0000-0000-000000000000")); - assert!(is_uuid("ffffffff-ffff-ffff-ffff-ffffffffffff")); - } - - #[test] - fn test_is_uuid_invalid() { - assert!(!is_uuid("not-a-uuid")); - assert!(!is_uuid("7a1d2f34-5678-49ab-9012-abcdef12345")); // too short - assert!(!is_uuid("7a1d2f34-5678-49ab-9012-abcdef1234567")); // too long - assert!(!is_uuid("7a1d2f34-5678-49ab-9012-abcdef12345g")); // non-hex char - assert!(!is_uuid("7a1d2f3405678-49ab-9012-abcdef123456")); // dash in wrong place - } - - // ---- combined anonymous instance ---- - - #[test] - fn test_combined_anonymous_instance_valid() { - let segments = validate_gts_id( - "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~7a1d2f34-5678-49ab-9012-abcdef123456", - false, - ) - .unwrap(); - assert_eq!(segments.len(), 3); - assert!(segments[0].is_type); - assert!(segments[1].is_type); - assert!(segments[2].is_uuid_tail); - assert!(!segments[2].is_type); - assert_eq!(segments[2].raw, "7a1d2f34-5678-49ab-9012-abcdef123456"); - } - - #[test] - fn test_combined_anonymous_instance_single_prefix_valid() { - let segments = validate_gts_id( - "gts.x.core.events.type.v1~7a1d2f34-5678-49ab-9012-abcdef123456", - false, - ) - .unwrap(); - assert_eq!(segments.len(), 2); - assert!(segments[0].is_type); - assert!(segments[1].is_uuid_tail); - } - - #[test] - fn test_combined_anonymous_instance_hyphen_in_segments_rejected() { - let err = validate_gts_id( - "gts.x-vendor.core.events.type.v1~x.commerce.orders.order_placed.v1.0~7a1d2f34-5678-49ab-9012-abcdef123456", - false, - ) - .unwrap_err(); - match err { - GtsIdError::Id { cause, .. } => { - assert!(cause.contains("'-'"), "got: {cause}"); - } - GtsIdError::Segment { .. } => panic!("expected Id error, got: {err}"), - } - } - - #[test] - fn test_uuid_alone_without_prefix_rejected() { - // A bare UUID with no GTS prefix is not a valid GTS ID - let err = validate_gts_id("7a1d2f34-5678-49ab-9012-abcdef123456", false).unwrap_err(); - match err { - GtsIdError::Id { cause, .. } => { - assert!(cause.contains("must start with 'gts.'"), "got: {cause}"); - } - GtsIdError::Segment { .. } => panic!("expected Id error, got: {err}"), - } - } - - #[test] - fn test_uuid_tail_without_preceding_tilde_rejected() { - // UUID as the only segment (no preceding ~) must be rejected - // "gts." + UUID has no tilde_parts.len() >= 2 - let err = validate_gts_id("gts.7a1d2f34-5678-49ab-9012-abcdef123456", false).unwrap_err(); - match err { - GtsIdError::Id { cause, .. } => { - assert!(cause.contains("'-'"), "got: {cause}"); - } - GtsIdError::Segment { .. } => panic!("expected Id error, got: {err}"), - } - } -} +//! This crate provides the single source of truth for GTS identifier parsing +//! and validation, used by both the `gts` runtime library and the `gts-macros` +//! proc-macro crate. + +mod error; +mod gts_id; +mod gts_id_pattern; +mod gts_id_segment; +pub(crate) mod parse; + +pub use error::{GtsIdError, GtsIdSegmentError}; +pub use gts_id::GtsId; +pub use gts_id_pattern::GtsIdPattern; +pub use gts_id_segment::{GtsIdPatternSegment, GtsIdSegment, GtsIdSegmentParts, GtsUuidTail}; +pub use parse::{GTS_MAX_LENGTH, GTS_PREFIX}; diff --git a/gts-id/src/parse.rs b/gts-id/src/parse.rs new file mode 100644 index 0000000..a09b0a1 --- /dev/null +++ b/gts-id/src/parse.rs @@ -0,0 +1,518 @@ +//! Low-level parsing of GTS identifier strings into structured segments. +//! +//! Following the "parse, don't validate" principle, these functions don't just +//! check validity — they produce a structured [`GtsIdSegment`] (or a `Vec` of +//! them) from the raw string. Callers that only care about validity simply +//! inspect the `Result` and discard the parsed value. + +use crate::{GtsIdError, GtsIdPatternSegment, GtsIdSegment}; + +/// The required prefix for all GTS identifiers. +pub const GTS_PREFIX: &str = "gts."; + +/// Maximum allowed length for a GTS identifier string. +pub const GTS_MAX_LENGTH: usize = 1024; + +/// Expected format string for segment error messages. +/// +/// Segment #1 shows the `gts.` prefix because the user writes +/// `gts.vendor.package...`; segments #2+ omit it because they +/// come after a `~` delimiter. +#[must_use] +pub fn expected_format(segment_num: usize) -> &'static str { + if segment_num == 1 { + "gts.vendor.package.namespace.type.vMAJOR[.MINOR]" + } else { + "vendor.package.namespace.type.vMAJOR[.MINOR]" + } +} + +/// Checks whether a string matches the UUID format +/// `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` (hex digits and dashes only). +#[inline] +#[must_use] +pub fn is_uuid(s: &str) -> bool { + s.len() == 36 + && s.char_indices().all(|(i, c)| match i { + 8 | 13 | 18 | 23 => c == '-', + _ => c.is_ascii_hexdigit(), + }) +} + +/// Validates a GTS segment token without regex. +/// +/// Valid tokens: start with `[a-z_]`, followed by `[a-z0-9_]*`. +#[inline] +#[must_use] +pub fn is_valid_segment_token(token: &str) -> bool { + if token.is_empty() { + return false; + } + let mut chars = token.chars(); + match chars.next() { + Some(c) if c.is_ascii_lowercase() || c == '_' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') +} + +/// Parse a `u32` and reject leading zeros (except `"0"` itself). +#[inline] +#[must_use] +pub fn parse_u32_exact(value: &str) -> Option { + let parsed = value.parse::().ok()?; + if parsed.to_string() == value { + Some(parsed) + } else { + None + } +} + +/// Validate the shared GTS string structure and split it into raw segment +/// strings plus an optional trailing UUID. +/// +/// This is the shared structural pass behind [`parse_id`] and [`parse_pattern`]; +/// per-segment parsing and the issue #37 single-segment rule live in those. +/// +/// # Errors +/// Returns [`GtsIdError`] on structural failure. +fn split_raw_segments( + id: &str, + allow_wildcards: bool, +) -> Result<(Vec, Option), GtsIdError> { + if !id.starts_with(GTS_PREFIX) { + return Err(GtsIdError::new( + id, + format!("must start with '{GTS_PREFIX}'"), + )); + } + + if id != id.to_lowercase() { + return Err(GtsIdError::new(id, "must be lowercase")); + } + + if id.len() > GTS_MAX_LENGTH { + return Err(GtsIdError::new( + id, + format!("too long ({} chars, max {GTS_MAX_LENGTH})", id.len()), + )); + } + + if allow_wildcards { + let wildcards_num = id.matches('*').count(); + if wildcards_num > 1 { + return Err(GtsIdError::new( + id, + "The wildcard '*' token is allowed only once", + )); + } + if wildcards_num > 0 && !id.ends_with('*') { + return Err(GtsIdError::new( + id, + "The wildcard '*' token is allowed only at the end of the pattern", + )); + } + } + + let remainder = &id[GTS_PREFIX.len()..]; + let tilde_parts: Vec<&str> = remainder.split('~').collect(); + + // Detect combined anonymous instance: last tilde-part is a UUID. + let uuid_tail: Option<&str> = { + let last = tilde_parts.last().copied().unwrap_or(""); + if is_uuid(last) && tilde_parts.len() >= 2 { + Some(last) + } else { + None + } + }; + + // Reject hyphens in the GTS segments portion (hyphens are only allowed in the UUID tail). + let segments_portion = match uuid_tail { + Some(uuid) => &id[..id.len() - uuid.len() - 1], // strip "~" + None => id, + }; + if segments_portion.contains('-') { + return Err(GtsIdError::new(id, "must not contain '-'")); + } + + // Build the list of raw segment strings, excluding the UUID tail. + // When a UUID tail is present, every preceding tilde-part was followed by '~' + // in the original string, so each is a type segment — append '~' to all of them. + // Otherwise use the standard reconstruction (last part may or may not have '~'). + let seg_count = tilde_parts.len() - usize::from(uuid_tail.is_some()); + let mut segments_raw: Vec = Vec::new(); + for (i, &part) in tilde_parts.iter().enumerate().take(seg_count) { + let is_last = i == seg_count - 1; + if part.is_empty() { + // The only allowed empty part is the single trailing one produced by a + // type-marker `~` at the end (e.g. "gts.v.p.n.t.v1~"). Any other empty + // part means consecutive tildes (e.g. "~~") or a leading tilde, which + // are invalid. + if !(is_last && uuid_tail.is_none()) { + return Err(GtsIdError::new( + id, + format!("empty segment at tilde-part #{}", i + 1), + )); + } + } else if is_last && uuid_tail.is_none() { + segments_raw.push(part.to_owned()); + } else { + segments_raw.push(format!("{part}~")); + } + } + + if segments_raw.is_empty() { + return Err(GtsIdError::new(id, "no segments found")); + } + + Ok((segments_raw, uuid_tail.map(str::to_owned))) +} + +/// Error returned when a single-segment instance id is encountered (issue #37). +fn single_segment_instance_error(id: &str) -> GtsIdError { + GtsIdError::new( + id, + "Single-segment instance IDs are prohibited. Instance IDs must be chained with at least one type segment (e.g., 'type~instance')", + ) +} + +/// Parse a **concrete** GTS identifier into its [`GtsIdSegment`]s. +/// +/// Wildcards are rejected (a `*` is an invalid segment token). Also enforces +/// issue #37: a single-segment instance id is prohibited (an instance must be +/// chained with at least one type segment); a trailing UUID tail is exempt. +/// +/// This backs [`GtsId::try_new`](crate::GtsId::try_new). On failure the returned +/// [`GtsIdError`] carries the 1-based number and byte offset of the offending +/// segment within `id`. +/// +/// # Errors +/// Returns [`GtsIdError`] on parse failure or invariant violation. +pub fn parse_id(id: &str) -> Result, GtsIdError> { + let (segments_raw, uuid_tail) = split_raw_segments(id, false)?; + + let mut segments = Vec::new(); + let mut offset = GTS_PREFIX.len(); + for (i, seg) in segments_raw.iter().enumerate() { + let parsed = GtsIdSegment::parse(i + 1, seg) + .map_err(|cause| GtsIdError::new(id, cause).with_segment(i + 1, offset, seg.clone()))?; + offset += seg.len(); + segments.push(parsed); + } + + if let Some(ref uuid) = uuid_tail { + segments.push(GtsIdSegment::uuid_tail_segment(uuid)); + } + + // Issue #37: a concrete id has no wildcard exemption. + if uuid_tail.is_none() && segments.len() == 1 && !segments[0].is_type() { + return Err(single_segment_instance_error(id)); + } + + Ok(segments) +} + +/// Parse a GTS identifier **pattern** into its [`GtsIdPatternSegment`]s. +/// +/// A trailing `*` wildcard is accepted. Also enforces issue #37, with wildcard +/// segments (and UUID tails) exempt from the single-segment rule. +/// +/// This backs [`GtsIdPattern::try_new`](crate::GtsIdPattern::try_new). On failure +/// the returned [`GtsIdError`] carries the 1-based number and byte offset of the +/// offending segment within `id`. +/// +/// # Errors +/// Returns [`GtsIdError`] on parse failure or invariant violation. +pub fn parse_pattern(id: &str) -> Result, GtsIdError> { + let (segments_raw, uuid_tail) = split_raw_segments(id, true)?; + + let mut segments = Vec::new(); + let mut offset = GTS_PREFIX.len(); + for (i, seg) in segments_raw.iter().enumerate() { + let parsed = GtsIdPatternSegment::parse(i + 1, seg) + .map_err(|cause| GtsIdError::new(id, cause).with_segment(i + 1, offset, seg.clone()))?; + offset += seg.len(); + segments.push(parsed); + } + + if let Some(ref uuid) = uuid_tail { + segments.push(GtsIdPatternSegment::uuid_tail_segment(uuid)); + } + + // Issue #37: wildcard segments are exempt. + if uuid_tail.is_none() + && segments.len() == 1 + && !segments[0].is_type() + && !segments[0].is_wildcard() + { + return Err(single_segment_instance_error(id)); + } + + Ok(segments) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + // ---- is_valid_segment_token ---- + + #[test] + fn test_valid_tokens() { + assert!(is_valid_segment_token("abc")); + assert!(is_valid_segment_token("a1b2")); + assert!(is_valid_segment_token("_private")); + assert!(is_valid_segment_token("a_b_c")); + } + + #[test] + fn test_invalid_tokens() { + assert!(!is_valid_segment_token("")); + assert!(!is_valid_segment_token("1abc")); + assert!(!is_valid_segment_token("ABC")); + assert!(!is_valid_segment_token("a-b")); + assert!(!is_valid_segment_token("a.b")); + } + + // ---- parse_u32_exact ---- + + #[test] + fn test_parse_u32_exact_valid() { + assert_eq!(parse_u32_exact("0"), Some(0)); + assert_eq!(parse_u32_exact("1"), Some(1)); + assert_eq!(parse_u32_exact("42"), Some(42)); + } + + #[test] + fn test_parse_u32_exact_rejects_leading_zeros() { + assert_eq!(parse_u32_exact("01"), None); + assert_eq!(parse_u32_exact("007"), None); + } + + #[test] + fn test_parse_u32_exact_rejects_non_numeric() { + assert_eq!(parse_u32_exact("abc"), None); + assert_eq!(parse_u32_exact(""), None); + } + + // ---- parse_id ---- + + #[test] + fn test_valid_gts_id() { + let segments = parse_id("gts.x.core.events.event.v1~").unwrap(); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].vendor(), "x"); + assert!(segments[0].is_type()); + } + + #[test] + fn test_valid_gts_id_chained() { + let segments = parse_id("gts.x.core.events.type.v1~vendor.app._.custom_event.v1~").unwrap(); + assert_eq!(segments.len(), 2); + assert_eq!(segments[0].vendor(), "x"); + assert_eq!(segments[1].vendor(), "vendor"); + } + + #[test] + fn test_gts_id_missing_prefix() { + let err = parse_id("x.core.events.event.v1~").unwrap_err(); + assert!(err.segment.is_none(), "expected id-level error, got: {err}"); + assert!(err.cause.contains("must start with 'gts.'"), "got: {err}"); + } + + #[test] + fn test_gts_id_uppercase() { + let err = parse_id("gts.X.core.events.event.v1~").unwrap_err(); + assert!(err.segment.is_none(), "expected id-level error, got: {err}"); + assert!(err.cause.contains("lowercase"), "got: {err}"); + } + + #[test] + fn test_gts_id_hyphen() { + let err = parse_id("gts.x-vendor.core.events.event.v1~").unwrap_err(); + assert!(err.segment.is_none(), "expected id-level error, got: {err}"); + assert!(err.cause.contains("'-'"), "got: {err}"); + } + + #[test] + fn test_gts_id_segment_error_carries_num_and_offset() { + let err = + parse_id("gts.x.core.modkit.plugin.v1~x.core.license_enforcer.integration.plugin.v1~") + .unwrap_err(); + let seg = err.segment.as_ref().expect("expected segment-level error"); + assert_eq!(seg.num, 2); + // offset = "gts.".len() + "x.core.modkit.plugin.v1~".len() = 4 + 24 = 28 + assert_eq!(seg.offset, 28); + assert!( + err.cause.contains("Too many name tokens before version"), + "got: {err}" + ); + } + + #[test] + fn test_gts_id_instance_no_tilde_end() { + let segments = parse_id("gts.x.core.events.event.v1~a.b.c.d.v1.0").unwrap(); + assert_eq!(segments.len(), 2); + assert!(segments[0].is_type()); + assert!(!segments[1].is_type()); + } + + #[test] + fn test_gts_id_double_tilde_rejected() { + let err = parse_id("gts.x.test1.events.type.v1.0~~").unwrap_err(); + assert!(err.segment.is_none(), "expected id-level error, got: {err}"); + assert!(err.cause.contains("empty segment"), "got: {err}"); + } + + #[test] + fn test_gts_id_parser_expects_trimmed_input() { + let err = parse_id(" gts.x.core.events.event.v1~ ").unwrap_err(); + assert!(err.cause.contains("must start with 'gts.'"), "got: {err}"); + } + + #[test] + fn test_gts_id_trimmed_input() { + let segments = parse_id("gts.x.core.events.event.v1~").unwrap(); + assert_eq!(segments.len(), 1); + } + + // ---- is_uuid ---- + + #[test] + fn test_is_uuid_valid() { + assert!(is_uuid("7a1d2f34-5678-49ab-9012-abcdef123456")); + assert!(is_uuid("00000000-0000-0000-0000-000000000000")); + assert!(is_uuid("ffffffff-ffff-ffff-ffff-ffffffffffff")); + } + + #[test] + fn test_is_uuid_invalid() { + assert!(!is_uuid("not-a-uuid")); + assert!(!is_uuid("7a1d2f34-5678-49ab-9012-abcdef12345")); // too short + assert!(!is_uuid("7a1d2f34-5678-49ab-9012-abcdef1234567")); // too long + assert!(!is_uuid("7a1d2f34-5678-49ab-9012-abcdef12345g")); // non-hex char + assert!(!is_uuid("7a1d2f3405678-49ab-9012-abcdef123456")); // dash in wrong place + } + + // ---- combined anonymous instance ---- + + #[test] + fn test_combined_anonymous_instance_valid() { + let segments = parse_id("gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~7a1d2f34-5678-49ab-9012-abcdef123456") + .unwrap(); + assert_eq!(segments.len(), 3); + assert!(segments[0].is_type()); + assert!(segments[1].is_type()); + assert!(segments[2].uuid_tail().is_some()); + assert!(!segments[2].is_type()); + assert_eq!(segments[2].raw(), "7a1d2f34-5678-49ab-9012-abcdef123456"); + } + + #[test] + fn test_combined_anonymous_instance_single_prefix_valid() { + let segments = + parse_id("gts.x.core.events.type.v1~7a1d2f34-5678-49ab-9012-abcdef123456").unwrap(); + assert_eq!(segments.len(), 2); + assert!(segments[0].is_type()); + assert!(segments[1].uuid_tail().is_some()); + } + + #[test] + fn test_combined_anonymous_instance_hyphen_in_segments_rejected() { + let err = parse_id("gts.x-vendor.core.events.type.v1~x.commerce.orders.order_placed.v1.0~7a1d2f34-5678-49ab-9012-abcdef123456") + .unwrap_err(); + assert!(err.segment.is_none(), "expected id-level error, got: {err}"); + assert!(err.cause.contains("'-'"), "got: {err}"); + } + + #[test] + fn test_uuid_alone_without_prefix_rejected() { + // A bare UUID with no GTS prefix is not a valid GTS ID + let err = parse_id("7a1d2f34-5678-49ab-9012-abcdef123456").unwrap_err(); + assert!(err.segment.is_none(), "expected id-level error, got: {err}"); + assert!(err.cause.contains("must start with 'gts.'"), "got: {err}"); + } + + #[test] + fn test_uuid_tail_without_preceding_tilde_rejected() { + // UUID as the only segment (no preceding ~) must be rejected + // "gts." + UUID has no tilde_parts.len() >= 2 + let err = parse_id("gts.7a1d2f34-5678-49ab-9012-abcdef123456").unwrap_err(); + assert!(err.segment.is_none(), "expected id-level error, got: {err}"); + assert!(err.cause.contains("'-'"), "got: {err}"); + } + + // ---- issue #37: single-segment instance prohibition ---- + + #[test] + fn test_single_segment_instance_rejected() { + // A lone instance segment (no '~', not a wildcard) is prohibited by #37. + let err = parse_id("gts.x.pkg.ns.type.v1.0").unwrap_err(); + assert!(err.segment.is_none(), "expected id-level error, got: {err}"); + assert!(err.cause.contains("Single-segment instance"), "got: {err}"); + } + + #[test] + fn test_single_segment_wildcard_allowed() { + // Wildcards are exempt from #37, so "gts.a.b.*" is accepted. + let segments = parse_pattern("gts.a.b.*").unwrap(); + assert_eq!(segments.len(), 1); + assert!(segments[0].is_wildcard()); + } + + // ---- wildcard placement rules live in the parser (id-level errors) ---- + + #[test] + fn test_parse_pattern_multistar_rejected() { + let err = parse_pattern("gts.*.*.*.*").unwrap_err(); + assert!(err.segment.is_none(), "expected id-level error, got: {err}"); + assert!(err.cause.contains("only once"), "got: {err}"); + } + + #[test] + fn test_parse_pattern_star_not_at_end_rejected() { + let err = parse_pattern("gts.*.core.events.event.v1~").unwrap_err(); + assert!(err.segment.is_none(), "expected id-level error, got: {err}"); + assert!(err.cause.contains("only at the end"), "got: {err}"); + } + + #[test] + fn test_parse_pattern_version_wildcard_accepted() { + // `v*` ends the string with `*`, so the structural gate admits it; the + // segment parser turns it into a single wildcard segment. + let segments = parse_pattern("gts.x.llm.chat.message.v*").unwrap(); + assert_eq!(segments.len(), 1); + assert!(segments[0].is_wildcard()); + } + + #[test] + fn test_parse_pattern_star_then_tilde_rejected() { + // `…*~` does not end with `*`, so the gate rejects it id-level rather + // than the segment parser silently stripping the trailing `~`. + let err = parse_pattern("gts.x.llm.chat.message.v1.*~").unwrap_err(); + assert!(err.segment.is_none(), "expected id-level error, got: {err}"); + assert!(err.cause.contains("only at the end"), "got: {err}"); + } + + #[test] + fn test_parse_pattern_midchain_wildcard_rejected() { + // A wildcard that is the final token of a non-final chain segment is only + // catchable structurally — the per-segment parser sees it as valid. + let err = parse_pattern("gts.x.*~a.b.c.d.v1~").unwrap_err(); + assert!(err.segment.is_none(), "expected id-level error, got: {err}"); + assert!(err.cause.contains("only at the end"), "got: {err}"); + } + + #[test] + fn test_parse_pattern_wildcard_rules_off_without_flag() { + // With wildcards disabled, '*' is just an invalid segment token, + // reported as a segment-level error. + let err = parse_id("gts.*.*.*.*").unwrap_err(); + assert!( + err.segment.is_some(), + "expected segment-level error, got: {err}" + ); + } +} diff --git a/gts-macros-cli/Cargo.toml b/gts-macros-cli/Cargo.toml index 502d5a6..63c262b 100644 --- a/gts-macros-cli/Cargo.toml +++ b/gts-macros-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gts-macros-cli" -version = "0.10.1" +version = "0.11.0" edition.workspace = true authors.workspace = true license.workspace = true diff --git a/gts-macros/Cargo.toml b/gts-macros/Cargo.toml index 386bba5..ed45d81 100644 --- a/gts-macros/Cargo.toml +++ b/gts-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gts-macros" -version = "0.10.1" +version = "0.11.0" edition.workspace = true authors.workspace = true license.workspace = true diff --git a/gts-macros/src/instance.rs b/gts-macros/src/instance.rs index 7e00a2b..8c21087 100644 --- a/gts-macros/src/instance.rs +++ b/gts-macros/src/instance.rs @@ -48,13 +48,8 @@ use syn::{ /// suffix, etc.) at compile time with span pointing at the literal. fn validate_instance_id_format(instance_id: &LitStr) -> syn::Result<()> { let raw = instance_id.value(); - if let Err(e) = gts_id::validate_gts_id(&raw, false) { - let msg = match &e { - gts_id::GtsIdError::Id { cause, .. } => format!("Invalid GTS instance ID: {cause}"), - gts_id::GtsIdError::Segment { num, cause, .. } => { - format!("Invalid GTS instance ID: segment #{num}: {cause}") - } - }; + if let Err(e) = gts_id::GtsId::try_new(&raw) { + let msg = format!("Invalid GTS instance ID: {e}"); return Err(syn::Error::new_spanned(instance_id, msg)); } Ok(()) diff --git a/gts-macros/src/lib.rs b/gts-macros/src/lib.rs index cf7c80f..60d4bff 100644 --- a/gts-macros/src/lib.rs +++ b/gts-macros/src/lib.rs @@ -713,18 +713,10 @@ impl Parse for GtsSchemaArgs { )); } // General GTS ID validation via shared crate - if let Err(e) = gts_id::validate_gts_id(&id, false) { - let msg = match &e { - gts_id::GtsIdError::Id { cause, .. } => { - format!("Invalid GTS type ID: {cause}") - } - gts_id::GtsIdError::Segment { num, cause, .. } => { - format!("Segment #{num}: {cause}") - } - }; + if let Err(e) = gts_id::GtsId::try_new(&id) { return Err(syn::Error::new_spanned( value, - format!("struct_to_gts_schema: {msg}"), + format!("struct_to_gts_schema: Invalid GTS type ID: {e}"), )); } type_id = Some(id); @@ -2306,7 +2298,7 @@ mod instance; /// }); /// ``` /// -/// The literal is validated against `gts_id::validate_gts_id` and +/// The literal is validated against `gts_id::GtsId::try_new` and /// const-asserted to share its prefix with `::TYPE_ID`. /// The id field's apparent string value is rewritten by the macro - at /// runtime `t.id` is a `GtsInstanceId`, not a `String`. @@ -2381,7 +2373,7 @@ mod instance; /// exactly one of: id, gts_id, gtsId`. /// - Two id fields (`id:` and `gts_id:` together): `ambiguous id field`. /// - Non-literal id value (`id: some_var`): `must be a string literal`. -/// - Malformed id literal: full error from `gts_id::validate_gts_id`. +/// - Malformed id literal: full error from `gts_id::GtsId::try_new`. /// - Schema-prefix mismatch: const-assert fails at build time. /// - `..rest` struct update syntax: not supported. /// @@ -2421,7 +2413,7 @@ pub fn gts_instance(input: TokenStream) -> TokenStream { /// - Missing top-level `"id"`: `missing top-level "id" key`. /// - Duplicate top-level `"id"`: pointed at both spans. /// - Non-literal `"id"` value: `"id" must be a string literal`. -/// - Malformed id literal: full error from `gts_id::validate_gts_id`. +/// - Malformed id literal: full error from `gts_id::GtsId::try_new`. /// - Body missing chained `~`: `instance id literal must contain at /// least one ~`. /// diff --git a/gts-macros/tests/compile_fail/instance_id_invalid_format.rs b/gts-macros/tests/compile_fail/instance_id_invalid_format.rs index ec994d9..09f9865 100644 --- a/gts-macros/tests/compile_fail/instance_id_invalid_format.rs +++ b/gts-macros/tests/compile_fail/instance_id_invalid_format.rs @@ -1,5 +1,5 @@ //! Test: typed `gts_instance!` rejects a malformed id literal at -//! proc-macro time via the shared `gts_id::validate_gts_id`. Catches +//! proc-macro time via the shared `gts_id::GtsId::try_new`. Catches //! issues like a missing `....v` //! segment shape before any further checks. diff --git a/gts-macros/tests/compile_fail/instance_id_invalid_format.stderr b/gts-macros/tests/compile_fail/instance_id_invalid_format.stderr index 5383788..03e9431 100644 --- a/gts-macros/tests/compile_fail/instance_id_invalid_format.stderr +++ b/gts-macros/tests/compile_fail/instance_id_invalid_format.stderr @@ -1,4 +1,4 @@ -error: Invalid GTS instance ID: segment #2: Too few tokens (got 4, min 5). Expected format: vendor.package.namespace.type.vMAJOR[.MINOR] +error: Invalid GTS instance ID: Invalid GTS segment #2 @ offset 27: 'not.a.valid.segment': Too few tokens (got 4, min 5). Expected format: vendor.package.namespace.type.vMAJOR[.MINOR] --> tests/compile_fail/instance_id_invalid_format.rs:26:13 | 26 | id: "gts.acme.core.test.perm.v1~not.a.valid.segment", diff --git a/gts-macros/tests/compile_fail/instance_id_wildcard_in_instance.rs b/gts-macros/tests/compile_fail/instance_id_wildcard_in_instance.rs index 77c40b9..1663d8f 100644 --- a/gts-macros/tests/compile_fail/instance_id_wildcard_in_instance.rs +++ b/gts-macros/tests/compile_fail/instance_id_wildcard_in_instance.rs @@ -1,7 +1,7 @@ //! Test: typed `gts_instance!` rejects an id literal containing a //! wildcard (`*`). Wildcards are valid only in pattern-matching contexts //! (`~*` for "any instance of this type"), never in concrete -//! instance ids — `validate_gts_id(_, allow_wildcards=false)` enforces +//! instance ids — `GtsId::try_new` (wildcards rejected) enforces //! this at proc-macro time. use gts::GtsInstanceId; diff --git a/gts-macros/tests/compile_fail/instance_id_wildcard_in_instance.stderr b/gts-macros/tests/compile_fail/instance_id_wildcard_in_instance.stderr index ecadc95..3a320dc 100644 --- a/gts-macros/tests/compile_fail/instance_id_wildcard_in_instance.stderr +++ b/gts-macros/tests/compile_fail/instance_id_wildcard_in_instance.stderr @@ -1,4 +1,4 @@ -error: Invalid GTS instance ID: segment #2: Invalid package token '*'. Must start with [a-z_] and contain only [a-z0-9_] +error: Invalid GTS instance ID: Invalid GTS segment #2 @ offset 27: 'vendor.*.test.x.v1': Invalid package token '*'. Must start with [a-z_] and contain only [a-z0-9_] --> tests/compile_fail/instance_id_wildcard_in_instance.rs:25:13 | 25 | id: "gts.acme.core.test.perm.v1~vendor.*.test.x.v1", diff --git a/gts-macros/tests/compile_fail/instance_raw_no_tilde.stderr b/gts-macros/tests/compile_fail/instance_raw_no_tilde.stderr index 47afc50..461f857 100644 --- a/gts-macros/tests/compile_fail/instance_raw_no_tilde.stderr +++ b/gts-macros/tests/compile_fail/instance_raw_no_tilde.stderr @@ -1,4 +1,4 @@ -error: instance id literal must contain at least one `~` (chained form: gts.~) +error: Invalid GTS instance ID: Invalid GTS identifier: gts.acme.core.events.topic.v1: Single-segment instance IDs are prohibited. Instance IDs must be chained with at least one type segment (e.g., 'type~instance') --> tests/compile_fail/instance_raw_no_tilde.rs:11:15 | 11 | "id": "gts.acme.core.events.topic.v1", diff --git a/gts-macros/tests/compile_fail/invalid_gts_id_missing_prefix.stderr b/gts-macros/tests/compile_fail/invalid_gts_id_missing_prefix.stderr index c73641a..cd0a32d 100644 --- a/gts-macros/tests/compile_fail/invalid_gts_id_missing_prefix.stderr +++ b/gts-macros/tests/compile_fail/invalid_gts_id_missing_prefix.stderr @@ -1,4 +1,4 @@ -error: struct_to_gts_schema: Invalid GTS type ID: must start with 'gts.' +error: struct_to_gts_schema: Invalid GTS type ID: Invalid GTS identifier: x.core.events.type.v1~: must start with 'gts.' --> tests/compile_fail/invalid_gts_id_missing_prefix.rs:8:15 | 8 | type_id = "x.core.events.type.v1~", diff --git a/gts-macros/tests/compile_fail/invalid_gts_id_too_many_tokens.stderr b/gts-macros/tests/compile_fail/invalid_gts_id_too_many_tokens.stderr index fed1406..ec6c92c 100644 --- a/gts-macros/tests/compile_fail/invalid_gts_id_too_many_tokens.stderr +++ b/gts-macros/tests/compile_fail/invalid_gts_id_too_many_tokens.stderr @@ -1,4 +1,4 @@ -error: struct_to_gts_schema: Segment #2: Too many name tokens before version (got 5, expected 4). Expected format: vendor.package.namespace.type.vMAJOR[.MINOR] +error: struct_to_gts_schema: Invalid GTS type ID: Invalid GTS segment #2 @ offset 28: 'x.core.license_enforcer.integration.plugin.v1~': Too many name tokens before version (got 5, expected 4). Expected format: vendor.package.namespace.type.vMAJOR[.MINOR] --> tests/compile_fail/invalid_gts_id_too_many_tokens.rs:25:15 | 25 | type_id = "gts.x.core.modkit.plugin.v1~x.core.license_enforcer.integration.plugin.v1~", diff --git a/gts-macros/tests/compile_fail/version_missing_both.stderr b/gts-macros/tests/compile_fail/version_missing_both.stderr index bd6e784..751dffe 100644 --- a/gts-macros/tests/compile_fail/version_missing_both.stderr +++ b/gts-macros/tests/compile_fail/version_missing_both.stderr @@ -1,4 +1,4 @@ -error: struct_to_gts_schema: Segment #1: Too few tokens (got 4, min 5). Expected format: gts.vendor.package.namespace.type.vMAJOR[.MINOR] +error: struct_to_gts_schema: Invalid GTS type ID: Invalid GTS segment #1 @ offset 4: 'x.core.events.type~': Too few tokens (got 4, min 5). Expected format: gts.vendor.package.namespace.type.vMAJOR[.MINOR] --> tests/compile_fail/version_missing_both.rs:9:15 | 9 | type_id = "gts.x.core.events.type~", diff --git a/gts-macros/tests/compile_fail/version_missing_in_schema.stderr b/gts-macros/tests/compile_fail/version_missing_in_schema.stderr index 747de73..809acf8 100644 --- a/gts-macros/tests/compile_fail/version_missing_in_schema.stderr +++ b/gts-macros/tests/compile_fail/version_missing_in_schema.stderr @@ -1,4 +1,4 @@ -error: struct_to_gts_schema: Segment #1: Too few tokens (got 4, min 5). Expected format: gts.vendor.package.namespace.type.vMAJOR[.MINOR] +error: struct_to_gts_schema: Invalid GTS type ID: Invalid GTS segment #1 @ offset 4: 'x.core.events.type~': Too few tokens (got 4, min 5). Expected format: gts.vendor.package.namespace.type.vMAJOR[.MINOR] --> tests/compile_fail/version_missing_in_schema.rs:9:15 | 9 | type_id = "gts.x.core.events.type~", diff --git a/gts-macros/tests/inheritance_tests.rs b/gts-macros/tests/inheritance_tests.rs index 318d6fb..8a7b95d 100644 --- a/gts-macros/tests/inheritance_tests.rs +++ b/gts-macros/tests/inheritance_tests.rs @@ -821,8 +821,11 @@ mod tests { fn test_empty_struct_gts_instance_id_serialization() { // Test GTS instance ID serialization/deserialization for macro-generated structs with empty nested types - // Create instance ID using the macro-generated method - let instance_id = TopicV1::::gts_make_instance_id("test-topic"); + // Create instance ID using the macro-generated method. The segment must + // be a well-formed instance segment (vendor.package.namespace.type.vN[.M], + // no hyphens) so the round-trip survives validating deserialization. + let segment = "acme.shop.orders.topic.v1.0"; + let instance_id = TopicV1::::gts_make_instance_id(segment); // Serialize the instance ID let serialized = serde_json::to_string(&instance_id).expect("Serialization should succeed"); @@ -835,8 +838,9 @@ mod tests { // Verify the instance ID contains the expected GTS ID chain let id_str = instance_id.as_ref(); - assert!(id_str.contains("gts.x.core.events.topic.v1~")); - assert!(id_str.ends_with("test-topic")); + // The generated ID is deterministic, so assert the exact value rather + // than a contains/ends_with pair that could pass on unexpected text. + assert_eq!(id_str, format!("gts.x.core.events.topic.v1~{segment}")); // Note: gts_make_instance_id uses the schema ID of the type it's called on (TopicV1), // not the generic parameter (OrderTopicConfigV1) } diff --git a/gts-macros/tests/integration_tests.rs b/gts-macros/tests/integration_tests.rs index 37314ed..364799c 100644 --- a/gts-macros/tests/integration_tests.rs +++ b/gts-macros/tests/integration_tests.rs @@ -9,7 +9,7 @@ mod inheritance_tests; -use gts::{GtsConfig, GtsEntity, GtsID, GtsInstanceId, GtsSchema}; +use gts::{GtsConfig, GtsEntity, GtsId, GtsInstanceId, GtsSchema}; use gts_macros::struct_to_gts_schema; /// Event Topic (Stream) definition for testing GTS schema generation. /// Inspired by examples/examples/events/schemas/gts.x.core.events.topic.v1~.schema.json @@ -581,7 +581,7 @@ fn test_multiple_instances_validate_independently() { } // ============================================================================= -// Tests for GtsEntity and GtsID integration +// Tests for GtsEntity and GtsId integration // ============================================================================= #[test] @@ -608,10 +608,13 @@ fn test_schema_parsed_as_gts_entity() { // Verify GTS ID was parsed let gts_id = entity.gts_id.as_ref().expect("Entity should have a GTS ID"); - assert_eq!(gts_id.id, "gts.x.core.events.topic.v1~"); + assert_eq!(gts_id.id(), "gts.x.core.events.topic.v1~"); // Verify the ID matches what the macro generates - assert_eq!(gts_id.id, EventTopicV1::gts_type_id().clone().into_string()); + assert_eq!( + gts_id.id(), + EventTopicV1::gts_type_id().clone().into_string() + ); } #[test] @@ -644,7 +647,7 @@ fn test_instance_parsed_as_gts_entity() { // Verify GTS ID was parsed from the instance let gts_id = entity.gts_id.as_ref().expect("Entity should have a GTS ID"); assert_eq!( - gts_id.id, + gts_id.id(), "gts.x.core.events.topic.v1~x.commerce.orders.orders.v1.0" ); } @@ -654,23 +657,22 @@ fn test_gts_id_segments_match_schema() { // Get the schema ID from the macro let schema_id_str = EventTopicV1::gts_type_id().as_ref(); - // Parse it with GtsID - let gts_id = GtsID::new(schema_id_str).expect("Schema ID should be valid"); + // Parse it with GtsId + let gts_id = GtsId::try_new(schema_id_str).expect("Schema ID should be valid"); // Verify segments - assert_eq!( - gts_id.gts_id_segments.len(), - 1, - "Schema should have 1 segment" + assert_eq!(gts_id.segments().len(), 1, "Schema should have 1 segment"); + + let segment = >s_id.segments()[0]; + assert_eq!(segment.vendor(), "x"); + assert_eq!(segment.package(), "core"); + assert_eq!(segment.namespace(), "events"); + assert_eq!(segment.type_name(), "topic"); + assert_eq!(segment.ver_major(), 1); + assert!( + segment.is_type(), + "Schema ID should be a type (ends with ~)" ); - - let segment = >s_id.gts_id_segments[0]; - assert_eq!(segment.vendor, "x"); - assert_eq!(segment.package, "core"); - assert_eq!(segment.namespace, "events"); - assert_eq!(segment.type_name, "topic"); - assert_eq!(segment.ver_major, 1); - assert!(segment.is_type, "Schema ID should be a type (ends with ~)"); } #[test] @@ -678,53 +680,62 @@ fn test_gts_id_segments_match_instance() { // Generate an instance ID using the macro let instance_id_str = EventTopicV1::gts_make_instance_id("x.commerce.orders.orders.v1.0"); - // Parse it with GtsID - let gts_id = GtsID::new(&instance_id_str).expect("Instance ID should be valid"); + // Parse it with GtsId + let gts_id = GtsId::try_new(&instance_id_str).expect("Instance ID should be valid"); // Instance IDs have 2 segments: type segment + instance segment assert_eq!( - gts_id.gts_id_segments.len(), + gts_id.segments().len(), 2, "Instance should have 2 segments" ); // First segment is the type/schema segment - let type_segment = >s_id.gts_id_segments[0]; - assert_eq!(type_segment.vendor, "x"); - assert_eq!(type_segment.package, "core"); - assert_eq!(type_segment.namespace, "events"); - assert_eq!(type_segment.type_name, "topic"); - assert_eq!(type_segment.ver_major, 1); - assert!(type_segment.is_type, "First segment should be a type"); + let type_segment = >s_id.segments()[0]; + assert_eq!(type_segment.vendor(), "x"); + assert_eq!(type_segment.package(), "core"); + assert_eq!(type_segment.namespace(), "events"); + assert_eq!(type_segment.type_name(), "topic"); + assert_eq!(type_segment.ver_major(), 1); + assert!(type_segment.is_type(), "First segment should be a type"); // Second segment is the instance segment - let instance_segment = >s_id.gts_id_segments[1]; - assert_eq!(instance_segment.vendor, "x"); - assert_eq!(instance_segment.package, "commerce"); - assert_eq!(instance_segment.namespace, "orders"); - assert_eq!(instance_segment.type_name, "orders"); - assert_eq!(instance_segment.ver_major, 1); - assert_eq!(instance_segment.ver_minor, Some(0)); + let instance_segment = >s_id.segments()[1]; + assert_eq!(instance_segment.vendor(), "x"); + assert_eq!(instance_segment.package(), "commerce"); + assert_eq!(instance_segment.namespace(), "orders"); + assert_eq!(instance_segment.type_name(), "orders"); + assert_eq!(instance_segment.ver_major(), 1); + assert_eq!(instance_segment.ver_minor(), Some(0)); } #[test] fn test_schema_and_instance_segments_relationship() { // The schema ID from macro - let schema_id = GtsID::new(EventTopicV1::gts_type_id().as_ref()).unwrap(); + let schema_id = GtsId::try_new(EventTopicV1::gts_type_id().as_ref()).unwrap(); // An instance ID from the macro let instance_id_str = EventTopicV1::gts_make_instance_id("x.core.idp.contacts.v1"); - let instance_id = GtsID::new(&instance_id_str).unwrap(); + let instance_id = GtsId::try_new(&instance_id_str).unwrap(); // The first segment of the instance should match the schema's segment - let schema_segment = &schema_id.gts_id_segments[0]; - let instance_type_segment = &instance_id.gts_id_segments[0]; + let schema_segment = &schema_id.segments()[0]; + let instance_type_segment = &instance_id.segments()[0]; - assert_eq!(schema_segment.vendor, instance_type_segment.vendor); - assert_eq!(schema_segment.package, instance_type_segment.package); - assert_eq!(schema_segment.namespace, instance_type_segment.namespace); - assert_eq!(schema_segment.type_name, instance_type_segment.type_name); - assert_eq!(schema_segment.ver_major, instance_type_segment.ver_major); + assert_eq!(schema_segment.vendor(), instance_type_segment.vendor()); + assert_eq!(schema_segment.package(), instance_type_segment.package()); + assert_eq!( + schema_segment.namespace(), + instance_type_segment.namespace() + ); + assert_eq!( + schema_segment.type_name(), + instance_type_segment.type_name() + ); + assert_eq!( + schema_segment.ver_major(), + instance_type_segment.ver_major() + ); // get_type_id() should return the schema ID (without the instance segment) let type_id = instance_id.get_type_id(); @@ -755,29 +766,29 @@ fn test_entity_and_gts_id_vendor_package_namespace_match() { // Get the GTS ID from the entity let entity_gts_id = entity.gts_id.as_ref().unwrap(); - // Parse the same ID directly using GtsID - let direct_gts_id = GtsID::new(EventTopicV1::gts_type_id().as_ref()).unwrap(); + // Parse the same ID directly using GtsId + let direct_gts_id = GtsId::try_new(EventTopicV1::gts_type_id().as_ref()).unwrap(); // Verify they match - assert_eq!(entity_gts_id.id, direct_gts_id.id); + assert_eq!(entity_gts_id.id(), direct_gts_id.id()); assert_eq!( - entity_gts_id.gts_id_segments.len(), - direct_gts_id.gts_id_segments.len() + entity_gts_id.segments().len(), + direct_gts_id.segments().len() ); // Compare segment properties for (entity_seg, direct_seg) in entity_gts_id - .gts_id_segments + .segments() .iter() - .zip(direct_gts_id.gts_id_segments.iter()) + .zip(direct_gts_id.segments().iter()) { - assert_eq!(entity_seg.vendor, direct_seg.vendor); - assert_eq!(entity_seg.package, direct_seg.package); - assert_eq!(entity_seg.namespace, direct_seg.namespace); - assert_eq!(entity_seg.type_name, direct_seg.type_name); - assert_eq!(entity_seg.ver_major, direct_seg.ver_major); - assert_eq!(entity_seg.ver_minor, direct_seg.ver_minor); - assert_eq!(entity_seg.is_type, direct_seg.is_type); + assert_eq!(entity_seg.vendor(), direct_seg.vendor()); + assert_eq!(entity_seg.package(), direct_seg.package()); + assert_eq!(entity_seg.namespace(), direct_seg.namespace()); + assert_eq!(entity_seg.type_name(), direct_seg.type_name()); + assert_eq!(entity_seg.ver_major(), direct_seg.ver_major()); + assert_eq!(entity_seg.ver_minor(), direct_seg.ver_minor()); + assert_eq!(entity_seg.is_type(), direct_seg.is_type()); } } @@ -827,24 +838,25 @@ fn test_gts_entity_strips_uri_prefix_from_schema() { // The GTS ID should have the gts:// prefix stripped (entities.rs strips gts:// from $id field) let gts_id = entity.gts_id.as_ref().expect("Entity should have a GTS ID"); assert_eq!( - gts_id.id, "gts.x.core.events.topic.v1~", + gts_id.id(), + "gts.x.core.events.topic.v1~", "GTS ID should not contain 'gts://' prefix" ); } #[test] fn test_gts_id_does_not_accept_uri_prefix() { - // GtsID::new should NOT accept IDs with gts:// or gts: prefix directly + // GtsId::try_new should NOT accept IDs with gts:// or gts: prefix directly // The gts:// prefix is ONLY for JSON Schema $id field and must be stripped before parsing - assert!(GtsID::new("gts://gts.x.core.events.topic.v1~").is_err()); - assert!(!GtsID::is_valid("gts://gts.x.core.events.topic.v1~")); + assert!(GtsId::try_new("gts://gts.x.core.events.topic.v1~").is_err()); + assert!(!GtsId::is_valid("gts://gts.x.core.events.topic.v1~")); // "gts:" (without //) is also not valid - assert!(GtsID::new("gts:gts.x.core.events.topic.v1~").is_err()); - assert!(!GtsID::is_valid("gts:gts.x.core.events.topic.v1~")); + assert!(GtsId::try_new("gts:gts.x.core.events.topic.v1~").is_err()); + assert!(!GtsId::is_valid("gts:gts.x.core.events.topic.v1~")); // Regular GTS IDs should work - assert!(GtsID::is_valid("gts.x.core.events.topic.v1~")); + assert!(GtsId::is_valid("gts.x.core.events.topic.v1~")); } // ============================================================================= diff --git a/gts-macros/tests/value_dispatch_tests.rs b/gts-macros/tests/value_dispatch_tests.rs index c7ec53a..0fa05f1 100644 --- a/gts-macros/tests/value_dispatch_tests.rs +++ b/gts-macros/tests/value_dispatch_tests.rs @@ -254,7 +254,9 @@ mod deserialisation { // are effectively identity wrappers — the payload survives // any nesting without mangling. let envelope = EnvelopeV1:: { - gts_type: GtsTypeId::new("gts.x.test.value_dispatch.envelope.v1~x.test.unknown.v1~"), + gts_type: GtsTypeId::new( + "gts.x.test.value_dispatch.envelope.v1~x.test.unknown.thing.v1~", + ), payload: serde_json::json!({ "anything": ["the", "future", 42, null, { "nested": true }] }), @@ -368,7 +370,7 @@ mod dispatch { // A future / unknown gts_type — survives dispatch via the // open-set Unknown branch. serde_json::json!({ - "gts_type": "gts.x.test.value_dispatch.envelope.v1~x.test.unmodelled_future.v1~", + "gts_type": "gts.x.test.value_dispatch.envelope.v1~x.test.unmodelled.future.v1~", "payload": { "anything": "goes" } }), ]; @@ -415,7 +417,7 @@ mod dispatch { Decoded::Unknown(env) => { assert_eq!( env.gts_type.as_ref(), - "gts.x.test.value_dispatch.envelope.v1~x.test.unmodelled_future.v1~" + "gts.x.test.value_dispatch.envelope.v1~x.test.unmodelled.future.v1~" ); // Payload survives intact as the original JSON Value: assert_eq!(env.payload, serde_json::json!({ "anything": "goes" })); diff --git a/gts-validator/Cargo.toml b/gts-validator/Cargo.toml index cdde750..7c319a8 100644 --- a/gts-validator/Cargo.toml +++ b/gts-validator/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gts-validator" -version = "0.10.1" +version = "0.11.0" edition.workspace = true authors.workspace = true license.workspace = true diff --git a/gts-validator/src/format/markdown.rs b/gts-validator/src/format/markdown.rs index cc3b2c9..8d4b88c 100644 --- a/gts-validator/src/format/markdown.rs +++ b/gts-validator/src/format/markdown.rs @@ -44,7 +44,7 @@ fn parse_fence(trimmed_line: &str) -> Option<(char, usize)> { } /// Discovery regex (relaxed): finds strings that LOOK like GTS identifiers. -/// This is intentionally broader than the spec — validation is done by `GtsID::new()`. +/// This is intentionally broader than the spec — validation is done by `GtsId::try_new()`. /// /// Strategy: Match gts. followed by 4+ dot-separated segments where at least one /// segment looks like a version (starts with 'v' followed by digit). diff --git a/gts-validator/src/normalize.rs b/gts-validator/src/normalize.rs index 0071cfa..c82ad48 100644 --- a/gts-validator/src/normalize.rs +++ b/gts-validator/src/normalize.rs @@ -11,13 +11,13 @@ /// Result of normalizing a raw candidate string. #[derive(Debug, Clone, PartialEq, Eq)] pub struct NormalizedCandidate { - /// The canonical GTS identifier string (ready for `GtsID::new()`) + /// The canonical GTS identifier string (ready for `GtsId::try_new()`) pub gts_id: String, /// The original raw string (for error reporting) pub original: String, } -/// Normalize a raw candidate string into a form suitable for `GtsID::new()`. +/// Normalize a raw candidate string into a form suitable for `GtsId::try_new()`. /// /// Steps: /// 1. Trim whitespace diff --git a/gts-validator/src/validator.rs b/gts-validator/src/validator.rs index d4bf8dd..3139a32 100644 --- a/gts-validator/src/validator.rs +++ b/gts-validator/src/validator.rs @@ -94,7 +94,7 @@ pub fn is_bad_example_context(line: &str, match_start: usize) -> bool { /// Validate a GTS identifier candidate. /// -/// This function delegates all validation to `gts::GtsID::new()` and `gts::GtsWildcard::new()`. +/// This function delegates all validation to `gts::GtsId::try_new()` and `gts::GtsIdPattern::try_new()`. /// It does NOT re-implement GTS parsing. /// /// # Arguments @@ -122,20 +122,20 @@ pub fn validate_candidate( candidate.original )]; } - // GtsWildcard::new() delegates to GtsID::new() internally, + // GtsIdPattern::try_new() delegates to GtsId::try_new() internally, // so all spec rules are enforced. Single parse — vendor check // only runs on success to avoid duplicate/misleading errors. - match gts::GtsWildcard::new(gts_id) { + match gts::GtsIdPattern::try_new(gts_id) { Ok(parsed) => { if let Some(expected) = expected_vendor - && let Some(first_seg) = parsed.gts_id_segments.first() - && !first_seg.vendor.contains('*') - && first_seg.vendor != expected - && !is_example_vendor(&first_seg.vendor) + && let Some(first_seg) = parsed.segments().first() + && !first_seg.vendor().contains('*') + && first_seg.vendor() != expected + && !is_example_vendor(first_seg.vendor()) { errors.push(format!( "Vendor mismatch: expected '{expected}', found '{}'", - first_seg.vendor + first_seg.vendor() )); } } @@ -145,17 +145,17 @@ pub fn validate_candidate( } } else { // Delegate to gts crate — the single source of truth - match gts::GtsID::new(gts_id) { + match gts::GtsId::try_new(gts_id) { Ok(parsed) => { // Vendor check if let Some(expected) = expected_vendor - && let Some(first_seg) = parsed.gts_id_segments.first() - && first_seg.vendor != expected - && !is_example_vendor(&first_seg.vendor) + && let Some(first_seg) = parsed.segments().first() + && first_seg.vendor() != expected + && !is_example_vendor(first_seg.vendor()) { errors.push(format!( "Vendor mismatch: expected '{expected}', found '{}'", - first_seg.vendor + first_seg.vendor() )); } } diff --git a/gts/Cargo.toml b/gts/Cargo.toml index c55cd34..331a6f2 100644 --- a/gts/Cargo.toml +++ b/gts/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gts" -version = "0.10.1" +version = "0.11.0" edition.workspace = true authors.workspace = true license.workspace = true @@ -16,11 +16,10 @@ publish = true workspace = true [dependencies] -gts-id.workspace = true +gts-id = { workspace = true, features = ["uuid"] } serde.workspace = true serde_json.workspace = true thiserror.workspace = true -uuid.workspace = true jsonschema.workspace = true schemars.workspace = true walkdir.workspace = true diff --git a/gts/src/entities.rs b/gts/src/entities.rs index 1375017..8aef502 100644 --- a/gts/src/entities.rs +++ b/gts/src/entities.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; -use crate::gts::{GTS_URI_PREFIX, GtsID}; +use crate::gts::{GTS_URI_PREFIX, GtsId}; use crate::path_resolver::JsonPathResolver; use crate::schema_cast::{GtsEntityCastResult, SchemaCastError}; @@ -102,9 +102,9 @@ pub struct GtsRef { pub struct GtsEntity { /// The GTS ID if the entity has one (either from `id` field for well-known instances, /// or from `$id` field for schemas). None for anonymous instances. - pub gts_id: Option, + pub gts_id: Option, /// The instance ID - for anonymous instances this is the UUID from `id` field, - /// for well-known instances this equals `gts_id.id`, for schemas this equals `gts_id.id`. + /// for well-known instances this equals `gts_id.id()`, for schemas this equals `gts_id.id()`. pub instance_id: Option, /// True if this is a JSON Schema (has `$schema` field), false if it's an instance. pub is_schema: bool, @@ -133,7 +133,7 @@ impl GtsEntity { list_sequence: Option, content: &Value, cfg: Option<&GtsConfig>, - gts_id: Option, + gts_id: Option, is_schema: bool, label: String, validation: Option, @@ -181,7 +181,7 @@ impl GtsEntity { } else if let Some(ref instance_id) = entity.instance_id { entity.label = instance_id.clone(); } else if let Some(ref gts_id) = entity.gts_id { - entity.label = gts_id.id.clone(); + entity.label = gts_id.id().to_owned(); } else if entity.label.is_empty() { entity.label = String::new(); } @@ -236,8 +236,8 @@ impl GtsEntity { } let normalized = trimmed.strip_prefix(GTS_URI_PREFIX).unwrap_or(trimmed); - if GtsID::is_valid(normalized) { - self.gts_id = GtsID::new(normalized).ok(); + if GtsId::is_valid(normalized) { + self.gts_id = GtsId::try_new(normalized).ok(); self.instance_id = Some(normalized.to_owned()); self.selected_entity_field = Some("$id".to_owned()); } @@ -253,46 +253,29 @@ impl GtsEntity { // $schema values that parse as a GTS Type Identifier (chain // ending in '~'); leave selected_type_id_field set either // way so callers can see we looked at $schema. - if schema_str.ends_with('~') && GtsID::is_valid(schema_str) { + if schema_str.ends_with('~') && GtsId::is_valid(schema_str) { self.type_id = Some(schema_str.to_owned()); } self.selected_type_id_field = Some("$schema".to_owned()); } - // For chained GTS IDs, extract the parent schema from the chain + // For chained GTS IDs, the parent schema is the type id formed by + // every segment except the last. `GtsId::get_type_id()` reconstructs + // it correctly by joining the raw segments — which already carry their + // trailing `~` — so it avoids the double-`~` a hand-rolled + // `join("~")` would produce for chains with two or more parents. if let Some(ref gts_id) = self.gts_id - && gts_id.gts_id_segments.len() > 1 + && let Some(parent_id) = gts_id.get_type_id() { - // Build parent schema ID from all segments except the last - // Each segment.segment already includes the ~ suffix if it's a type - let parent_segments: Vec<&str> = gts_id - .gts_id_segments - .iter() - .take(gts_id.gts_id_segments.len() - 1) - .map(|seg| seg.segment.as_str()) - .collect(); - if !parent_segments.is_empty() { - // Join segments - they already have ~ at the end if they're types - // The full chain format is: gts.seg1~seg2~seg3~ - // For parent, we want: gts.seg1~ (if only one parent segment) - // or gts.seg1~seg2~ (if multiple parent segments) - let parent_id = format!("gts.{}", parent_segments.join("~")); - // Ensure it ends with ~ (parent is always a schema) - let parent_id = if parent_id.ends_with('~') { - parent_id - } else { - format!("{parent_id}~") - }; - // Use parent from chain as type_id when current value isn't - // already a GTS Type Identifier (e.g. $schema held a JSON - // Schema dialect URL, or no $schema was present). - let already_gts_type_id = self - .type_id - .as_ref() - .is_some_and(|s| s.ends_with('~') && GtsID::is_valid(s)); - if !already_gts_type_id { - self.type_id = Some(parent_id); - } + // Use parent from chain as type_id when current value isn't + // already a GTS Type Identifier (e.g. $schema held a JSON + // Schema dialect URL, or no $schema was present). + let already_gts_type_id = self + .type_id + .as_ref() + .is_some_and(|s| s.ends_with('~') && GtsId::is_valid(s)); + if !already_gts_type_id { + self.type_id = Some(parent_id); } } } @@ -301,9 +284,9 @@ impl GtsEntity { if self.gts_id.is_none() { let idv = self.calc_json_entity_id_legacy(cfg); if let Some(ref id) = idv - && GtsID::is_valid(id) + && GtsId::is_valid(id) { - self.gts_id = GtsID::new(id).ok(); + self.gts_id = GtsId::try_new(id).ok(); self.instance_id = Some(id.clone()); } } @@ -327,9 +310,9 @@ impl GtsEntity { // Check if id is a valid GTS ID (well-known instance) if let Some(ref id) = id_value { - if GtsID::is_valid(id) { + if GtsId::is_valid(id) { // Well-known instance: id IS the GTS ID - self.gts_id = GtsID::new(id).ok(); + self.gts_id = GtsId::try_new(id).ok(); self.instance_id = Some(id.clone()); // PRIORITY 1: Extract schema from chained ID (always takes priority) @@ -344,10 +327,10 @@ impl GtsEntity { // Extract schema ID: everything up to and including last ~ // For a 2-segment chain, this gives first segment (parent) if let Some(ref gts_id) = self.gts_id - && gts_id.gts_id_segments.len() > 1 - && let Some(last_tilde) = gts_id.id.rfind('~') + && gts_id.segments().len() > 1 + && let Some(last_tilde) = gts_id.id().rfind('~') { - self.type_id = Some(gts_id.id[..=last_tilde].to_string()); + self.type_id = Some(gts_id.id()[..=last_tilde].to_string()); // Mark that type_id was extracted from the id field self.selected_type_id_field = self.selected_entity_field.clone(); } @@ -401,7 +384,7 @@ impl GtsEntity { } // Only accept valid GTS type IDs (ending with ~) if let Some(v) = self.get_field_value(f) - && GtsID::is_valid(&v) + && GtsId::is_valid(&v) && v.ends_with('~') { self.selected_type_id_field = Some(f.clone()); @@ -421,7 +404,7 @@ impl GtsEntity { let gts_id = self .gts_id .as_ref() - .map(|g| g.id.clone()) + .map(|g| g.id().to_owned()) .unwrap_or_default(); JsonPathResolver::new(gts_id, self.content.clone()).resolve(path) } @@ -439,11 +422,12 @@ impl GtsEntity { // When casting a schema, from_schema might be a standard JSON Schema (no gts_id) if self.is_schema && let (Some(self_id), Some(from_id)) = (&self.gts_id, &from_schema.gts_id) - && self_id.id != from_id.id + && self_id.id() != from_id.id() { return Err(SchemaCastError::InternalError(format!( "Internal error: {} != {}", - self_id.id, from_id.id + self_id.id(), + from_id.id() ))); } @@ -458,12 +442,12 @@ impl GtsEntity { let from_id = self .gts_id .as_ref() - .map(|g| g.id.clone()) + .map(|g| g.id().to_owned()) .unwrap_or_default(); let to_id = to_schema .gts_id .as_ref() - .map(|g| g.id.clone()) + .map(|g| g.id().to_owned()) .unwrap_or_default(); GtsEntityCastResult::cast( @@ -534,7 +518,7 @@ impl GtsEntity { let gts_id_matcher = |node: &Value, path: &str| -> Option { if let Some(s) = node.as_str() - && GtsID::is_valid(s) + && GtsId::is_valid(s) { return Some(GtsRef { id: s.to_owned(), @@ -613,7 +597,7 @@ impl GtsEntity { // First pass: look for valid GTS IDs for f in fields { if let Some(v) = self.get_field_value(f) - && GtsID::is_valid(&v) + && GtsId::is_valid(&v) { self.selected_entity_field = Some(f.clone()); return Some(v); @@ -639,7 +623,7 @@ impl GtsEntity { pub fn effective_id(&self) -> Option { // Prefer GTS ID if available if let Some(ref gts_id) = self.gts_id { - return Some(gts_id.id.clone()); + return Some(gts_id.id().to_owned()); } // Fall back to instance_id for anonymous instances self.instance_id.clone() @@ -1007,7 +991,7 @@ mod tests { // ============================================================================= // Tests for URI prefix "gts:" in JSON Schema $id field // The gts: prefix is used in JSON Schema for URI compatibility. - // GtsEntity strips it when parsing so the GtsID works with normal "gts." format. + // GtsEntity strips it when parsing so the GtsId works with normal "gts." format. // ============================================================================= #[test] @@ -1034,7 +1018,7 @@ mod tests { // The gts_id should have the prefix stripped let gts_id = entity.gts_id.as_ref().expect("Entity should have a GTS ID"); - assert_eq!(gts_id.id, "gts.vendor.package.namespace.type.v1.0~"); + assert_eq!(gts_id.id(), "gts.vendor.package.namespace.type.v1.0~"); assert!(entity.is_schema, "Entity should be detected as a schema"); } @@ -1063,7 +1047,7 @@ mod tests { let gts_id = entity.gts_id.as_ref().expect("Entity should have a GTS ID"); assert_eq!( - gts_id.id, + gts_id.id(), "gts.vendor.package.namespace.type.v1~other.app.data.item.v1.0" ); @@ -1266,7 +1250,7 @@ mod tests { "Well-known instance should have gts_id" ); assert_eq!( - entity.gts_id.as_ref().unwrap().id, + entity.gts_id.as_ref().unwrap().id(), "gts.x.core.events.type.v1~abc.app._.custom_event.v1.2" ); assert_eq!( @@ -1452,7 +1436,7 @@ mod tests { assert!(!entity.is_schema); assert!(entity.gts_id.is_some()); assert_eq!( - entity.gts_id.as_ref().unwrap().id, + entity.gts_id.as_ref().unwrap().id(), "gts.vendor.package.namespace.type.v1.0~a.b.c.d.v1" ); // Chained ID should have type_id extracted from the chain diff --git a/gts/src/gts.rs b/gts/src/gts.rs index 3d492e9..29be2a7 100644 --- a/gts/src/gts.rs +++ b/gts/src/gts.rs @@ -1,493 +1,41 @@ -use std::fmt; -use std::str::FromStr; -use std::sync::LazyLock; -use thiserror::Error; -use uuid::Uuid; - -pub const GTS_PREFIX: &str = gts_id::GTS_PREFIX; -/// URI-compatible prefix for GTS identifiers in JSON Schema `$id` field (e.g., `gts://gts.x.y.z...`). -/// This is ONLY used for JSON Schema serialization/deserialization, not for GTS ID parsing. -pub const GTS_URI_PREFIX: &str = "gts://"; -static GTS_NS: LazyLock = LazyLock::new(|| Uuid::new_v5(&Uuid::NAMESPACE_URL, b"gts")); - -#[derive(Debug, Error)] -pub enum GtsError { - #[error("Invalid GTS segment #{num} @ offset {offset}: '{segment}': {cause}")] - Segment { - num: usize, - offset: usize, - segment: String, - cause: String, - }, - - #[error("Invalid GTS identifier: {id}: {cause}")] - Id { id: String, cause: String }, +//! GTS identifier types. +//! +//! The low-level identifier primitives ([`GtsId`], [`GtsIdSegment`], +//! [`GtsIdPattern`]) and all validation live in the [`gts_id`] crate and are +//! re-exported here. The typed, schema-aware wrappers [`GtsTypeId`] and +//! [`GtsInstanceId`] live in this crate because they carry the JSON Schema +//! integration (`serde`, `schemars`, [`GtsTypeId::json_schema_value`]), which is +//! a `gts` (schema) concern rather than a pure identifier concern. They are +//! built on the crate-private [`GtsEntityId`] string newtype, which is plumbing +//! for those wrappers and intentionally not part of the public API. +//! +//! [`GtsIdError`] — the single error type shared across all GTS identifier and +//! wildcard parsing — is re-exported here from the [`gts_id`] crate. - #[error("Invalid GTS wildcard pattern: {pattern}: {cause}")] - Wildcard { pattern: String, cause: String }, -} - -/// Parsed GTS segment containing vendor, package, namespace, type, and version info. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -#[allow(clippy::struct_excessive_bools)] -pub struct GtsIdSegment { - pub num: usize, - pub offset: usize, - pub segment: String, - pub vendor: String, - pub package: String, - pub namespace: String, - pub type_name: String, - pub ver_major: u32, - pub ver_minor: Option, - pub is_type: bool, - pub is_wildcard: bool, - pub is_uuid_tail: bool, -} - -impl GtsIdSegment { - /// Creates a new GTS ID segment from a string. - /// - /// # Errors - /// Returns `GtsError::Segment` if the segment string is invalid. - pub fn new(num: usize, offset: usize, segment: &str) -> Result { - let segment = segment.trim().to_owned(); - let mut seg = GtsIdSegment { - num, - offset, - segment: segment.clone(), - vendor: String::new(), - package: String::new(), - namespace: String::new(), - type_name: String::new(), - ver_major: 0, - ver_minor: None, - is_type: false, - is_wildcard: false, - is_uuid_tail: false, - }; - - seg.parse_segment_id(&segment)?; - Ok(seg) - } - - fn parse_segment_id(&mut self, segment: &str) -> Result<(), GtsError> { - let parsed = gts_id::validate_segment(self.num, segment, true).map_err(|cause| { - GtsError::Segment { - num: self.num, - offset: self.offset, - segment: self.segment.clone(), - cause, - } - })?; - self.vendor = parsed.vendor; - self.package = parsed.package; - self.namespace = parsed.namespace; - self.type_name = parsed.type_name; - self.ver_major = parsed.ver_major; - self.ver_minor = parsed.ver_minor; - self.is_type = parsed.is_type; - self.is_wildcard = parsed.is_wildcard; - self.is_uuid_tail = parsed.is_uuid_tail; - Ok(()) - } -} - -/// GTS ID - a validated Global Type System identifier. -/// -/// GTS IDs follow the format: `gts.....[~]` -/// where `~` suffix indicates a type/schema definition. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct GtsID { - pub id: String, - pub gts_id_segments: Vec, -} - -impl GtsID { - /// Parse and validate a GTS identifier string. - /// - /// # Errors - /// Returns `GtsError::Id` if the string is not a valid GTS identifier. - pub fn new(id: &str) -> Result { - let raw = id.trim(); - - // Delegate all validation to the shared gts-id crate (single source of truth). - let parsed_segments = gts_id::validate_gts_id(raw, true).map_err(|e| match e { - gts_id::GtsIdError::Id { cause, .. } => GtsError::Id { - id: id.to_owned(), - cause, - }, - gts_id::GtsIdError::Segment { - num, - offset, - segment, - cause, - } => GtsError::Segment { - num, - offset, - segment, - cause, - }, - })?; - - // Convert ParsedSegment → GtsIdSegment - let gts_id_segments: Vec = parsed_segments - .into_iter() - .enumerate() - .map(|(i, p)| GtsIdSegment { - num: i + 1, - offset: p.offset, - segment: p.raw, - vendor: p.vendor, - package: p.package, - namespace: p.namespace, - type_name: p.type_name, - ver_major: p.ver_major, - ver_minor: p.ver_minor, - is_type: p.is_type, - is_wildcard: p.is_wildcard, - is_uuid_tail: p.is_uuid_tail, - }) - .collect(); - - // Issue #37: Single-segment instance IDs are prohibited. - // Instance IDs must be chained with at least one type segment (e.g., 'type~instance'). - // Exception: combined anonymous instances (UUID tail) are always valid. - let has_uuid_tail = gts_id_segments.last().is_some_and(|s| s.is_uuid_tail); - if !has_uuid_tail - && gts_id_segments.len() == 1 - && !gts_id_segments[0].is_type - && !gts_id_segments[0].is_wildcard - { - return Err(GtsError::Id { - id: id.to_owned(), - cause: "Single-segment instance IDs are prohibited. Instance IDs must be chained with at least one type segment (e.g., 'type~instance')".to_owned(), - }); - } - - Ok(GtsID { - id: raw.to_owned(), - gts_id_segments, - }) - } - - #[must_use] - pub fn is_type(&self) -> bool { - self.id.ends_with('~') - } - - #[must_use] - pub fn get_type_id(&self) -> Option { - if self.gts_id_segments.len() < 2 { - return None; - } - let segments: String = self.gts_id_segments[..self.gts_id_segments.len() - 1] - .iter() - .map(|s| s.segment.as_str()) - .collect::>() - .join(""); - Some(format!("{GTS_PREFIX}{segments}")) - } - - /// Generate a deterministic UUID v5 from this GTS ID. - #[must_use] - pub fn to_uuid(&self) -> Uuid { - Uuid::new_v5(>S_NS, self.id.as_bytes()) - } - - /// Check if a string is a valid GTS identifier. - #[must_use] - pub fn is_valid(s: &str) -> bool { - if !s.starts_with(GTS_PREFIX) { - return false; - } - Self::new(s).is_ok() - } - - /// Check if this GTS ID matches a wildcard pattern. - #[must_use] - pub fn wildcard_match(&self, pattern: &GtsWildcard) -> bool { - let p = &pattern.id; - - // No wildcard case - need exact match with version flexibility - if !p.contains('*') { - return Self::match_segments(&pattern.gts_id_segments, &self.gts_id_segments); - } - - // Wildcard case - if p.matches('*').count() > 1 || !p.ends_with('*') { - return false; - } - - Self::match_segments(&pattern.gts_id_segments, &self.gts_id_segments) - } - - fn match_segments(pattern_segs: &[GtsIdSegment], candidate_segs: &[GtsIdSegment]) -> bool { - // If pattern is longer than candidate, no match - if pattern_segs.len() > candidate_segs.len() { - return false; - } - - for (i, p_seg) in pattern_segs.iter().enumerate() { - let c_seg = &candidate_segs[i]; - - // If pattern segment is a wildcard, check non-wildcard fields first - if p_seg.is_wildcard { - if !p_seg.vendor.is_empty() && p_seg.vendor != c_seg.vendor { - return false; - } - if !p_seg.package.is_empty() && p_seg.package != c_seg.package { - return false; - } - if !p_seg.namespace.is_empty() && p_seg.namespace != c_seg.namespace { - return false; - } - if !p_seg.type_name.is_empty() && p_seg.type_name != c_seg.type_name { - return false; - } - if p_seg.ver_major != 0 && p_seg.ver_major != c_seg.ver_major { - return false; - } - if let Some(p_minor) = p_seg.ver_minor - && Some(p_minor) != c_seg.ver_minor - { - return false; - } - if p_seg.is_type && p_seg.is_type != c_seg.is_type { - return false; - } - // Wildcard matches - accept anything after this point - return true; - } - - // Non-wildcard UUID tail - compare raw segment string (the actual UUID) - if p_seg.is_uuid_tail && p_seg.segment != c_seg.segment { - return false; - } - - // Non-wildcard segment - all fields must match exactly - if p_seg.vendor != c_seg.vendor { - return false; - } - if p_seg.package != c_seg.package { - return false; - } - if p_seg.namespace != c_seg.namespace { - return false; - } - if p_seg.type_name != c_seg.type_name { - return false; - } - - // Check version matching - if p_seg.ver_major != c_seg.ver_major { - return false; - } - - // Minor version: if pattern has no minor version, accept any minor in candidate - if let Some(p_minor) = p_seg.ver_minor - && Some(p_minor) != c_seg.ver_minor - { - return false; - } - - // Check is_type flag matches - if p_seg.is_type != c_seg.is_type { - return false; - } - } - - true - } - - /// Splits a GTS ID with an optional attribute path. - /// - /// # Errors - /// Returns `GtsError::Id` if the path is empty after the `@` separator. - pub fn split_at_path(gts_with_path: &str) -> Result<(String, Option), GtsError> { - if !gts_with_path.contains('@') { - return Ok((gts_with_path.to_owned(), None)); - } - - let parts: Vec<&str> = gts_with_path.splitn(2, '@').collect(); - let gts = parts[0].to_owned(); - let path = parts.get(1).map(|s| (*s).to_owned()); - - if let Some(ref p) = path - && p.is_empty() - { - return Err(GtsError::Id { - id: gts_with_path.to_owned(), - cause: "Attribute path cannot be empty".to_owned(), - }); - } - - Ok((gts, path)) - } -} - -impl fmt::Display for GtsID { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.id) - } -} - -impl FromStr for GtsID { - type Err = GtsError; - - fn from_str(s: &str) -> Result { - Self::new(s) - } -} - -impl AsRef for GtsID { - fn as_ref(&self) -> &str { - &self.id - } -} - -/// GTS Wildcard pattern -#[derive(Debug, Clone, PartialEq)] -pub struct GtsWildcard { - pub id: String, - pub gts_id_segments: Vec, -} - -impl GtsWildcard { - /// Returns the non-wildcard prefix of a pattern string. - /// - /// For `"gts.x.core.srr.resource.v1~*"` returns `"gts.x.core.srr.resource.v1~"`. - /// For an exact pattern (no `*`) returns the full string. - fn prefix_str(pattern: &str) -> &str { - match pattern.find('*') { - Some(idx) => &pattern[..idx], - None => pattern, - } - } - - /// Returns `true` if there is at least one GTS ID that matches **both** patterns. - /// - /// Two patterns overlap when one pattern's fixed prefix is a prefix of the - /// other's fixed prefix (or they share the same prefix), meaning there exists - /// at least one concrete GTS ID that satisfies both constraints. - /// - /// # Examples - /// - /// ```ignore - /// let broad = GtsWildcard::new("gts.x.core.srr.resource.v1~*")?; - /// let narrow = GtsWildcard::new("gts.x.core.srr.resource.v1~acme.*")?; - /// assert!(broad.overlaps(&narrow)); // "acme.*" is a subset of "*" - /// - /// let other = GtsWildcard::new("gts.x.core.other.resource.v1~*")?; - /// assert!(!broad.overlaps(&other)); // different base type — no overlap - /// ``` - #[must_use] - pub fn overlaps(&self, other: &GtsWildcard) -> bool { - let a = Self::prefix_str(&self.id); - let b = Self::prefix_str(&other.id); - a.starts_with(b) || b.starts_with(a) - } - - /// Returns `true` if every GTS ID matching `self` also matches `other`. - /// - /// In other words, `self` is a **narrower** (more specific) pattern than `other`: - /// the effective type set of `self` is a subset of the effective type set of `other`. - /// - /// # Examples - /// - /// ```ignore - /// let broad = GtsWildcard::new("gts.x.core.srr.resource.v1~*")?; - /// let narrow = GtsWildcard::new("gts.x.core.srr.resource.v1~acme.*")?; - /// assert!(narrow.is_subset_of(&broad)); - /// assert!(!broad.is_subset_of(&narrow)); - /// ``` - #[must_use] - pub fn is_subset_of(&self, other: &GtsWildcard) -> bool { - let a = Self::prefix_str(&self.id); - let b = Self::prefix_str(&other.id); - a.starts_with(b) - } - - /// Creates a new GTS wildcard pattern. - /// - /// # Errors - /// Returns `GtsError::Wildcard` if the pattern is invalid. - pub fn new(pattern: &str) -> Result { - let p = pattern.trim(); - - if !p.starts_with(GTS_PREFIX) { - return Err(GtsError::Wildcard { - pattern: pattern.to_owned(), - cause: format!("Does not start with '{GTS_PREFIX}'"), - }); - } - - if p.matches('*').count() > 1 { - return Err(GtsError::Wildcard { - pattern: pattern.to_owned(), - cause: "The wildcard '*' token is allowed only once".to_owned(), - }); - } - - if p.contains('*') && !p.ends_with(".*") && !p.ends_with("~*") { - return Err(GtsError::Wildcard { - pattern: pattern.to_owned(), - cause: "The wildcard '*' token is allowed only at the end of the pattern" - .to_owned(), - }); - } - - // Try to parse as GtsID - let gts_id = GtsID::new(p).map_err(|e| GtsError::Wildcard { - pattern: pattern.to_owned(), - cause: e.to_string(), - })?; - - Ok(GtsWildcard { - id: gts_id.id, - gts_id_segments: gts_id.gts_id_segments, - }) - } -} - -impl fmt::Display for GtsWildcard { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.id) - } -} - -impl FromStr for GtsWildcard { - type Err = GtsError; - - fn from_str(s: &str) -> Result { - Self::new(s) - } -} +use std::fmt; -impl AsRef for GtsWildcard { - fn as_ref(&self) -> &str { - &self.id - } -} +pub use gts_id::{ + GTS_PREFIX, GtsId, GtsIdError, GtsIdPattern, GtsIdPatternSegment, GtsIdSegment, + GtsIdSegmentParts, GtsUuidTail, +}; /// A type-safe wrapper for GTS entity identifiers. /// /// `GtsEntityId` wraps a fully-formed GTS entity ID string (e.g., -/// `gts.x.core.events.topic.v1~vendor.app.orders.v1.0`). It can be used as a map key, -/// compared for equality, hashed, and serialized/deserialized. +/// `gts.x.core.events.topic.v1~vendor.app.orders.v1.0`). It is crate-private +/// plumbing for the schema-aware wrappers ([`GtsTypeId`], [`GtsInstanceId`]) and +/// performs no validation itself — validation lives in their `try_new` +/// constructors. #[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct GtsEntityId(String); +pub(crate) struct GtsEntityId(String); impl GtsEntityId { - /// Creates a new GTS entity ID from a string. - /// Must be private as it's used by `GtsInstanceId::new()` or `GtsEntityId::new()`. - #[must_use] + /// Creates a new GTS entity ID from a string, without validation. fn new(id: &str) -> Self { Self(id.to_owned()) } /// Returns the underlying string representation of the entity ID. - #[must_use] fn into_string(self) -> String { self.0 } @@ -505,11 +53,9 @@ impl AsRef for GtsEntityId { } } -impl From for String { - fn from(id: GtsEntityId) -> Self { - id.0 - } -} +/// URI-compatible prefix for GTS identifiers in JSON Schema `$id` field (e.g., `gts://gts.x.y.z...`). +/// This is ONLY used for JSON Schema serialization/deserialization, not for GTS ID parsing. +pub const GTS_URI_PREFIX: &str = "gts://"; /// A type-safe wrapper for GTS instance identifiers. /// @@ -543,7 +89,7 @@ impl<'de> serde::Deserialize<'de> for GtsInstanceId { D: serde::Deserializer<'de>, { let s = String::deserialize(deserializer)?; - Ok(GtsInstanceId(GtsEntityId(s))) + GtsInstanceId::try_new(&s).map_err(serde::de::Error::custom) } } @@ -587,7 +133,7 @@ impl GtsInstanceId { /// /// # Example /// ``` - /// use gts::gts::GtsInstanceId; + /// use gts::GtsInstanceId; /// /// let schema = GtsInstanceId::json_schema_value(); /// assert_eq!(schema["type"], "string"); @@ -620,6 +166,46 @@ impl GtsInstanceId { Self(GtsEntityId::new(&format!("{schema_id}{segment}"))) } + /// Creates a new GTS instance ID from a fully-formed string, validating + /// that it is a well-formed *instance* identifier. + /// + /// Unlike the infallible [`GtsInstanceId::new`], this constructor enforces + /// the instance/type discrimination via the type system rather than relying + /// on downstream `ends_with('~')` string checks. The string is first parsed + /// and structurally validated via [`GtsId::try_new`], then classified: + /// + /// * it must parse as a valid GTS identifier, and + /// * it must **not** be a type id (a trailing `~` denotes a type id). + /// + /// A successfully parsed instance id is always chained with at least one + /// type segment (single-segment instance ids are rejected by [`GtsId::try_new`]), + /// so it necessarily contains `~`. + /// + /// # Errors + /// Returns the underlying [`GtsIdError`] if the string fails GTS ID + /// validation, or [`GtsIdError`] if it is a (trailing-`~`) type id. + /// + /// # Example + /// ``` + /// use gts::GtsInstanceId; + /// + /// assert!(GtsInstanceId::try_new("gts.x.core.events.event.v1~a.b.c.d.v1.0").is_ok()); + /// // Trailing '~' is a type id, not an instance id: + /// assert!(GtsInstanceId::try_new("gts.x.core.events.event.v1~").is_err()); + /// // A bare single-segment id is not a chained instance id: + /// assert!(GtsInstanceId::try_new("gts.x.core.events.event.v1").is_err()); + /// ``` + pub fn try_new(instance_id: &str) -> Result { + let parsed = GtsId::try_new(instance_id)?; + if parsed.is_type() { + return Err(GtsIdError::new( + instance_id, + "GTS instance IDs must not end with '~' (a trailing '~' denotes a type id)", + )); + } + Ok(Self(GtsEntityId::new(parsed.as_ref()))) + } + /// Returns the underlying string representation of the instance ID. #[must_use] pub fn into_string(self) -> String { @@ -641,7 +227,7 @@ impl AsRef for GtsInstanceId { impl From for String { fn from(id: GtsInstanceId) -> Self { - id.0.into() + id.0.into_string() } } @@ -680,7 +266,7 @@ impl PartialEq for GtsInstanceId { /// # Example /// /// ``` -/// use gts::gts::GtsTypeId; +/// use gts::GtsTypeId; /// /// let id = GtsTypeId::new("gts.x.core.events.topic.v1~vendor.app.orders.v1.0~"); /// assert_eq!(id.as_ref(), "gts.x.core.events.topic.v1~vendor.app.orders.v1.0~"); @@ -707,7 +293,7 @@ impl<'de> serde::Deserialize<'de> for GtsTypeId { D: serde::Deserializer<'de>, { let s = String::deserialize(deserializer)?; - Ok(GtsTypeId(GtsEntityId(s))) + GtsTypeId::try_new(&s).map_err(serde::de::Error::custom) } } @@ -751,7 +337,7 @@ impl GtsTypeId { /// /// # Example /// ``` - /// use gts::gts::GtsTypeId; + /// use gts::GtsTypeId; /// /// let schema = GtsTypeId::json_schema_value(); /// assert_eq!(schema["type"], "string"); @@ -783,6 +369,37 @@ impl GtsTypeId { Self(GtsEntityId::new(type_id)) } + /// Creates a new GTS type ID from a string, validating that it is a + /// well-formed *type* identifier. + /// + /// Unlike the infallible [`GtsTypeId::new`], this constructor enforces the + /// type/instance discrimination via the type system rather than relying on + /// downstream `ends_with('~')` string checks. The string is first parsed and + /// structurally validated via [`GtsId::try_new`], then classified: + /// + /// * it must parse as a valid GTS identifier, and + /// * it must be a type id (i.e. end with `~`). + /// + /// # Errors + /// Returns the underlying [`GtsIdError`] if the string fails GTS ID + /// validation, or [`GtsIdError`] if it is an instance id (no trailing `~`). + /// + /// # Example + /// ``` + /// use gts::GtsTypeId; + /// + /// assert!(GtsTypeId::try_new("gts.x.core.events.event.v1~").is_ok()); + /// // An instance id (no trailing '~') is not a type id: + /// assert!(GtsTypeId::try_new("gts.x.core.events.event.v1~a.b.c.d.v1.0").is_err()); + /// ``` + pub fn try_new(type_id: &str) -> Result { + let parsed = GtsId::try_new(type_id)?; + if !parsed.is_type() { + return Err(GtsIdError::new(type_id, "GTS type IDs must end with '~'")); + } + Ok(Self(GtsEntityId::new(parsed.as_ref()))) + } + /// Returns the underlying string representation of the type ID. #[must_use] pub fn into_string(self) -> String { @@ -804,7 +421,7 @@ impl AsRef for GtsTypeId { impl From for String { fn from(id: GtsTypeId) -> Self { - id.0.into() + id.0.into_string() } } @@ -840,581 +457,60 @@ mod tests { use super::*; #[test] - fn test_gts_id_valid() { - let id = GtsID::new("gts.x.core.events.event.v1~").expect("test"); - assert_eq!(id.id, "gts.x.core.events.event.v1~"); - assert!(id.is_type()); - assert_eq!(id.gts_id_segments.len(), 1); - } - - #[test] - fn test_gts_id_with_minor_version() { - let id = GtsID::new("gts.x.core.events.event.v1.2~").expect("test"); - assert_eq!(id.id, "gts.x.core.events.event.v1.2~"); - assert!(id.is_type()); - let seg = &id.gts_id_segments[0]; - assert_eq!(seg.vendor, "x"); - assert_eq!(seg.package, "core"); - assert_eq!(seg.namespace, "events"); - assert_eq!(seg.type_name, "event"); - assert_eq!(seg.ver_major, 1); - assert_eq!(seg.ver_minor, Some(2)); - } - - #[test] - fn test_gts_id_instance() { - let id = GtsID::new("gts.x.core.events.event.v1~a.b.c.d.v1.0").expect("test"); - assert_eq!(id.id, "gts.x.core.events.event.v1~a.b.c.d.v1.0"); - assert!(!id.is_type()); - } - - #[test] - fn test_gts_id_invalid_uppercase() { - let result = GtsID::new("gts.X.core.events.event.v1~"); - assert!(result.is_err()); - } - - #[test] - fn test_gts_id_invalid_no_prefix() { - let result = GtsID::new("x.core.events.event.v1~"); - assert!(result.is_err()); - } - - #[test] - fn test_gts_id_invalid_hyphen() { - let result = GtsID::new("gts.x-vendor.core.events.event.v1~"); - assert!(result.is_err()); - } - - #[test] - fn test_gts_wildcard_simple() { - let pattern = GtsWildcard::new("gts.x.core.events.*").expect("test"); - let id = GtsID::new("gts.x.core.events.event.v1~").expect("test"); - assert!(id.wildcard_match(&pattern)); - } - - #[test] - fn test_gts_wildcard_no_match() { - let pattern = GtsWildcard::new("gts.x.core.events.*").expect("test"); - let id = GtsID::new("gts.y.core.events.event.v1~").expect("test"); - assert!(!id.wildcard_match(&pattern)); - } - - #[test] - fn test_gts_wildcard_type_suffix() { - // Wildcard after ~ should match type IDs - let pattern = GtsWildcard::new("gts.x.core.events.*").expect("test"); - let id = GtsID::new("gts.x.core.events.event.v1~").expect("test"); - assert!(id.wildcard_match(&pattern)); - } + fn test_type_id_try_new_accepts_type_rejects_instance() { + // A trailing-'~' type id is accepted. + let id = GtsTypeId::try_new("gts.x.core.events.event.v1~").expect("test"); + assert_eq!(id.as_ref(), "gts.x.core.events.event.v1~"); - #[test] - fn test_uuid_generation() { - let id = GtsID::new("gts.x.core.events.event.v1~").expect("test"); - let uuid1 = id.to_uuid(); - let uuid2 = id.to_uuid(); - // UUIDs should be deterministic - assert_eq!(uuid1, uuid2); - assert!(!uuid1.to_string().is_empty()); - } + // A valid instance id (no trailing '~') is classified out. + let err = GtsTypeId::try_new("gts.x.core.events.event.v1~a.b.c.d.v1.0") + .expect_err("must reject instance id"); + assert!(err.to_string().contains("must end with '~'")); - #[test] - fn test_uuid_different_ids() { - let id1 = GtsID::new("gts.x.core.events.event.v1~").expect("test"); - let id2 = GtsID::new("gts.x.core.events.event.v2~").expect("test"); - assert_ne!(id1.to_uuid(), id2.to_uuid()); + // A wholly invalid CTI is rejected by GtsId::try_new before classification. + assert!(GtsTypeId::try_new("not a valid cti~").is_err()); } #[test] - fn test_get_type_id() { - // get_type_id is for chained IDs - returns None for single segment - let id = GtsID::new("gts.x.core.events.event.v1~").expect("test"); - let type_id = id.get_type_id(); - assert!(type_id.is_none()); + fn test_instance_id_try_new_accepts_instance_rejects_type() { + // A chained, non-type id is accepted. + let id = GtsInstanceId::try_new("gts.x.core.events.event.v1~a.b.c.d.v1.0").expect("test"); + assert_eq!(id.as_ref(), "gts.x.core.events.event.v1~a.b.c.d.v1.0"); - // For chained IDs, it returns the base type - let chained = - GtsID::new("gts.x.core.events.type.v1~vendor.app._.custom.v1~").expect("test"); - let base_type = chained.get_type_id(); - assert!(base_type.is_some()); - assert_eq!(base_type.expect("test"), "gts.x.core.events.type.v1~"); - } + // A valid type id (trailing '~') is classified out. + let err = + GtsInstanceId::try_new("gts.x.core.events.event.v1~").expect_err("must reject type id"); + assert!(err.to_string().contains("must not end with '~'")); - #[test] - fn test_split_at_path() { - let (gts, path) = - GtsID::split_at_path("gts.x.core.events.event.v1~@field.subfield").expect("test"); - assert_eq!(gts, "gts.x.core.events.event.v1~"); - assert_eq!(path, Some("field.subfield".to_owned())); + // A wholly invalid CTI is rejected by GtsId::try_new before classification. + assert!(GtsInstanceId::try_new("not a valid cti").is_err()); } #[test] - fn test_split_at_path_no_path() { - let (gts, path) = GtsID::split_at_path("gts.x.core.events.event.v1~").expect("test"); - assert_eq!(gts, "gts.x.core.events.event.v1~"); - assert_eq!(path, None); - } + fn test_deserialize_routes_through_validated_constructors() { + // Deserialization must reuse the validating `try_new` constructors, not + // the infallible internal newtype. Valid ids round-trip. + let instance: GtsInstanceId = + serde_json::from_str("\"gts.x.core.events.event.v1~a.b.c.d.v1.0\"").expect("valid"); + assert_eq!(instance.as_ref(), "gts.x.core.events.event.v1~a.b.c.d.v1.0"); - #[test] - fn test_split_at_path_empty_path_error() { - let result = GtsID::split_at_path("gts.x.core.events.event.v1~@"); - assert!(result.is_err()); - } + let type_id: GtsTypeId = + serde_json::from_str("\"gts.x.core.events.event.v1~\"").expect("valid"); + assert_eq!(type_id.as_ref(), "gts.x.core.events.event.v1~"); - #[test] - fn test_is_valid() { - assert!(GtsID::is_valid("gts.x.core.events.event.v1~")); - assert!(!GtsID::is_valid("invalid")); - assert!(!GtsID::is_valid("gts.X.core.events.event.v1~")); - } + // Structurally invalid strings are rejected during deserialization. + assert!(serde_json::from_str::("\"not a valid cti\"").is_err()); + assert!(serde_json::from_str::("\"not a valid cti~\"").is_err()); - #[test] - fn test_version_flexibility_in_matching() { - // Pattern without minor version should match any minor version - let pattern = GtsWildcard::new("gts.x.core.events.event.v1~").expect("test"); - let id_no_minor = GtsID::new("gts.x.core.events.event.v1~").expect("test"); - let id_with_minor = GtsID::new("gts.x.core.events.event.v1.0~").expect("test"); - - assert!(id_no_minor.wildcard_match(&pattern)); - assert!(id_with_minor.wildcard_match(&pattern)); - } - - #[test] - fn test_chained_identifiers() { - let id = - GtsID::new("gts.x.core.events.type.v1~vendor.app._.custom_event.v1~").expect("test"); - assert_eq!(id.gts_id_segments.len(), 2); - assert_eq!(id.gts_id_segments[0].vendor, "x"); - assert_eq!(id.gts_id_segments[1].vendor, "vendor"); - } - - #[test] - fn test_gts_id_segment_validation() { - // Test invalid segment with special characters - let result = GtsIdSegment::new(0, 0, "invalid-segment"); - assert!(result.is_err()); - - // Test valid segment - let result = GtsIdSegment::new(0, 0, "x.core.events.event.v1"); - assert!(result.is_ok()); - } - - #[test] - fn test_gts_id_with_underscore() { - // Underscores are allowed in namespace - let id = GtsID::new("gts.x.core._.event.v1~").expect("test"); - assert_eq!(id.gts_id_segments[0].namespace, "_"); - } - - #[test] - fn test_gts_wildcard_exact_match() { - let pattern = GtsWildcard::new("gts.x.core.events.event.v1~").expect("test"); - let id = GtsID::new("gts.x.core.events.event.v1~").expect("test"); - assert!(id.wildcard_match(&pattern)); - } - - #[test] - fn test_gts_wildcard_version_mismatch() { - let pattern = GtsWildcard::new("gts.x.core.events.event.v2~").expect("test"); - let id = GtsID::new("gts.x.core.events.event.v1~").expect("test"); - assert!(!id.wildcard_match(&pattern)); - } - - #[test] - fn test_gts_wildcard_with_minor_version() { - let pattern = GtsWildcard::new("gts.x.core.events.event.v1.0~").expect("test"); - let id = GtsID::new("gts.x.core.events.event.v1.0~").expect("test"); - assert!(id.wildcard_match(&pattern)); - } - - #[test] - fn test_gts_wildcard_invalid_pattern() { - let result = GtsWildcard::new("invalid"); - assert!(result.is_err()); - } - - #[test] - fn test_gts_id_invalid_version_format() { - let result = GtsID::new("gts.x.core.events.event.vX~"); - assert!(result.is_err()); - } - - #[test] - fn test_gts_id_missing_segments() { - let result = GtsID::new("gts.x.core~"); - assert!(result.is_err()); - } - - #[test] - fn test_gts_id_empty_segment() { - let result = GtsID::new("gts.x..events.event.v1~"); - assert!(result.is_err()); - } - - #[test] - fn test_gts_wildcard_multiple_wildcards_error() { - let result = GtsWildcard::new("gts.*.*.*.*"); - assert!(result.is_err()); - } - - #[test] - fn test_split_at_path_multiple_at_signs() { - // Should only split at first @ - let (gts, path) = - GtsID::split_at_path("gts.x.core.events.event.v1~@field@subfield").expect("test"); - assert_eq!(gts, "gts.x.core.events.event.v1~"); - assert_eq!(path, Some("field@subfield".to_owned())); - } - - #[test] - fn test_gts_wildcard_instance_match() { - let pattern = GtsWildcard::new("gts.x.core.events.*").expect("test"); - let id = GtsID::new("gts.x.core.events.event.v1~a.b.c.d.v1.0").expect("test"); - assert!(id.wildcard_match(&pattern)); - } - - #[test] - fn test_gts_id_whitespace_trimming() { - let id = GtsID::new(" gts.x.core.events.event.v1~ ").expect("test"); - assert_eq!(id.id, "gts.x.core.events.event.v1~"); - } - - #[test] - fn test_gts_wildcard_whitespace_trimming() { - let pattern = GtsWildcard::new(" gts.x.core.events.* ").expect("test"); - assert_eq!(pattern.id, "gts.x.core.events.*"); - } - - #[test] - fn test_gts_id_long_chain() { - let id = GtsID::new("gts.a.b.c.d.v1~e.f.g.h.v2~i.j.k.l.v3~").expect("test"); - assert_eq!(id.gts_id_segments.len(), 3); - } - - #[test] - fn test_gts_wildcard_only_at_end() { - // Wildcard in middle should fail - let result1 = GtsWildcard::new("gts.*.core.events.event.v1~"); - assert!(result1.is_err()); - - // Wildcard at end should work - let pattern2 = GtsWildcard::new("gts.x.core.events.*").expect("test"); - let id2 = GtsID::new("gts.x.core.events.event.v1~").expect("test"); - assert!(id2.wildcard_match(&pattern2)); - } - - #[test] - fn test_gts_id_version_without_minor() { - let id = GtsID::new("gts.x.core.events.event.v1~").expect("test"); - assert_eq!(id.gts_id_segments[0].ver_major, 1); - assert_eq!(id.gts_id_segments[0].ver_minor, None); - } - - #[test] - fn test_gts_id_version_with_large_numbers() { - let id = GtsID::new("gts.x.core.events.event.v99.999~").expect("test"); - assert_eq!(id.gts_id_segments[0].ver_major, 99); - assert_eq!(id.gts_id_segments[0].ver_minor, Some(999)); - } - - #[test] - fn test_gts_wildcard_no_wildcard_different_vendor() { - let pattern = GtsWildcard::new("gts.x.core.events.event.v1~").expect("test"); - let id = GtsID::new("gts.y.core.events.event.v1~").expect("test"); - assert!(!id.wildcard_match(&pattern)); - } - - #[test] - fn test_gts_id_invalid_double_tilde() { - let result = GtsID::new("gts.x.core.events.event.v1~~"); - assert!(result.is_err()); - } - - #[test] - fn test_split_at_path_with_hash() { - // Hash is not a separator, should be part of the ID - let (gts, path) = GtsID::split_at_path("gts.x.core.events.event.v1~#field").expect("test"); - assert_eq!(gts, "gts.x.core.events.event.v1~#field"); - assert_eq!(path, None); - } - - #[test] - fn test_gts_id_display_trait() { - let id = GtsID::new("gts.x.core.events.event.v1~").expect("test"); - assert_eq!(format!("{id}"), "gts.x.core.events.event.v1~"); - } - - #[test] - fn test_gts_id_from_str_trait() { - let id: GtsID = "gts.x.core.events.event.v1~".parse().expect("test"); - assert_eq!(id.id, "gts.x.core.events.event.v1~"); - } - - #[test] - fn test_gts_id_as_ref_trait() { - let id = GtsID::new("gts.x.core.events.event.v1~").expect("test"); - let s: &str = id.as_ref(); - assert_eq!(s, "gts.x.core.events.event.v1~"); - } - - #[test] - fn test_gts_wildcard_display_trait() { - let pattern = GtsWildcard::new("gts.x.core.events.*").expect("test"); - assert_eq!(format!("{pattern}"), "gts.x.core.events.*"); - } - - #[test] - fn test_gts_wildcard_from_str_trait() { - let pattern: GtsWildcard = "gts.x.core.events.*".parse().expect("test"); - assert_eq!(pattern.id, "gts.x.core.events.*"); - } - - #[test] - fn test_gts_wildcard_as_ref_trait() { - let pattern = GtsWildcard::new("gts.x.core.events.*").expect("test"); - let s: &str = pattern.as_ref(); - assert_eq!(s, "gts.x.core.events.*"); - } - - #[test] - fn test_gts_id_new_with_uri_prefix() { - // Should reject gts:// prefix - assert!(GtsID::new("gts://x.core.v1~").is_err()); - } - - #[test] - fn test_gts_id_minimum_segments() { - // Too few segments - assert!(GtsID::new("gts~").is_err()); - assert!(GtsID::new("gts.x~").is_err()); - assert!(GtsID::new("gts.x.pkg~").is_err()); - assert!(GtsID::new("gts.x.pkg.ns~").is_err()); - - // Minimum valid (vendor.package.namespace.type.version) - assert!(GtsID::new("gts.x.pkg.ns.type.v1~").is_ok()); - } - - #[test] - fn test_gts_id_invalid_characters() { - assert!(GtsID::new("gts.x.test!.v1~").is_err()); - assert!(GtsID::new("gts.x.te$t.v1~").is_err()); - assert!(GtsID::new("gts.x.te st.v1~").is_err()); - } - - #[test] - fn test_gts_id_uppercase_rejected() { - assert!(GtsID::new("gts.x.Test.v1~").is_err()); - assert!(GtsID::new("gts.X.test.v1~").is_err()); - } - - #[test] - fn test_gts_id_hyphen_rejected() { - assert!(GtsID::new("gts.x.test-name.v1~").is_err()); - } - - #[test] - fn test_gts_id_digit_start_segment() { - // Digits at start of segment - assert!(GtsID::new("gts.x.9test.v1~").is_err()); - } - - #[test] - fn test_gts_id_with_numbers_midword() { - // Numbers in middle of segment are OK - assert!(GtsID::new("gts.x.test2name.ns.type.v1~").is_ok()); - assert!(GtsID::new("gts.x.pkg.item3.type.v1~").is_ok()); - } - - #[test] - fn test_gts_wildcard_type_suffix_match() { - // Wildcard after type suffix - let pattern = GtsWildcard::new("gts.x.pkg.ns.type.v1~*").expect("test"); - let id1 = GtsID::new("gts.x.pkg.ns.type.v1~a.b.c.child.v1~").expect("test"); - let id2 = GtsID::new("gts.x.pkg.ns.type.v2~a.b.c.child.v1~").expect("test"); - assert!(id1.wildcard_match(&pattern)); - assert!(!id2.wildcard_match(&pattern)); - } - - #[test] - fn test_split_at_path_valid_json_pointer() { - let (gts, path) = GtsID::split_at_path("gts.x.test.v1~@/properties/field").expect("test"); - assert_eq!(gts, "gts.x.test.v1~"); - assert_eq!(path, Some("/properties/field".to_owned())); - } - - #[test] - fn test_gts_id_segment_start_underscore() { - // Underscore at start is invalid - assert!(GtsID::new("gts.x._private.event.v1~").is_err()); - } - - #[test] - fn test_gts_id_multi_digit_versions() { - // Multi-digit version numbers - assert!(GtsID::new("gts.x.pkg.ns.event.v10~").is_ok()); - assert!(GtsID::new("gts.x.pkg.ns.event.v1.20~").is_ok()); - } - - #[test] - fn test_gts_segment_too_many_tildes() { - // Multiple tildes together (invalid segment) - let seg = GtsIdSegment::new(1, 0, "x.pkg.ns.type.v1~~"); - assert!(seg.is_err()); - if let Err(e) = seg { - assert!(e.to_string().contains("Too many '~' characters")); - } - } - - #[test] - fn test_gts_segment_tilde_not_at_end() { - // Tilde in middle of segment (not at end) - let seg = GtsIdSegment::new(1, 0, "x.pkg~mid.ns.type.v1"); - assert!(seg.is_err()); - if let Err(e) = seg { - assert!(e.to_string().contains("'~' must be at the end")); - } - } - - #[test] - fn test_gts_segment_too_many_tokens() { - // More than 6 tokens in a segment - let seg = GtsIdSegment::new(1, 0, "x.pkg.ns.type.v1.2.extra~"); - assert!(seg.is_err()); - if let Err(e) = seg { - assert!(e.to_string().contains("Too many tokens")); - } - } - - #[test] - fn test_gts_segment_version_without_v_prefix() { - // Version without 'v' prefix - let seg = GtsIdSegment::new(1, 0, "x.pkg.ns.type.1~"); - assert!(seg.is_err()); - if let Err(e) = seg { - assert!(e.to_string().contains("Major version must start with 'v'")); - } - } - - #[test] - fn test_gts_segment_version_leading_zeros() { - // Version with leading zeros (should fail because "01" != "1") - let seg = GtsIdSegment::new(1, 0, "x.pkg.ns.type.v01~"); - assert!(seg.is_err()); - if let Err(e) = seg { - assert!(e.to_string().contains("Major version must be an integer")); - } - } - - #[test] - fn test_gts_wildcard_at_various_positions() { - // Wildcard at vendor position - let result = GtsWildcard::new("gts.*"); - assert!(result.is_ok()); - - // Wildcard at package position - let result = GtsWildcard::new("gts.x.*"); - assert!(result.is_ok()); - - // Wildcard at namespace position - let result = GtsWildcard::new("gts.x.pkg.*"); - assert!(result.is_ok()); - - // Wildcard at type position - let result = GtsWildcard::new("gts.x.pkg.ns.*"); - assert!(result.is_ok()); - - // Wildcard at version position - let result = GtsWildcard::new("gts.x.pkg.ns.type.*"); - assert!(result.is_ok()); - } - - // ---- overlaps ---- - - #[test] - fn test_overlaps_broad_and_narrow() { - let broad = GtsWildcard::new("gts.x.core.srr.resource.v1~*").expect("test"); - let narrow = GtsWildcard::new("gts.x.core.srr.resource.v1~acme.*").expect("test"); - assert!(broad.overlaps(&narrow)); - assert!(narrow.overlaps(&broad)); // symmetric - } - - #[test] - fn test_overlaps_disjoint_types() { - let a = GtsWildcard::new("gts.x.core.srr.resource.v1~*").expect("test"); - let b = GtsWildcard::new("gts.x.core.other.resource.v1~*").expect("test"); - assert!(!a.overlaps(&b)); - assert!(!b.overlaps(&a)); - } - - #[test] - fn test_overlaps_same_pattern() { - let a = GtsWildcard::new("gts.x.core.srr.resource.v1~*").expect("test"); - let b = GtsWildcard::new("gts.x.core.srr.resource.v1~*").expect("test"); - assert!(a.overlaps(&b)); - } - - #[test] - fn test_overlaps_exact_vs_wildcard() { - let exact = - GtsWildcard::new("gts.x.core.srr.resource.v1~acme.crm._.contact.v1~").expect("test"); - let broad = GtsWildcard::new("gts.x.core.srr.resource.v1~*").expect("test"); - assert!(exact.overlaps(&broad)); - assert!(broad.overlaps(&exact)); - } - - #[test] - fn test_overlaps_tilde_star_chain() { - // "~*" pattern: any chained type under the base - let base = GtsWildcard::new("gts.x.core.srr.resource.v1~*").expect("test"); - let sub = GtsWildcard::new("gts.x.core.srr.resource.v1~acme.crm.*").expect("test"); - assert!(base.overlaps(&sub)); - } - - // ---- is_subset_of ---- - - #[test] - fn test_subset_narrow_is_subset_of_broad() { - let broad = GtsWildcard::new("gts.x.core.srr.resource.v1~*").expect("test"); - let narrow = GtsWildcard::new("gts.x.core.srr.resource.v1~acme.*").expect("test"); - assert!(narrow.is_subset_of(&broad)); - assert!(!broad.is_subset_of(&narrow)); - } - - #[test] - fn test_subset_identical_patterns() { - let a = GtsWildcard::new("gts.x.core.srr.resource.v1~*").expect("test"); - let b = GtsWildcard::new("gts.x.core.srr.resource.v1~*").expect("test"); - assert!(a.is_subset_of(&b)); // identical ⊆ identical - assert!(b.is_subset_of(&a)); - } - - #[test] - fn test_subset_disjoint_not_subset() { - let a = GtsWildcard::new("gts.x.core.srr.resource.v1~*").expect("test"); - let b = GtsWildcard::new("gts.x.core.other.resource.v1~*").expect("test"); - assert!(!a.is_subset_of(&b)); - assert!(!b.is_subset_of(&a)); - } - - #[test] - fn test_subset_exact_is_subset_of_wildcard() { - let exact = - GtsWildcard::new("gts.x.core.srr.resource.v1~acme.crm._.contact.v1~").expect("test"); - let broad = GtsWildcard::new("gts.x.core.srr.resource.v1~*").expect("test"); - assert!(exact.is_subset_of(&broad)); - assert!(!broad.is_subset_of(&exact)); - } - - #[test] - fn test_subset_three_levels() { - let l1 = GtsWildcard::new("gts.x.core.srr.resource.v1~*").expect("test"); - let l2 = GtsWildcard::new("gts.x.core.srr.resource.v1~acme.*").expect("test"); - let l3 = GtsWildcard::new("gts.x.core.srr.resource.v1~acme.crm.*").expect("test"); - assert!(l3.is_subset_of(&l2)); - assert!(l3.is_subset_of(&l1)); - assert!(l2.is_subset_of(&l1)); - assert!(!l1.is_subset_of(&l2)); - assert!(!l1.is_subset_of(&l3)); - assert!(!l2.is_subset_of(&l3)); + // A type id no longer deserializes as an instance id, and vice versa. + assert!( + serde_json::from_str::("\"gts.x.core.events.event.v1~\"").is_err(), + "a trailing-'~' type id must not deserialize as an instance id" + ); + assert!( + serde_json::from_str::("\"gts.x.core.events.event.v1~a.b.c.d.v1.0\"") + .is_err(), + "a non-'~' instance id must not deserialize as a type id" + ); } } diff --git a/gts/src/lib.rs b/gts/src/lib.rs index 3b34dd1..a1bbc90 100644 --- a/gts/src/lib.rs +++ b/gts/src/lib.rs @@ -19,7 +19,10 @@ pub use entities::{GtsConfig, GtsEntity, GtsFile, ValidationError, ValidationRes pub use files_reader::GtsFileReader; #[allow(deprecated)] pub use gts::GtsSchemaId; -pub use gts::{GtsError, GtsID, GtsIdSegment, GtsInstanceId, GtsTypeId, GtsWildcard}; +pub use gts::{ + GtsId, GtsIdError, GtsIdPattern, GtsIdPatternSegment, GtsIdSegment, GtsIdSegmentParts, + GtsInstanceId, GtsTypeId, GtsUuidTail, +}; pub use ops::GtsOps; pub use path_resolver::JsonPathResolver; pub use schema::{ diff --git a/gts/src/ops.rs b/gts/src/ops.rs index 4f13b00..ab66e4b 100644 --- a/gts/src/ops.rs +++ b/gts/src/ops.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use crate::entities::{GtsConfig, GtsEntity}; use crate::files_reader::GtsFileReader; -use crate::gts::{GtsID, GtsWildcard}; +use crate::gts::{GtsId, GtsIdPattern}; use crate::path_resolver::JsonPathResolver; use crate::schema_cast::GtsEntityCastResult; use crate::store::{GtsStore, GtsStoreQueryResult}; @@ -41,14 +41,37 @@ pub struct GtsIdSegmentInfo { impl From<&crate::gts::GtsIdSegment> for GtsIdSegmentInfo { fn from(seg: &crate::gts::GtsIdSegment) -> Self { + // A concrete segment always carries a real major version (including a + // legitimate `v0`), so `ver_major` is never the wildcard "unspecified" + // sentinel here. Self { - vendor: seg.vendor.clone(), - package: seg.package.clone(), - namespace: seg.namespace.clone(), - type_name: seg.type_name.clone(), - ver_major: Some(seg.ver_major), - ver_minor: seg.ver_minor, - is_type: seg.is_type, + vendor: seg.vendor().to_owned(), + package: seg.package().to_owned(), + namespace: seg.namespace().to_owned(), + type_name: seg.type_name().to_owned(), + ver_major: Some(seg.ver_major()), + ver_minor: seg.ver_minor(), + is_type: seg.is_type(), + } + } +} + +impl From<&crate::gts::GtsIdPatternSegment> for GtsIdSegmentInfo { + fn from(seg: &crate::gts::GtsIdPatternSegment) -> Self { + Self { + vendor: seg.vendor().to_owned(), + package: seg.package().to_owned(), + namespace: seg.namespace().to_owned(), + type_name: seg.type_name().to_owned(), + // For a wildcard segment, `ver_major() == 0` is the "unspecified" + // sentinel and must serialize as `null`. + ver_major: if seg.is_wildcard() && seg.ver_major() == 0 { + None + } else { + Some(seg.ver_major()) + }, + ver_minor: seg.ver_minor(), + is_type: seg.is_type(), } } } @@ -472,16 +495,16 @@ impl GtsOps { let contains_wildcard = gts_id.contains('*'); if contains_wildcard { - // Use GtsWildcard for wildcard pattern validation - it enforces: + // Use GtsIdPattern for wildcard pattern validation - it enforces: // - Only one '*' allowed // - '*' must be at end (ending with '.*' or '~*') // - No '*' in the middle of segments - match GtsWildcard::new(gts_id) { + match GtsIdPattern::try_new(gts_id) { Ok(w) => GtsIdValidationResult { id: gts_id.to_owned(), valid: true, error: String::new(), - is_type: Some(w.id.ends_with('~')), + is_type: Some(w.pattern().ends_with('~')), is_wildcard: true, }, Err(e) => GtsIdValidationResult { @@ -493,7 +516,7 @@ impl GtsOps { }, } } else { - match GtsID::new(gts_id) { + match GtsId::try_new(gts_id) { Ok(id) => GtsIdValidationResult { id: gts_id.to_owned(), valid: true, @@ -516,21 +539,17 @@ impl GtsOps { let contains_wildcard = gts_id.contains('*'); if contains_wildcard { - // Use GtsWildcard for wildcard pattern parsing/validation - match GtsWildcard::new(gts_id) { + // Use GtsIdPattern for wildcard pattern parsing/validation + match GtsIdPattern::try_new(gts_id) { Ok(w) => { - let segments = w - .gts_id_segments - .iter() - .map(GtsIdSegmentInfo::from) - .collect(); + let segments = w.segments().iter().map(GtsIdSegmentInfo::from).collect(); GtsIdParseResult { id: gts_id.to_owned(), ok: true, segments, error: String::new(), - is_type: Some(w.id.ends_with('~')), + is_type: Some(w.pattern().ends_with('~')), is_wildcard: true, } } @@ -544,13 +563,9 @@ impl GtsOps { }, } } else { - match GtsID::new(gts_id) { + match GtsId::try_new(gts_id) { Ok(id) => { - let segments = id - .gts_id_segments - .iter() - .map(GtsIdSegmentInfo::from) - .collect(); + let segments = id.segments().iter().map(GtsIdSegmentInfo::from).collect(); GtsIdParseResult { id: gts_id.to_owned(), @@ -575,58 +590,53 @@ impl GtsOps { #[must_use] pub fn match_id_pattern(candidate: &str, pattern: &str) -> GtsIdMatchResult { - // Both candidate and pattern can be either valid GTS ID or valid wildcard - // Try to parse both as GtsID or GtsWildcard - let candidate_result = if candidate.contains('*') { - GtsWildcard::new(candidate).map(|w| (w.id.clone(), w.gts_id_segments)) - } else { - GtsID::new(candidate).map(|g| (g.id.clone(), g.gts_id_segments)) - }; - - let pattern_result = if pattern.contains('*') { - GtsWildcard::new(pattern).map(|w| (w.id.clone(), w.gts_id_segments)) + // The pattern side is always a pattern; a concrete id is just a + // zero-`*` pattern, which `GtsIdPattern::try_new` accepts. + let pattern_result = GtsIdPattern::try_new(pattern); + + // The candidate may itself be a wildcard pattern. Either way it is matched + // against the pattern with the same field-level logic (minor-version + // flexibility, wildcard tails); `matches_pattern` is defined on both + // `GtsId` and `GtsIdPattern`. + let match_result: Result = if candidate.contains('*') { + match (GtsIdPattern::try_new(candidate), &pattern_result) { + (Ok(cand), Ok(pat)) => Ok(cand.matches_pattern(pat)), + (Err(e), _) => Err((true, e.to_string())), + (_, Err(e)) => Err((false, e.to_string())), + } } else { - GtsID::new(pattern).map(|g| (g.id.clone(), g.gts_id_segments)) + match (GtsId::try_new(candidate), &pattern_result) { + (Ok(cand), Ok(pat)) => Ok(cand.matches_pattern(pat)), + (Err(e), _) => Err((true, e.to_string())), + (_, Err(e)) => Err((false, e.to_string())), + } }; - match (candidate_result, pattern_result) { - (Ok((c_id, c_segments)), Ok((p_id, p_segments))) => { - let c = GtsID { - id: c_id, - gts_id_segments: c_segments, - }; - let p = GtsWildcard { - id: p_id, - gts_id_segments: p_segments, - }; - let is_match = c.wildcard_match(&p); - GtsIdMatchResult { - candidate: candidate.to_owned(), - pattern: pattern.to_owned(), - is_match, - error: String::new(), - } - } - (Err(e), _) => GtsIdMatchResult { + match match_result { + Ok(is_match) => GtsIdMatchResult { candidate: candidate.to_owned(), pattern: pattern.to_owned(), - is_match: false, - error: format!("Invalid candidate: {e}"), + is_match, + error: String::new(), }, - (_, Err(e)) => GtsIdMatchResult { + Err((is_candidate, e)) => GtsIdMatchResult { candidate: candidate.to_owned(), pattern: pattern.to_owned(), is_match: false, - error: format!("Invalid pattern: {e}"), + error: if is_candidate { + format!("Invalid candidate: {e}") + } else { + format!("Invalid pattern: {e}") + }, }, } } #[must_use] pub fn uuid(gts_id: &str) -> GtsUuidResult { - match GtsID::new(gts_id) { + match GtsId::try_new(gts_id) { Ok(g) => GtsUuidResult { - id: g.id.clone(), + id: g.id().to_owned(), uuid: g.to_uuid().to_string(), }, Err(_) => GtsUuidResult { @@ -805,7 +815,7 @@ impl GtsOps { } pub fn attr(&mut self, gts_with_path: &str) -> JsonPathResolver { - match GtsID::split_at_path(gts_with_path) { + match GtsId::split_at_path(gts_with_path) { Ok((gts, Some(path))) => { if let Some(entity) = self.store.get(>s) { entity.resolve_path(&path) @@ -850,7 +860,7 @@ impl GtsOps { id: entity .gts_id .as_ref() - .map_or_else(|| gts_id.to_owned(), |g| g.id.clone()), + .map_or_else(|| gts_id.to_owned(), |g| g.id().to_owned()), type_id: entity.type_id.clone(), is_type_schema: entity.is_schema, content: Some(entity.content.clone()), @@ -901,7 +911,7 @@ impl GtsOps { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; - use crate::gts::GtsID; + use crate::gts::GtsId; use serde_json::json; #[test] @@ -992,10 +1002,10 @@ mod tests { #[test] fn test_gts_id_validation() { - assert!(!GtsID::is_valid("gts.vendor.package.namespace.type.v1.0")); // Single-segment instance - should be invalid - assert!(GtsID::is_valid("gts.vendor.package.namespace.type.v1.0~")); // Single-segment type - should be valid - assert!(!GtsID::is_valid("invalid")); - assert!(!GtsID::is_valid("")); + assert!(!GtsId::is_valid("gts.vendor.package.namespace.type.v1.0")); // Single-segment instance - should be invalid + assert!(GtsId::is_valid("gts.vendor.package.namespace.type.v1.0~")); // Single-segment type - should be valid + assert!(!GtsId::is_valid("invalid")); + assert!(!GtsId::is_valid("")); } #[test] @@ -2494,6 +2504,24 @@ mod tests { assert!(result.is_match); } + #[test] + fn test_gts_ops_match_id_pattern_wildcard_candidate_directionality() { + let broad_candidate = GtsOps::match_id_pattern("gts.vendor.*", "gts.vendor.package.*"); + assert!( + !broad_candidate.is_match, + "A broader candidate pattern must not match a narrower pattern" + ); + + let narrow_candidate = GtsOps::match_id_pattern("gts.vendor.package.*", "gts.vendor.*"); + assert!( + narrow_candidate.is_match, + "A narrower candidate pattern should match a broader pattern" + ); + + let disjoint_candidate = GtsOps::match_id_pattern("gts.vendor.package.*", "gts.other.*"); + assert!(!disjoint_candidate.is_match); + } + #[test] fn test_gts_ops_match_id_pattern_invalid() { let result = GtsOps::match_id_pattern( @@ -3519,7 +3547,30 @@ mod tests { let result = GtsOps::parse_id("gts.vendor.package.namespace.*"); assert!(result.ok, "Parsing valid wildcard should succeed"); assert!(result.is_wildcard); - assert!(!result.segments.is_empty(), "Should have parsed segments"); + assert_eq!(result.segments.len(), 1); + assert_eq!(result.segments[0].vendor, "vendor"); + assert_eq!(result.segments[0].package, "package"); + assert_eq!(result.segments[0].namespace, "namespace"); + assert_eq!(result.segments[0].type_name, ""); + assert_eq!(result.segments[0].ver_major, None); + assert_eq!(result.segments[0].ver_minor, None); + assert!(!result.segments[0].is_type); + assert_eq!(result.is_type, Some(false)); + } + + #[test] + fn test_parse_id_with_version_wildcard_shape() { + let result = GtsOps::parse_id("gts.vendor.package.namespace.type.v*"); + assert!(result.ok, "Parsing valid version wildcard should succeed"); + assert!(result.is_wildcard); + assert_eq!(result.segments.len(), 1); + assert_eq!(result.segments[0].vendor, "vendor"); + assert_eq!(result.segments[0].package, "package"); + assert_eq!(result.segments[0].namespace, "namespace"); + assert_eq!(result.segments[0].type_name, "type"); + assert_eq!(result.segments[0].ver_major, None); + assert_eq!(result.segments[0].ver_minor, None); + assert!(!result.segments[0].is_type); assert_eq!(result.is_type, Some(false)); } @@ -3530,7 +3581,21 @@ mod tests { let result = GtsOps::parse_id("gts.vendor.package.namespace.type.v1~*"); assert!(result.ok, "Parsing valid wildcard should succeed"); assert!(result.is_wildcard); - assert!(!result.segments.is_empty(), "Should have parsed segments"); + assert_eq!(result.segments.len(), 2); + assert_eq!(result.segments[0].vendor, "vendor"); + assert_eq!(result.segments[0].package, "package"); + assert_eq!(result.segments[0].namespace, "namespace"); + assert_eq!(result.segments[0].type_name, "type"); + assert_eq!(result.segments[0].ver_major, Some(1)); + assert_eq!(result.segments[0].ver_minor, None); + assert!(result.segments[0].is_type); + assert_eq!(result.segments[1].vendor, ""); + assert_eq!(result.segments[1].package, ""); + assert_eq!(result.segments[1].namespace, ""); + assert_eq!(result.segments[1].type_name, ""); + assert_eq!(result.segments[1].ver_major, None); + assert_eq!(result.segments[1].ver_minor, None); + assert!(!result.segments[1].is_type); assert_eq!( result.is_type, Some(false), diff --git a/gts/src/schema_cast.rs b/gts/src/schema_cast.rs index ba37cdb..0489e0f 100644 --- a/gts/src/schema_cast.rs +++ b/gts/src/schema_cast.rs @@ -3,7 +3,7 @@ use serde_json::{Map, Value}; use std::collections::{HashMap, HashSet}; use thiserror::Error; -use crate::gts::GtsID; +use crate::gts::GtsId; #[derive(Debug, Error)] pub enum SchemaCastError { @@ -137,12 +137,10 @@ impl GtsEntityCastResult { #[must_use] pub fn infer_direction(from_id: &str, to_id: &str) -> String { - if let (Ok(gid_from), Ok(gid_to)) = (GtsID::new(from_id), GtsID::new(to_id)) - && let (Some(from_seg), Some(to_seg)) = ( - gid_from.gts_id_segments.last(), - gid_to.gts_id_segments.last(), - ) - && let (Some(from_minor), Some(to_minor)) = (from_seg.ver_minor, to_seg.ver_minor) + if let (Ok(gid_from), Ok(gid_to)) = (GtsId::try_new(from_id), GtsId::try_new(to_id)) + && let (Some(from_seg), Some(to_seg)) = + (gid_from.segments().last(), gid_to.segments().last()) + && let (Some(from_minor), Some(to_minor)) = (from_seg.ver_minor(), to_seg.ver_minor()) { if to_minor > from_minor { return "up".to_owned(); @@ -269,8 +267,8 @@ impl GtsEntityCastResult { && let Some(const_value) = p_obj.get("const") && let Some(old_value) = result.get(prop) && let (Some(const_str), Some(old_str)) = (const_value.as_str(), old_value.as_str()) - && GtsID::is_valid(const_str) - && GtsID::is_valid(old_str) + && GtsId::is_valid(const_str) + && GtsId::is_valid(old_str) && old_str != const_str { result.insert(prop.clone(), const_value.clone()); diff --git a/gts/src/store.rs b/gts/src/store.rs index 72bd3b7..35112f1 100644 --- a/gts/src/store.rs +++ b/gts/src/store.rs @@ -5,7 +5,7 @@ use std::sync::{Arc, RwLock}; use thiserror::Error; use crate::entities::GtsEntity; -use crate::gts::{GTS_URI_PREFIX, GtsID, GtsWildcard}; +use crate::gts::{GTS_URI_PREFIX, GtsId, GtsIdPattern}; use crate::schema_cast::GtsEntityCastResult; /// Custom retriever for resolving gts:// URI scheme references in JSON Schema validation @@ -155,7 +155,7 @@ impl GtsStore { return Err(StoreError::InvalidSchemaId); } - let gts_id = GtsID::new(type_id).map_err(|_| StoreError::InvalidSchemaId)?; + let gts_id = GtsId::try_new(type_id).map_err(|_| StoreError::InvalidSchemaId)?; let entity = GtsEntity::new( None, None, @@ -600,12 +600,17 @@ impl GtsStore { if ref_uri.starts_with('#') { // Valid local ref } - // GTS refs must use gts:// URI format + // GTS refs must use gts:// URI format and target a schema + // (type) document. Only `entity.is_schema` documents are + // registered for retrieval (see `GtsRetriever::new`), so an + // instance-id ref would pass a plain `is_valid` check here + // and then fail later during retrieval. Require a type id up + // front via `GtsTypeId::try_new` (valid GTS id ending in `~`). else if let Some(gts_id) = ref_uri.strip_prefix(GTS_URI_PREFIX) { - // Validate the GTS ID - if !GtsID::is_valid(gts_id) { + if crate::GtsTypeId::try_new(gts_id).is_err() { return Err(StoreError::InvalidRef(format!( - "at '{current_path}': '{ref_uri}' contains invalid GTS identifier '{gts_id}'" + "at '{current_path}': '{ref_uri}' must reference a GTS type id \ + (a valid identifier ending with '~'), got '{gts_id}'" ))); } } @@ -730,23 +735,23 @@ impl GtsStore { /// # Errors /// Returns `StoreError::ValidationError` if any derived schema loosens base constraints. pub(crate) fn validate_schema_chain(&mut self, gts_id: &str) -> Result<(), StoreError> { - let gid = GtsID::new(gts_id) + let gid = GtsId::try_new(gts_id) .map_err(|e| StoreError::ValidationError(format!("Invalid GTS ID: {e}")))?; // Single-segment schemas have no parent to validate against - if gid.gts_id_segments.len() < 2 { + if gid.segments().len() < 2 { return Ok(()); } // Build pairs of (base_id, derived_id) for each adjacent level // Note: segment.segment already includes the trailing '~' for type segments - let segments = &gid.gts_id_segments; + let segments = &gid.segments(); for i in 0..segments.len() - 1 { let base_id = format!( "gts.{}", segments[..=i] .iter() - .map(|s| s.segment.as_str()) + .map(gts_id::GtsIdSegment::raw) .collect::>() .join("") ); @@ -754,7 +759,7 @@ impl GtsStore { "gts.{}", segments[..=i + 1] .iter() - .map(|s| s.segment.as_str()) + .map(gts_id::GtsIdSegment::raw) .collect::>() .join("") ); @@ -832,10 +837,10 @@ impl GtsStore { /// # Errors /// Returns `StoreError::ValidationError` if trait validation fails. pub(crate) fn validate_schema_traits(&mut self, gts_id: &str) -> Result<(), StoreError> { - let gid = GtsID::new(gts_id) + let gid = GtsId::try_new(gts_id) .map_err(|e| StoreError::ValidationError(format!("Invalid GTS ID: {e}")))?; - let segments = &gid.gts_id_segments; + let segments = &gid.segments(); // Collect raw trait schemas and trait values from every schema in the chain. // We use *raw* content because resolve_schema_refs flattens allOf and only @@ -857,7 +862,7 @@ impl GtsStore { "gts.{}", segments[..=i] .iter() - .map(|s| s.segment.as_str()) + .map(gts_id::GtsIdSegment::raw) .collect::>() .join("") ); @@ -940,7 +945,7 @@ impl GtsStore { /// entity. Additionally, if a trait schema is defined but no `x-gts-traits` /// values exist anywhere in the chain, the entity is incomplete. pub(crate) fn validate_entity_traits(&mut self, gts_id: &str) -> Result<(), StoreError> { - let gid = GtsID::new(gts_id) + let gid = GtsId::try_new(gts_id) .map_err(|e| StoreError::ValidationError(format!("Invalid GTS ID: {e}")))?; // If the type named by `gts_id` is abstract, it is exempt from the OP#13 @@ -960,7 +965,7 @@ impl GtsStore { return Ok(()); } - let segments = &gid.gts_id_segments; + let segments = &gid.segments(); let mut trait_schemas: Vec = Vec::new(); let mut has_trait_values = false; @@ -970,7 +975,7 @@ impl GtsStore { "gts.{}", segments[..=i] .iter() - .map(|s| s.segment.as_str()) + .map(gts_id::GtsIdSegment::raw) .collect::>() .join("") ); @@ -1029,8 +1034,8 @@ impl GtsStore { pub fn validate_instance(&mut self, instance_id: &str) -> Result<(), StoreError> { // Try to parse as GTS ID first (for well-known instances) // If that fails, use the instance_id directly (for anonymous instances with UUIDs) - let lookup_id = if let Ok(gid) = GtsID::new(instance_id) { - gid.id + let lookup_id = if let Ok(gid) = GtsId::try_new(instance_id) { + gid.id().to_owned() } else { instance_id.to_owned() }; @@ -1156,8 +1161,8 @@ impl GtsStore { .gts_id .as_ref() .ok_or(StoreError::InvalidEntity)? - .id - .clone(); + .id() + .to_owned(); (from_entity.clone(), id) } else { let type_id = from_entity @@ -1405,7 +1410,7 @@ impl GtsStore { fn validate_query_pattern( base_pattern: &str, is_wildcard: bool, - ) -> (Option, Option, String) { + ) -> (Option, Option, String) { if is_wildcard { if !base_pattern.ends_with(".*") && !base_pattern.ends_with("~*") { return ( @@ -1414,14 +1419,14 @@ impl GtsStore { "Invalid query: wildcard patterns must end with .* or ~*".to_owned(), ); } - match GtsWildcard::new(base_pattern) { + match GtsIdPattern::try_new(base_pattern) { Ok(pattern) => (Some(pattern), None, String::new()), Err(e) => (None, None, format!("Invalid query: {e}")), } } else { - match GtsID::new(base_pattern) { + match GtsId::try_new(base_pattern) { Ok(gts_id) => { - if gts_id.gts_id_segments.is_empty() { + if gts_id.segments().is_empty() { ( None, None, @@ -1437,24 +1442,24 @@ impl GtsStore { } fn matches_id_pattern( - entity_id: &GtsID, + entity_id: &GtsId, base_pattern: &str, is_wildcard: bool, - wildcard_pattern: Option<&GtsWildcard>, - exact_gts_id: Option<&GtsID>, + wildcard_pattern: Option<&GtsIdPattern>, + exact_gts_id: Option<&GtsId>, ) -> bool { if is_wildcard && let Some(pattern) = wildcard_pattern { - return entity_id.wildcard_match(pattern); + return entity_id.matches_pattern(pattern); } - // For non-wildcard patterns, use wildcard_match to support version flexibility + // For non-wildcard patterns, use matches_pattern to support version flexibility if let Some(_exact) = exact_gts_id { - match GtsWildcard::new(base_pattern) { - Ok(pattern_as_wildcard) => entity_id.wildcard_match(&pattern_as_wildcard), - Err(_) => entity_id.id == base_pattern, + match GtsIdPattern::try_new(base_pattern) { + Ok(pattern_as_wildcard) => entity_id.matches_pattern(&pattern_as_wildcard), + Err(_) => entity_id.id() == base_pattern, } } else { - entity_id.id == base_pattern + entity_id.id() == base_pattern } } diff --git a/gts/src/store_test.rs b/gts/src/store_test.rs index 52f29a8..9509bfc 100644 --- a/gts/src/store_test.rs +++ b/gts/src/store_test.rs @@ -2208,9 +2208,12 @@ impl GtsReader for MockGtsReader { } fn read_by_id(&self, entity_id: &str) -> Option { + // Match on `effective_id()` to mirror `GtsStore::populate_from_reader`, + // which keys entities by their effective id (so anonymous instances are + // addressable by `instance_id`, not just by `gts_id`). self.entities .iter() - .find(|e| e.gts_id.as_ref().map(|id| id.id.as_str()) == Some(entity_id)) + .find(|e| e.effective_id().as_deref() == Some(entity_id)) .cloned() } @@ -2363,7 +2366,7 @@ fn test_validate_schema_refs_invalid_gts_id_in_uri() { let result = GtsStore::validate_schema_refs(&schema, ""); assert!(result.is_err()); let err = result.unwrap_err().to_string(); - assert!(err.contains("invalid GTS identifier")); + assert!(err.contains("must reference a GTS type id")); } #[test] @@ -2510,8 +2513,8 @@ fn test_validate_schema_refs_rejects_malformed_gts_id_in_ref() { assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!( - err.contains("invalid GTS identifier") || err.contains("contains invalid"), - "Error should mention invalid GTS identifier" + err.contains("must reference a GTS type id"), + "Error should explain a GTS type id is required, got: {err}" ); } @@ -2631,8 +2634,8 @@ fn test_validate_schema_refs_gts_prefix_but_empty_id() { assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!( - err.contains("invalid GTS identifier") || err.contains("contains invalid"), - "Error should mention invalid GTS identifier" + err.contains("must reference a GTS type id"), + "Error should explain a GTS type id is required, got: {err}" ); } @@ -2679,7 +2682,7 @@ fn test_validate_schema_x_gts_refs_entity_not_schema() { "name": "test" }); - let gts_id = GtsID::new("gts.vendor.package.namespace.type.v1.0~").expect("test"); + let gts_id = GtsId::try_new("gts.vendor.package.namespace.type.v1.0~").expect("test"); let entity = GtsEntity::new( None, None, @@ -2763,7 +2766,7 @@ fn test_validate_schema_entity_not_schema() { "name": "test" }); - let gts_id = GtsID::new("gts.vendor.package.namespace.type.v1.0~").expect("test"); + let gts_id = GtsId::try_new("gts.vendor.package.namespace.type.v1.0~").expect("test"); let entity = GtsEntity::new( None, None, diff --git a/gts/src/x_gts_ref.rs b/gts/src/x_gts_ref.rs index dca8cad..c799155 100644 --- a/gts/src/x_gts_ref.rs +++ b/gts/src/x_gts_ref.rs @@ -92,7 +92,7 @@ use serde_json::Value; use std::fmt; -use crate::gts::GtsID; +use crate::gts::GtsId; /// Error type for x-gts-ref validation failures #[derive(Debug, Clone)] @@ -406,7 +406,7 @@ impl XGtsRefValidator { if ref_pattern.starts_with('/') { match Self::resolve_pointer(root_schema, ref_pattern) { Some(resolved) => { - if !GtsID::is_valid(&resolved) { + if !GtsId::is_valid(&resolved) { return Some(XGtsRefValidationError::new( field_path.to_owned(), ref_pattern.to_owned(), @@ -461,7 +461,7 @@ impl XGtsRefValidator { } // Specific GTS ID - if !GtsID::is_valid(pattern) { + if !GtsId::is_valid(pattern) { return Some(XGtsRefValidationError::new( field_path.to_owned(), pattern.to_owned(), @@ -481,7 +481,7 @@ impl XGtsRefValidator { field_path: &str, ) -> Option { // Validate it's a valid GTS ID - if !GtsID::is_valid(value) { + if !GtsId::is_valid(value) { return Some(XGtsRefValidationError::new( field_path.to_owned(), value.to_owned(),