From 522a8ee5b82ba38b82de9545314bf2e95b4f94a8 Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Thu, 25 Jun 2026 19:08:44 +0300 Subject: [PATCH 1/2] feat: support configurable GTS ID prefixes - Add compile-time GTS_ID_PREFIX configuration with validation and Cargo rebuild tracking. - Thread the configured prefix through ID parsing, schema generation, validation, and schema reference handling. - Add macro argument support and tests for prefix-aware ID construction. Signed-off-by: Aviator 5 --- README.md | 4 +- gts-cli/src/gen_schemas.rs | 10 +- gts-id/README.md | 39 +++ gts-id/build.rs | 15 ++ gts-id/src/gts_id.rs | 232 +++++++++++------- gts-id/src/gts_id_pattern.rs | 225 +++++++++-------- gts-id/src/gts_id_segment.rs | 12 +- gts-id/src/lib.rs | 4 +- gts-id/src/parse.rs | 118 +++++---- gts-id/src/prefix.rs | 118 +++++++++ gts-macros/README.md | 52 ++++ gts-macros/src/id_arg.rs | 72 ++++++ gts-macros/src/instance.rs | 39 +-- gts-macros/src/lib.rs | 32 ++- .../instance_id_not_literal.stderr | 2 +- .../instance_raw_id_not_literal.stderr | 2 +- gts-macros/tests/golden/traits_bool_false.rs | 2 +- gts-macros/tests/golden/traits_bool_true.rs | 2 +- .../tests/golden/traits_generic_child.rs | 4 +- .../tests/golden/traits_inline_chain.rs | 4 +- .../tests/golden/traits_referenced_chain.rs | 8 +- .../tests/golden/traits_schema_narrowing.rs | 4 +- .../tests/golden/traits_struct_literal.rs | 4 +- gts-macros/tests/instance_macro_tests.rs | 125 +++++++--- gts-validator/src/format/json.rs | 16 +- gts-validator/src/format/markdown.rs | 55 +++-- gts-validator/src/normalize.rs | 12 +- gts-validator/src/output.rs | 3 +- gts/src/entities.rs | 18 +- gts/src/gts.rs | 21 +- gts/src/lib.rs | 1 + gts/src/schema.rs | 6 +- gts/src/schema_modifiers.rs | 7 +- gts/src/schema_refs.rs | 4 +- gts/src/schema_resolver.rs | 4 +- gts/src/schema_traits.rs | 2 +- gts/src/store.rs | 47 +--- gts/src/testing.rs | 3 +- gts/src/x_gts_ref.rs | 15 +- 39 files changed, 930 insertions(+), 413 deletions(-) create mode 100644 gts-id/build.rs create mode 100644 gts-id/src/prefix.rs create mode 100644 gts-macros/src/id_arg.rs diff --git a/README.md b/README.md index 845d4b0..04b6d9d 100644 --- a/README.md +++ b/README.md @@ -930,7 +930,9 @@ GTS identifiers follow this format: gts.....v[.][~] ``` -- **Prefix**: Always starts with `gts.` +- **Prefix**: Always starts with `gts.` (configurable at compile time via the + `GTS_ID_PREFIX` environment variable — see + [gts-id/README.md](gts-id/README.md#configurable-identifier-prefix)) - **Vendor**: Organization or vendor code - **Package**: Module or application name - **Namespace**: Category within the package diff --git a/gts-cli/src/gen_schemas.rs b/gts-cli/src/gen_schemas.rs index 4a4099b..8f6a31a 100644 --- a/gts-cli/src/gen_schemas.rs +++ b/gts-cli/src/gen_schemas.rs @@ -1,5 +1,5 @@ use anyhow::{Result, bail}; -use gts::{GtsInstanceId, GtsTypeId}; +use gts::{GTS_ID_URI_PREFIX, GtsInstanceId, GtsTypeId}; use regex::Regex; use std::collections::HashMap; use std::fs; @@ -415,7 +415,7 @@ fn build_json_schema( BaseAttr::IsBase => { // Base type - simple flat schema let mut s = json!({ - "$id": format!("gts://{type_id}"), + "$id": format!("{GTS_ID_URI_PREFIX}{type_id}"), "$schema": gts::JSON_SCHEMA_DRAFT_07, "title": struct_name, "type": "object", @@ -447,12 +447,12 @@ fn build_json_schema( } let mut s = json!({ - "$id": format!("gts://{type_id}"), + "$id": format!("{GTS_ID_URI_PREFIX}{type_id}"), "$schema": gts::JSON_SCHEMA_DRAFT_07, "title": format!("{struct_name} (extends {parent_name})"), "type": "object", "allOf": [ - { "$ref": format!("gts://{parent_type_id}") }, + { "$ref": format!("{GTS_ID_URI_PREFIX}{parent_type_id}") }, own_properties ] }); @@ -682,7 +682,7 @@ mod tests { assert!(req); assert_eq!(schema["type"], "string"); assert_eq!(schema["format"], "gts-instance-id"); - assert_eq!(schema["x-gts-ref"], "gts.*"); + assert_eq!(schema["x-gts-ref"], format!("{}*", gts::GTS_ID_PREFIX)); // Generic type parameter let (req, schema) = rust_type_to_json_schema("P"); diff --git a/gts-id/README.md b/gts-id/README.md index ae4b8ae..8c68244 100644 --- a/gts-id/README.md +++ b/gts-id/README.md @@ -16,6 +16,45 @@ gts.....v[.] * 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` +## Configurable identifier prefix + +By default, all GTS identifiers start with `gts.`. This prefix can be overridden +at **compile time** via the `GTS_ID_PREFIX` environment variable: + +```bash +# Use a custom prefix for your organization +GTS_ID_PREFIX=acme. cargo build +``` + +The override is validated at compile time — an invalid value fails the build +with a clear diagnostic. A valid prefix is a single lowercase token +(`[a-z][a-z0-9_]*`) terminated by a single `.`: + +| Value | Accepted? | Reason | +|-------|-----------|--------| +| `acme.` | ✅ | Single lowercase token + trailing dot | +| `gts.` | ✅ | The default | +| `acme` | ❌ | Missing trailing `.` | +| `Acme.` | ❌ | Uppercase rejected | +| `my.org.` | ❌ | Multi-segment (contains an extra `.`) | +| `acme-prod.` | ❌ | Hyphen is not allowed | +| `_.` | ❌ | Must start with a letter, not underscore | +| (empty) | ❌ | Empty prefix | + +Because the prefix is baked into the binary at compile time, a single binary +cannot work with multiple prefixes. The `build.rs` emits +`cargo:rerun-if-env-changed=GTS_ID_PREFIX` so Cargo rebuilds when the variable +changes. + +### Relevant constants + +| Constant | Description | +|----------|-------------| +| `GTS_ID_PREFIX` | The configured prefix (default `"gts."`). | +| `DEFAULT_GTS_ID_PREFIX` | The default prefix (`"gts."`), regardless of override. | +| `GTS_ID_PREFIX_ENV` | The env var name (`"GTS_ID_PREFIX"`). | +| `GTS_ID_MAX_LENGTH` | Maximum identifier length (1024 chars). | + ## Types | Type | Purpose | diff --git a/gts-id/build.rs b/gts-id/build.rs new file mode 100644 index 0000000..5a6c96e --- /dev/null +++ b/gts-id/build.rs @@ -0,0 +1,15 @@ +//! Build script for `gts-id`. +//! +//! `GTS_ID_PREFIX` is resolved at compile time via `option_env!`. Cargo does +//! not track `option_env!`/`env!` reads automatically, so without this hint a +//! stale build would silently keep the previously compiled prefix when the +//! environment variable changes. Emitting `rerun-if-env-changed` forces a +//! rebuild whenever the prefix is changed. +//! +//! A build script is a standalone program compiled before (and independently +//! of) the crate, so it cannot reference `gts_id::GTS_ID_PREFIX_ENV` directly. +//! The variable name is therefore hardcoded here; keep it in sync with +//! `GTS_ID_PREFIX_ENV` / the `option_env!` literal in `src/prefix.rs`. +fn main() { + println!("cargo:rerun-if-env-changed=GTS_ID_PREFIX"); +} diff --git a/gts-id/src/gts_id.rs b/gts-id/src/gts_id.rs index 92ab079..32e3d2c 100644 --- a/gts-id/src/gts_id.rs +++ b/gts-id/src/gts_id.rs @@ -8,7 +8,7 @@ use std::fmt; use std::str::FromStr; use crate::parse::parse_id; -use crate::{GTS_PREFIX, GtsIdError, GtsIdPattern, GtsIdSegment}; +use crate::{GTS_ID_PREFIX, GtsIdError, GtsIdPattern, GtsIdSegment}; /// GTS ID - a validated Global Type System identifier. /// @@ -74,7 +74,28 @@ impl GtsId { .map(GtsIdSegment::raw) .collect::>() .join(""); - Some(format!("{GTS_PREFIX}{segments}")) + Some(format!("{GTS_ID_PREFIX}{segments}")) + } + + /// Returns all prefix IDs of this identifier, from the first segment up to + /// and including the full ID. + /// + /// For a single-segment ID `gts.x.core.events.event.v1~` the result is + /// `["gts.x.core.events.event.v1~"]`. + /// For a three-segment chain `gts.A~B~C~` the result is + /// `["gts.A~", "gts.A~B~", "gts.A~B~C~"]`. + #[must_use] + pub fn chain_ids(&self) -> Vec { + (1..=self.segments.len()) + .map(|n| { + let joined: String = self.segments[..n] + .iter() + .map(GtsIdSegment::raw) + .collect::>() + .join(""); + format!("{GTS_ID_PREFIX}{joined}") + }) + .collect() } /// Generate a deterministic UUID v5 from this GTS ID. @@ -176,18 +197,24 @@ impl AsRef for GtsId { mod tests { use super::*; + /// Prepend the configured prefix to a suffix, yielding a full GTS id string + /// for use in tests. This keeps tests prefix-aware without hardcoding "gts.". + fn gts_id(suffix: &str) -> String { + format!("{GTS_ID_PREFIX}{suffix}") + } + #[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~"); + let id = GtsId::try_new(>s_id("x.core.events.event.v1~")).expect("test"); + assert_eq!(id.id, gts_id("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~"); + let id = GtsId::try_new(>s_id("x.core.events.event.v1.2~")).expect("test"); + assert_eq!(id.id, gts_id("x.core.events.event.v1.2~")); assert!(id.is_type()); let seg = &id.segments[0]; assert_eq!(seg.vendor(), "x"); @@ -200,14 +227,14 @@ mod tests { #[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"); + let id = GtsId::try_new(>s_id("x.core.events.event.v1~a.b.c.d.v1.0")).expect("test"); + assert_eq!(id.id, gts_id("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~"); + let result = GtsId::try_new(>s_id("X.core.events.event.v1~")); assert!(result.is_err()); } @@ -219,57 +246,59 @@ mod tests { #[test] fn test_gts_id_invalid_hyphen() { - let result = GtsId::try_new("gts.x-vendor.core.events.event.v1~"); + let result = GtsId::try_new(>s_id("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 id = GtsId::try_new(>s_id("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"); + GtsId::try_new(>s_id("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~"); + assert_eq!(base_type.expect("test"), gts_id("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~"); + GtsId::split_at_path(>s_id("x.core.events.event.v1~@field.subfield")).expect("test"); + assert_eq!(gts, gts_id("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~"); + let (gts, path) = GtsId::split_at_path(>s_id("x.core.events.event.v1~")).expect("test"); + assert_eq!(gts, gts_id("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~@"); + let result = GtsId::split_at_path(>s_id("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(>s_id("x.core.events.event.v1~"))); assert!(!GtsId::is_valid("invalid")); - assert!(!GtsId::is_valid("gts.X.core.events.event.v1~")); + assert!(!GtsId::is_valid(>s_id("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"); + let id = GtsId::try_new(>s_id( + "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"); @@ -278,25 +307,25 @@ mod tests { #[test] fn test_gts_id_with_underscore() { // Underscores are allowed in namespace - let id = GtsId::try_new("gts.x.core._.event.v1~").expect("test"); + let id = GtsId::try_new(>s_id("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~"); + let result = GtsId::try_new(>s_id("x.core.events.event.vX~")); assert!(result.is_err()); } #[test] fn test_gts_id_missing_segments() { - let result = GtsId::try_new("gts.x.core~"); + let result = GtsId::try_new(>s_id("x.core~")); assert!(result.is_err()); } #[test] fn test_gts_id_empty_segment() { - let result = GtsId::try_new("gts.x..events.event.v1~"); + let result = GtsId::try_new(>s_id("x..events.event.v1~")); assert!(result.is_err()); } @@ -304,68 +333,70 @@ mod tests { 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~"); + GtsId::split_at_path(>s_id("x.core.events.event.v1~@field@subfield")).expect("test"); + assert_eq!(gts, gts_id("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~"); + let id = + GtsId::try_new(&format!(" {} ", gts_id("x.core.events.event.v1~"))).expect("test"); + assert_eq!(id.id, gts_id("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"); + let id = GtsId::try_new(>s_id("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"); + let id = GtsId::try_new(>s_id("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"); + let id = GtsId::try_new(>s_id("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~~"); + let result = GtsId::try_new(>s_id("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"); + let (gts, path) = + GtsId::split_at_path(>s_id("x.core.events.event.v1~#field")).expect("test"); + assert_eq!(gts, gts_id("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~"); + let id = GtsId::try_new(>s_id("x.core.events.event.v1~")).expect("test"); + assert_eq!(format!("{id}"), gts_id("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~"); + let id: GtsId = gts_id("x.core.events.event.v1~").parse().expect("test"); + assert_eq!(id.id, gts_id("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 id = GtsId::try_new(>s_id("x.core.events.event.v1~")).expect("test"); let s: &str = id.as_ref(); - assert_eq!(s, "gts.x.core.events.event.v1~"); + assert_eq!(s, gts_id("x.core.events.event.v1~")); } #[test] @@ -381,13 +412,13 @@ mod tests { #[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()); + assert!(GtsId::try_new(>s_id("~")).is_err()); + assert!(GtsId::try_new(>s_id("x~")).is_err()); + assert!(GtsId::try_new(>s_id("x.pkg~")).is_err()); + assert!(GtsId::try_new(>s_id("x.pkg.ns~")).is_err()); // Minimum valid (vendor.package.namespace.type.version) - assert!(GtsId::try_new("gts.x.pkg.ns.type.v1~").is_ok()); + assert!(GtsId::try_new(>s_id("x.pkg.ns.type.v1~")).is_ok()); } #[test] @@ -395,40 +426,41 @@ mod tests { // 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()); + assert!(GtsId::try_new(>s_id("x.core.events.event!.v1~")).is_err()); + assert!(GtsId::try_new(>s_id("x.core.events.ev$ent.v1~")).is_err()); + assert!(GtsId::try_new(>s_id("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()); + assert!(GtsId::try_new(>s_id("x.core.events.Test.v1~")).is_err()); + assert!(GtsId::try_new(>s_id("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()); + assert!(GtsId::try_new(>s_id("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()); + assert!(GtsId::try_new(>s_id("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()); + assert!(GtsId::try_new(>s_id("x.test2name.ns.type.v1~")).is_ok()); + assert!(GtsId::try_new(>s_id("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~"); + let (gts, path) = + GtsId::split_at_path(>s_id("x.test.v1~@/properties/field")).expect("test"); + assert_eq!(gts, gts_id("x.test.v1~")); assert_eq!(path, Some("/properties/field".to_owned())); } @@ -438,14 +470,14 @@ mod tests { // 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()); + assert!(GtsId::try_new(>s_id("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()); + assert!(GtsId::try_new(>s_id("x.pkg.ns.event.v10~")).is_ok()); + assert!(GtsId::try_new(>s_id("x.pkg.ns.event.v1.20~")).is_ok()); } #[test] @@ -454,14 +486,14 @@ mod tests { // 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~*")); + assert!(GtsId::try_new(>s_id("x.core.*")).is_err()); + assert!(GtsId::try_new(>s_id("x.core.events.topic.v1~*")).is_err()); + assert!(!GtsId::is_valid(>s_id("x.core.*"))); + assert!(!GtsId::is_valid(>s_id("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()); + assert!(GtsIdPattern::try_new(>s_id("x.core.*")).is_ok()); + assert!(GtsIdPattern::try_new(>s_id("x.core.events.topic.v1~*")).is_ok()); } #[test] @@ -469,12 +501,12 @@ mod tests { // `*` 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 + gts_id("x.core.*~"), + gts_id("x.core.events.topic.v1.*~"), + gts_id("x.*.events.topic.v1~"), // `*` not terminal ] { assert!( - GtsIdPattern::try_new(pattern).is_err(), + GtsIdPattern::try_new(&pattern).is_err(), "pattern must be rejected: {pattern}" ); } @@ -486,34 +518,66 @@ mod tests { // 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", - ) + let id = GtsId::try_new(>s_id( + "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~" + gts_id("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"); + let id = GtsId::try_new(>s_id( + "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~" + gts_id("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"); + let id = GtsId::try_new(>s_id("x.core.events.topic.v1~")).expect("single type segment"); assert_eq!(id.get_type_id(), None); } + #[test] + fn test_chain_ids_single_segment() { + let id = GtsId::try_new(>s_id("x.core.events.event.v1~")).expect("test"); + let chain = id.chain_ids(); + assert_eq!(chain, vec![gts_id("x.core.events.event.v1~")]); + } + + #[test] + fn test_chain_ids_multi_segment() { + let id = GtsId::try_new(>s_id( + "x.core.events.topic.v1~vendor.app.orders.thing.v1~acme.shop.checkout.item.v1.0", + )) + .expect("valid three-segment chain"); + let chain = id.chain_ids(); + assert_eq!(chain.len(), 3); + assert_eq!(chain[0], gts_id("x.core.events.topic.v1~")); + assert_eq!( + chain[1], + gts_id("x.core.events.topic.v1~vendor.app.orders.thing.v1~") + ); + assert_eq!( + chain[2], + gts_id( + "x.core.events.topic.v1~vendor.app.orders.thing.v1~acme.shop.checkout.item.v1.0" + ) + ); + // No double tildes + assert!(chain.iter().all(|c| !c.contains("~~"))); + } + #[test] fn test_to_pattern_roundtrip() { - let id = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + let id = GtsId::try_new(>s_id("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 @@ -524,7 +588,7 @@ mod tests { #[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 id = GtsId::try_new(>s_id("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()); @@ -534,7 +598,7 @@ mod tests { #[cfg(feature = "uuid")] #[test] fn test_uuid_generation() { - let id = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + let id = GtsId::try_new(>s_id("x.core.events.event.v1~")).expect("test"); let uuid1 = id.to_uuid(); let uuid2 = id.to_uuid(); // UUIDs should be deterministic @@ -545,8 +609,8 @@ mod tests { #[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"); + let id1 = GtsId::try_new(>s_id("x.core.events.event.v1~")).expect("test"); + let id2 = GtsId::try_new(>s_id("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 index 54bf3d7..3bc0280 100644 --- a/gts-id/src/gts_id_pattern.rs +++ b/gts-id/src/gts_id_pattern.rs @@ -269,17 +269,23 @@ impl AsRef for GtsIdPattern { mod tests { use super::*; + /// Prepend the configured prefix to a suffix, yielding a full GTS id string + /// for use in tests. This keeps tests prefix-aware without hardcoding "gts.". + fn gts_id(suffix: &str) -> String { + format!("{}{suffix}", crate::GTS_ID_PREFIX) + } + #[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"); + let pattern = GtsIdPattern::try_new(>s_id("x.core.events.*")).expect("test"); + let id = GtsId::try_new(>s_id("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"); + let pattern = GtsIdPattern::try_new(>s_id("x.core.events.*")).expect("test"); + let id = GtsId::try_new(>s_id("y.core.events.event.v1~")).expect("test"); assert!(!id.matches_pattern(&pattern)); } @@ -288,13 +294,16 @@ mod tests { // 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 pattern = GtsIdPattern::try_new(>s_id("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"); + let type_candidate = GtsId::try_new(>s_id( + "x.core.events.topic.v1~vendor.app.orders.thing.v1~", + )) + .expect("test"); + let instance_candidate = GtsId::try_new(>s_id( + "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)); @@ -303,17 +312,17 @@ mod tests { #[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"); + let pattern = GtsIdPattern::try_new(>s_id("x.core.events.*")).expect("test"); + let id = GtsId::try_new(>s_id("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"); + let pattern = GtsIdPattern::try_new(>s_id("x.core.events.event.v1~")).expect("test"); + let id_no_minor = GtsId::try_new(>s_id("x.core.events.event.v1~")).expect("test"); + let id_with_minor = GtsId::try_new(>s_id("x.core.events.event.v1.0~")).expect("test"); assert!(id_no_minor.matches_pattern(&pattern)); assert!(id_with_minor.matches_pattern(&pattern)); @@ -321,42 +330,45 @@ mod tests { #[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"); + let pattern = GtsIdPattern::try_new(>s_id("x.core.events.event.v1~")).expect("test"); + let id = GtsId::try_new(>s_id("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"); + let pattern = GtsIdPattern::try_new(>s_id("x.core.events.event.v2~")).expect("test"); + let id = GtsId::try_new(>s_id("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"); + let pattern = GtsIdPattern::try_new(>s_id( + "x.core.events.topic.v1~vendor.app.orders.order.v1~", + )) + .expect("test"); + let id = GtsId::try_new(>s_id("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", - ) + let pattern = GtsIdPattern::try_new(>s_id( + "x.core.events.topic.v1~7a1d2f34-5678-49ab-9012-abcdef123456", + )) + .expect("test"); + let id = GtsId::try_new(>s_id( + "x.core.events.topic.v1~7a1d2f34-5678-49ab-9012-abcdef123457", + )) .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"); + let pattern = GtsIdPattern::try_new(>s_id("x.core.events.event.v1.0~")).expect("test"); + let id = GtsId::try_new(>s_id("x.core.events.event.v1.0~")).expect("test"); assert!(id.matches_pattern(&pattern)); } @@ -368,67 +380,68 @@ mod tests { #[test] fn test_gts_wildcard_multiple_wildcards_error() { - let result = GtsIdPattern::try_new("gts.*.*.*.*"); + let result = GtsIdPattern::try_new(>s_id("*.*.*.*")); 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"); + let pattern = GtsIdPattern::try_new(>s_id("x.core.events.*")).expect("test"); + let id = GtsId::try_new(>s_id("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.*"); + let pattern = + GtsIdPattern::try_new(&format!(" {} ", gts_id("x.core.events.*"))).expect("test"); + assert_eq!(pattern.pattern(), gts_id("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~"); + let result1 = GtsIdPattern::try_new(>s_id("*.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"); + let pattern2 = GtsIdPattern::try_new(>s_id("x.core.events.*")).expect("test"); + let id2 = GtsId::try_new(>s_id("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"); + let pattern = GtsIdPattern::try_new(>s_id("x.core.events.event.v1~")).expect("test"); + let id = GtsId::try_new(>s_id("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.*"); + let pattern = GtsIdPattern::try_new(>s_id("x.core.events.*")).expect("test"); + assert_eq!(format!("{pattern}"), gts_id("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.*"); + let pattern: GtsIdPattern = gts_id("x.core.events.*").parse().expect("test"); + assert_eq!(pattern.pattern(), gts_id("x.core.events.*")); } #[test] fn test_gts_wildcard_as_ref_trait() { - let pattern = GtsIdPattern::try_new("gts.x.core.events.*").expect("test"); + let pattern = GtsIdPattern::try_new(>s_id("x.core.events.*")).expect("test"); let s: &str = pattern.as_ref(); - assert_eq!(s, "gts.x.core.events.*"); + assert_eq!(s, gts_id("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"); + let pattern = GtsIdPattern::try_new(>s_id("x.pkg.ns.type.v1~*")).expect("test"); + let id1 = GtsId::try_new(>s_id("x.pkg.ns.type.v1~a.b.c.child.v1~")).expect("test"); + let id2 = GtsId::try_new(>s_id("x.pkg.ns.type.v2~a.b.c.child.v1~")).expect("test"); assert!(id1.matches_pattern(&pattern)); assert!(!id2.matches_pattern(&pattern)); } @@ -436,23 +449,23 @@ mod tests { #[test] fn test_gts_wildcard_at_various_positions() { // Wildcard at vendor position - let result = GtsIdPattern::try_new("gts.*"); + let result = GtsIdPattern::try_new(>s_id("*")); assert!(result.is_ok()); // Wildcard at package position - let result = GtsIdPattern::try_new("gts.x.*"); + let result = GtsIdPattern::try_new(>s_id("x.*")); assert!(result.is_ok()); // Wildcard at namespace position - let result = GtsIdPattern::try_new("gts.x.pkg.*"); + let result = GtsIdPattern::try_new(>s_id("x.pkg.*")); assert!(result.is_ok()); // Wildcard at type position - let result = GtsIdPattern::try_new("gts.x.pkg.ns.*"); + let result = GtsIdPattern::try_new(>s_id("x.pkg.ns.*")); assert!(result.is_ok()); // Wildcard at version position - let result = GtsIdPattern::try_new("gts.x.pkg.ns.type.*"); + let result = GtsIdPattern::try_new(>s_id("x.pkg.ns.type.*")); assert!(result.is_ok()); } @@ -460,8 +473,8 @@ mod tests { #[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"); + let broad = GtsIdPattern::try_new(>s_id("x.core.srr.resource.v1~*")).expect("test"); + let narrow = GtsIdPattern::try_new(>s_id("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)); @@ -470,16 +483,16 @@ mod tests { #[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"); + let a = GtsIdPattern::try_new(>s_id("x.core.srr.resource.v1~*")).expect("test"); + let b = GtsIdPattern::try_new(>s_id("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"); + let a = GtsIdPattern::try_new(>s_id("x.core.srr.resource.v1~*")).expect("test"); + let b = GtsIdPattern::try_new(>s_id("x.core.srr.resource.v1~*")).expect("test"); // A pattern covers an identical one (both directions). assert!(a.covers(&b)); assert!(b.covers(&a)); @@ -487,18 +500,18 @@ mod tests { #[test] fn test_covers_wildcard_covers_exact() { - let exact = GtsIdPattern::try_new("gts.x.core.srr.resource.v1~acme.crm._.contact.v1~") + let exact = GtsIdPattern::try_new(>s_id("x.core.srr.resource.v1~acme.crm._.contact.v1~")) .expect("test"); - let broad = GtsIdPattern::try_new("gts.x.core.srr.resource.v1~*").expect("test"); + let broad = GtsIdPattern::try_new(>s_id("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"); + let l1 = GtsIdPattern::try_new(>s_id("x.core.srr.resource.v1~*")).expect("test"); + let l2 = GtsIdPattern::try_new(>s_id("x.core.srr.resource.v1~acme.*")).expect("test"); + let l3 = GtsIdPattern::try_new(>s_id("x.core.srr.resource.v1~acme.crm.*")).expect("test"); // Broader patterns cover narrower ones, transitively. assert!(l1.covers(&l2)); assert!(l1.covers(&l3)); @@ -512,26 +525,26 @@ mod tests { #[test] fn test_version_wildcard_valid_and_is_wildcard() { - let pattern = GtsIdPattern::try_new("gts.x.llm.chat.message.v*").expect("test"); + let pattern = GtsIdPattern::try_new(>s_id("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*")); + assert!(GtsIdPattern::is_valid(>s_id("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"); + let pattern = GtsIdPattern::try_new(>s_id("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~", + gts_id("x.llm.chat.message.v1.0~"), + gts_id("x.llm.chat.message.v1.1~"), + gts_id("x.llm.chat.message.v2~"), + gts_id("x.llm.chat.message.v1.0~acme.app.ns.derived.v1~"), ] { - let candidate = GtsId::try_new(id).expect("test"); + 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"); + let other = GtsId::try_new(>s_id("x.llm.chat.other.v1~")).expect("test"); assert!(!other.matches_pattern(&pattern)); } @@ -539,14 +552,14 @@ mod tests { 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"); + let v_star = GtsIdPattern::try_new(>s_id("x.llm.chat.message.v*")).expect("test"); + let dot_star = GtsIdPattern::try_new(>s_id("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~", + gts_id("x.llm.chat.message.v1~"), + gts_id("x.llm.chat.message.v9.9~"), + gts_id("x.llm.chat.message.v1.0~acme.app.ns.derived.v1~"), ] { - let candidate = GtsId::try_new(id).expect("test"); + let candidate = GtsId::try_new(&id).expect("test"); assert_eq!( candidate.matches_pattern(&v_star), candidate.matches_pattern(&dot_star), @@ -558,11 +571,11 @@ mod tests { #[test] fn test_version_wildcard_rejections() { // `*` after `v*` — two wildcards. - assert!(!GtsIdPattern::is_valid("gts.x.llm.chat.message.v*~*")); + assert!(!GtsIdPattern::is_valid(>s_id("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.*~")); + assert!(!GtsIdPattern::is_valid(>s_id("x.llm.chat.message.v1.*~"))); // Partial (non-version) token wildcard. - assert!(!GtsIdPattern::is_valid("gts.x.llm.chat.msg*")); + assert!(!GtsIdPattern::is_valid(>s_id("x.llm.chat.msg*"))); } // ---- covers: minor-version flexibility (segment-based) ---- @@ -572,24 +585,24 @@ mod tests { // 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"); + let any_minor = GtsIdPattern::try_new(>s_id("x.core.events.event.v1~*")).expect("test"); + let specific = GtsIdPattern::try_new(>s_id("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"); + let any_minor = GtsIdPattern::try_new(>s_id("x.core.events.event.v1~")).expect("test"); + let specific = GtsIdPattern::try_new(>s_id("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"); + let v1 = GtsIdPattern::try_new(>s_id("x.core.events.event.v1~*")).expect("test"); + let v2 = GtsIdPattern::try_new(>s_id("x.core.events.event.v2~*")).expect("test"); assert!(!v1.covers(&v2)); assert!(!v2.covers(&v1)); } @@ -598,7 +611,7 @@ mod tests { #[test] fn test_from_gts_id_ref() { - let id = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + let id = GtsId::try_new(>s_id("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()); @@ -613,24 +626,24 @@ mod tests { // 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~") + let pattern = GtsId::try_new(>s_id("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"); + let exact = GtsId::try_new(>s_id("a.b.c.d.v1~")).expect("test"); + let derived = GtsId::try_new(>s_id("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"); + let other_base = GtsId::try_new(>s_id("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 id = GtsId::try_new(>s_id("x.core.events.event.v1~")).expect("test"); let expected = id.id().to_owned(); let pattern: GtsIdPattern = id.into(); assert_eq!(pattern.pattern(), expected); @@ -639,8 +652,10 @@ mod tests { #[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 id = GtsId::try_new(>s_id( + "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()); @@ -649,7 +664,7 @@ mod tests { #[test] fn test_from_ref_and_owned_agree() { - let id = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + let id = GtsId::try_new(>s_id("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. @@ -658,7 +673,7 @@ mod tests { #[test] fn test_from_gts_id_matches_to_pattern() { - let id = GtsId::try_new("gts.x.core.events.event.v1~").expect("test"); + let id = GtsId::try_new(>s_id("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()); } @@ -668,13 +683,15 @@ mod tests { #[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~*")); + assert!(GtsIdPattern::is_valid(>s_id("x.core.events.event.v1~"))); + assert!(GtsIdPattern::is_valid(>s_id("x.core.events.*"))); + assert!(GtsIdPattern::is_valid(>s_id("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.*~")); + assert!(!GtsIdPattern::is_valid(>s_id("x.*.events.event.v1~"))); + assert!(!GtsIdPattern::is_valid(>s_id( + "x.core.events.topic.v1.*~" + ))); } } diff --git a/gts-id/src/gts_id_segment.rs b/gts-id/src/gts_id_segment.rs index f6e2fcd..af09dc3 100644 --- a/gts-id/src/gts_id_segment.rs +++ b/gts-id/src/gts_id_segment.rs @@ -718,9 +718,13 @@ mod tests { #[test] fn test_segment1_format_has_gts_prefix() { let err = GtsIdSegment::parse(1, "x.core.events.event~").unwrap_err(); + let expected = format!( + "{}vendor.package.namespace.type.vMAJOR", + crate::GTS_ID_PREFIX + ); assert!( - err.contains("gts.vendor.package.namespace.type.vMAJOR"), - "segment #1 format should include gts. prefix, got: {err}" + err.contains(&expected), + "segment #1 format should include configured prefix, got: {err}" ); } @@ -728,8 +732,8 @@ mod tests { 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}" + !err.contains(&format!("{}vendor", crate::GTS_ID_PREFIX)), + "segment #2 format should NOT include configured prefix, got: {err}" ); assert!( err.contains("vendor.package.namespace.type.vMAJOR"), diff --git a/gts-id/src/lib.rs b/gts-id/src/lib.rs index c6e188a..8ba20cc 100644 --- a/gts-id/src/lib.rs +++ b/gts-id/src/lib.rs @@ -9,9 +9,11 @@ mod gts_id; mod gts_id_pattern; mod gts_id_segment; pub(crate) mod parse; +pub(crate) mod prefix; 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}; +pub use parse::GTS_ID_MAX_LENGTH; +pub use prefix::{DEFAULT_GTS_ID_PREFIX, GTS_ID_PREFIX, GTS_ID_PREFIX_ENV}; diff --git a/gts-id/src/parse.rs b/gts-id/src/parse.rs index a09b0a1..3ed3a4b 100644 --- a/gts-id/src/parse.rs +++ b/gts-id/src/parse.rs @@ -5,25 +5,22 @@ //! 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."; +use crate::{GTS_ID_PREFIX, GtsIdError, GtsIdPatternSegment, GtsIdSegment}; /// Maximum allowed length for a GTS identifier string. -pub const GTS_MAX_LENGTH: usize = 1024; +pub const GTS_ID_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 +/// Segment #1 shows the configured prefix because the user writes +/// `vendor.package...`; segments #2+ omit it because they /// come after a `~` delimiter. #[must_use] -pub fn expected_format(segment_num: usize) -> &'static str { +pub fn expected_format(segment_num: usize) -> String { if segment_num == 1 { - "gts.vendor.package.namespace.type.vMAJOR[.MINOR]" + format!("{GTS_ID_PREFIX}vendor.package.namespace.type.vMAJOR[.MINOR]") } else { - "vendor.package.namespace.type.vMAJOR[.MINOR]" + "vendor.package.namespace.type.vMAJOR[.MINOR]".to_owned() } } @@ -80,10 +77,10 @@ fn split_raw_segments( id: &str, allow_wildcards: bool, ) -> Result<(Vec, Option), GtsIdError> { - if !id.starts_with(GTS_PREFIX) { + if !id.starts_with(GTS_ID_PREFIX) { return Err(GtsIdError::new( id, - format!("must start with '{GTS_PREFIX}'"), + format!("must start with '{GTS_ID_PREFIX}'"), )); } @@ -91,10 +88,10 @@ fn split_raw_segments( return Err(GtsIdError::new(id, "must be lowercase")); } - if id.len() > GTS_MAX_LENGTH { + if id.len() > GTS_ID_MAX_LENGTH { return Err(GtsIdError::new( id, - format!("too long ({} chars, max {GTS_MAX_LENGTH})", id.len()), + format!("too long ({} chars, max {GTS_ID_MAX_LENGTH})", id.len()), )); } @@ -114,7 +111,7 @@ fn split_raw_segments( } } - let remainder = &id[GTS_PREFIX.len()..]; + let remainder = &id[GTS_ID_PREFIX.len()..]; let tilde_parts: Vec<&str> = remainder.split('~').collect(); // Detect combined anonymous instance: last tilde-part is a UUID. @@ -193,7 +190,7 @@ 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(); + let mut offset = GTS_ID_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()))?; @@ -228,7 +225,7 @@ 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(); + let mut offset = GTS_ID_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()))?; @@ -257,6 +254,12 @@ pub fn parse_pattern(id: &str) -> Result, GtsIdError> { mod tests { use super::*; + /// Prepend the configured prefix to a suffix, yielding a full GTS id string + /// for use in tests. This keeps tests prefix-aware without hardcoding "gts.". + fn gts_id(suffix: &str) -> String { + format!("{GTS_ID_PREFIX}{suffix}") + } + // ---- is_valid_segment_token ---- #[test] @@ -301,7 +304,7 @@ mod tests { #[test] fn test_valid_gts_id() { - let segments = parse_id("gts.x.core.events.event.v1~").unwrap(); + let segments = parse_id(>s_id("x.core.events.event.v1~")).unwrap(); assert_eq!(segments.len(), 1); assert_eq!(segments[0].vendor(), "x"); assert!(segments[0].is_type()); @@ -309,7 +312,10 @@ mod tests { #[test] fn test_valid_gts_id_chained() { - let segments = parse_id("gts.x.core.events.type.v1~vendor.app._.custom_event.v1~").unwrap(); + let segments = parse_id(>s_id( + "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"); @@ -319,32 +325,38 @@ mod tests { 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}"); + assert!( + err.cause + .contains(&format!("must start with '{GTS_ID_PREFIX}'")), + "got: {err}" + ); } #[test] fn test_gts_id_uppercase() { - let err = parse_id("gts.X.core.events.event.v1~").unwrap_err(); + let err = parse_id(>s_id("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(); + let err = parse_id(>s_id("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 err = parse_id(>s_id( + "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); + // offset = prefix.len() + "x.core.modkit.plugin.v1~".len() + let expected_offset = GTS_ID_PREFIX.len() + "x.core.modkit.plugin.v1~".len(); + assert_eq!(seg.offset, expected_offset); assert!( err.cause.contains("Too many name tokens before version"), "got: {err}" @@ -353,7 +365,7 @@ mod tests { #[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(); + let segments = parse_id(>s_id("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()); @@ -361,20 +373,24 @@ mod tests { #[test] fn test_gts_id_double_tilde_rejected() { - let err = parse_id("gts.x.test1.events.type.v1.0~~").unwrap_err(); + let err = parse_id(>s_id("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}"); + let err = parse_id(&format!(" {} ", gts_id("x.core.events.event.v1~"))).unwrap_err(); + assert!( + err.cause + .contains(&format!("must start with '{GTS_ID_PREFIX}'")), + "got: {err}" + ); } #[test] fn test_gts_id_trimmed_input() { - let segments = parse_id("gts.x.core.events.event.v1~").unwrap(); + let segments = parse_id(>s_id("x.core.events.event.v1~")).unwrap(); assert_eq!(segments.len(), 1); } @@ -400,7 +416,7 @@ mod tests { #[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") + let segments = parse_id(>s_id("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()); @@ -412,8 +428,10 @@ mod tests { #[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(); + let segments = parse_id(>s_id( + "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()); @@ -421,7 +439,7 @@ mod tests { #[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") + let err = parse_id(>s_id("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}"); @@ -432,14 +450,18 @@ mod tests { // 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}"); + assert!( + err.cause + .contains(&format!("must start with '{GTS_ID_PREFIX}'")), + "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(); + // prefix + UUID has no tilde_parts.len() >= 2 + let err = parse_id(>s_id("7a1d2f34-5678-49ab-9012-abcdef123456")).unwrap_err(); assert!(err.segment.is_none(), "expected id-level error, got: {err}"); assert!(err.cause.contains("'-'"), "got: {err}"); } @@ -449,15 +471,15 @@ mod tests { #[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(); + let err = parse_id(>s_id("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(); + // Wildcards are exempt from #37, so "prefix.a.b.*" is accepted. + let segments = parse_pattern(>s_id("a.b.*")).unwrap(); assert_eq!(segments.len(), 1); assert!(segments[0].is_wildcard()); } @@ -466,14 +488,14 @@ mod tests { #[test] fn test_parse_pattern_multistar_rejected() { - let err = parse_pattern("gts.*.*.*.*").unwrap_err(); + let err = parse_pattern(>s_id("*.*.*.*")).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(); + let err = parse_pattern(>s_id("*.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}"); } @@ -482,7 +504,7 @@ mod tests { 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(); + let segments = parse_pattern(>s_id("x.llm.chat.message.v*")).unwrap(); assert_eq!(segments.len(), 1); assert!(segments[0].is_wildcard()); } @@ -491,7 +513,7 @@ mod tests { 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(); + let err = parse_pattern(>s_id("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}"); } @@ -500,7 +522,7 @@ mod tests { 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(); + let err = parse_pattern(>s_id("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}"); } @@ -509,7 +531,7 @@ mod tests { 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(); + let err = parse_id(>s_id("*.*.*.*")).unwrap_err(); assert!( err.segment.is_some(), "expected segment-level error, got: {err}" diff --git a/gts-id/src/prefix.rs b/gts-id/src/prefix.rs new file mode 100644 index 0000000..d3ed516 --- /dev/null +++ b/gts-id/src/prefix.rs @@ -0,0 +1,118 @@ +//! Configuration of the GTS identifier prefix. +//! +//! The prefix is resolved once, at compile time, from the [`GTS_ID_PREFIX_ENV`] +//! environment variable (falling back to [`DEFAULT_GTS_ID_PREFIX`]). It is +//! validated by [`validate_gts_id_prefix`] so an invalid override fails the +//! build instead of silently producing malformed identifiers. +//! +//! Because the value is baked in at compile time, the crate's `build.rs` emits +//! `rerun-if-env-changed` so Cargo rebuilds when the variable changes. + +/// The default identifier prefix for all GTS identifiers. +pub const DEFAULT_GTS_ID_PREFIX: &str = "gts."; + +/// Environment variable used to override the GTS identifier prefix at compile time. +/// +/// `option_env!` requires a string *literal*, so the name is repeated in +/// [`GTS_ID_PREFIX`]'s initializer below; `build.rs` likewise hardcodes it for +/// its `rerun-if-env-changed` hint. Keep all three occurrences in sync. +pub const GTS_ID_PREFIX_ENV: &str = "GTS_ID_PREFIX"; + +/// The configured prefix for all GTS identifiers. +/// +/// Defaults to [`DEFAULT_GTS_ID_PREFIX`] and can be overridden at compile time +/// via the [`GTS_ID_PREFIX_ENV`] environment variable. The override must be a +/// single lowercase token (`[a-z][a-z0-9_]*`) terminated by a single `.` +/// (e.g. `acme.`); multi-segment prefixes such as `my.org.` are rejected at +/// compile time by [`validate_gts_id_prefix`]. +pub const GTS_ID_PREFIX: &str = validate_gts_id_prefix(match option_env!("GTS_ID_PREFIX") { + Some(prefix) => prefix, + None => DEFAULT_GTS_ID_PREFIX, +}); + +/// Validates a configured GTS identifier prefix at compile time. +/// +/// A valid prefix is a single lowercase token (`[a-z][a-z0-9_]*`) followed by +/// a single trailing `.`. Multi-segment prefixes (`my.org.`), uppercase, and +/// other punctuation are rejected. +#[allow(clippy::manual_is_ascii_check)] +const fn validate_gts_id_prefix(prefix: &str) -> &str { + let bytes = prefix.as_bytes(); + assert!(!bytes.is_empty(), "GTS_ID_PREFIX must not be empty"); + assert!( + bytes[bytes.len() - 1] == b'.', + "GTS_ID_PREFIX must end with '.'" + ); + assert!( + bytes.len() != 1, + "GTS_ID_PREFIX must contain a token before the final dot" + ); + + let first = bytes[0]; + assert!( + first >= b'a' && first <= b'z', + "GTS_ID_PREFIX must start with a lowercase ASCII letter" + ); + + let mut i = 1; + while i < bytes.len() - 1 { + let b = bytes[i]; + assert!( + (b >= b'a' && b <= b'z') || (b >= b'0' && b <= b'9') || b == b'_', + "GTS_ID_PREFIX must be a lowercase ASCII token followed by '.'" + ); + i += 1; + } + + prefix +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use std::panic::catch_unwind; + + #[test] + fn test_valid_prefixes() { + assert_eq!(validate_gts_id_prefix("gts."), "gts."); + assert_eq!(validate_gts_id_prefix("a."), "a."); + assert_eq!(validate_gts_id_prefix("acme."), "acme."); + assert_eq!(validate_gts_id_prefix("a1."), "a1."); + assert_eq!(validate_gts_id_prefix("a_b."), "a_b."); + assert_eq!(validate_gts_id_prefix("abc123_."), "abc123_."); + } + + #[test] + fn test_default_prefix_is_in_effect() { + // When no custom prefix is configured via GTS_ID_PREFIX, the resolved + // prefix must equal the default. When a custom prefix *is* configured, + // the resolved prefix differs from the default — that's expected and + // tested by the compile-time validation above. + if option_env!("GTS_ID_PREFIX").is_none() { + assert_eq!(GTS_ID_PREFIX, DEFAULT_GTS_ID_PREFIX); + assert_eq!(GTS_ID_PREFIX, "gts."); + } + } + + #[test] + fn test_invalid_prefixes_rejected() { + let invalid = [ + "", // empty + "acme", // no trailing dot + "Acme.", // uppercase + "acme-prod.", // hyphen + "my.org.", // multi-segment (dot in middle) + ".", // bare dot, no token + "1bad.", // starts with digit + "_.", // starts with underscore + ]; + for prefix in invalid { + let result = catch_unwind(|| validate_gts_id_prefix(prefix)); + assert!( + result.is_err(), + "prefix {prefix:?} should be rejected but was accepted" + ); + } + } +} diff --git a/gts-macros/README.md b/gts-macros/README.md index d3bee97..e2e8e38 100644 --- a/gts-macros/README.md +++ b/gts-macros/README.md @@ -746,6 +746,58 @@ Examples: - `gts.x.core.iam.user.v1~` - IAM user schema - `gts.x.commerce.orders.order.v1.0~` - Order schema with minor version +### The `gts_id!` helper macro + +The `gts_id!` macro prepends the configured GTS ID prefix +(`gts.` by default, overridable via `GTS_ID_PREFIX` at compile time — see +[`gts-id/README.md`](../gts-id/README.md#configurable-identifier-prefix)) +to a prefix-less suffix, producing a `&'static str` literal: + +```rust +use gts_macros::gts_id; + +// With the default prefix "gts.": +let id: &str = gts_id!("x.core.events.topic.v1~"); +assert_eq!(id, "gts.x.core.events.topic.v1~"); +``` + +This avoids hard-coding the prefix at every call site. When the prefix is +overridden at compile time, the same code automatically uses the new prefix. + +#### Marker form inside other macros + +`gts_id!("...")` is also recognized as a **marker** inside the `type_id` / +`schema_id` / `id` arguments of `#[struct_to_gts_schema]`, `gts_instance!`, +and `gts_instance_raw!`. The macros intercept it syntactically and prepend +the configured prefix themselves (the compiler does not expand macros +inside another macro's input): + +```rust +use gts_macros::{struct_to_gts_schema, gts_id, gts_instance}; + +#[struct_to_gts_schema( + dir_path = "schemas", + base = true, + type_id = gts_id!("x.core.events.topic.v1~"), // ← prefix-less + description = "Topic type", + properties = "id,name" +)] +pub struct TopicV1 { + pub id: gts::GtsInstanceId, + pub name: String, +} + +// Inside gts_instance!: +let t: TopicV1 = gts_instance!(TopicV1 { + id: gts_id!("x.core.events.topic.v1~vendor.app.orders.created.v1"), + name: "orders".to_owned(), +}); +``` + +Both the marker form `gts_id!("...")` and the full literal form +`"gts.x.core.events.topic.v1~..."` are accepted for backward compatibility. +Qualified paths such as `gts_macros::gts_id!("...")` are also recognized. + --- ## Complete Example diff --git a/gts-macros/src/id_arg.rs b/gts-macros/src/id_arg.rs new file mode 100644 index 0000000..f416c7a --- /dev/null +++ b/gts-macros/src/id_arg.rs @@ -0,0 +1,72 @@ +//! Shared parsing for GTS-id macro arguments. +//! +//! Several macros accept a GTS identifier as input: `struct_to_gts_schema` +//! (`type_id = ...`), `gts_instance!` (`id: ...`), and `gts_instance_raw!` +//! (`"id": ...`). Each historically required the *full* identifier string +//! literal, including the configured prefix (`gts.` by default). +//! +//! To avoid hard-coding the prefix at every call site, those macros also accept +//! the [`PREFIX_MACRO`] marker form `gts_id!("")`, where `` is +//! the identifier *without* the prefix. The macros parse their own token +//! streams, so they recognize this shape directly and prepend +//! [`gts_id::GTS_ID_PREFIX`] themselves — they do not rely on the compiler to +//! expand `gts_id!` (which would not happen inside another macro's input). +//! +//! The same name is also exported as a real expression macro (see the +//! `gts_id` `#[proc_macro]` in `lib.rs`) so it works in ordinary expression +//! position too (e.g. building expected ids in tests). + +use gts_id::GTS_ID_PREFIX; +use syn::parse::ParseStream; +use syn::{Expr, ExprLit, ExprMacro, Lit, LitStr, Path}; + +/// Name of the marker / helper macro that prepends the configured prefix. +pub const PREFIX_MACRO: &str = "gts_id"; + +/// Returns `true` if `path` is `gts_id` or ends with `::gts_id` (i.e. a +/// qualified path whose last segment is the marker macro name). +fn is_prefix_macro_path(path: &Path) -> bool { + path.segments + .last() + .is_some_and(|seg| seg.ident == PREFIX_MACRO) +} + +/// Build the full id literal from a suffix written inside `gts_id!("...")`, +/// preserving the suffix literal's span for diagnostics. +pub fn build_prefixed_lit(suffix: &LitStr) -> LitStr { + LitStr::new(&format!("{GTS_ID_PREFIX}{}", suffix.value()), suffix.span()) +} + +/// Parse a GTS-id macro argument from a parse stream, accepting either a full +/// string literal or the `gts_id!("")` marker form. Returns a `LitStr` +/// holding the full identifier. +pub fn parse_gts_id_arg(input: ParseStream) -> syn::Result { + let expr: Expr = input.parse()?; + gts_id_lit_from_expr(&expr) +} + +/// Extract the full id `LitStr` from an already-parsed expression, accepting +/// the literal and `gts_id!("...")` forms. Any other expression is an error. +pub fn gts_id_lit_from_expr(expr: &Expr) -> syn::Result { + match expr { + Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) => Ok(s.clone()), + Expr::Macro(ExprMacro { mac, .. }) if is_prefix_macro_path(&mac.path) => { + let suffix: LitStr = mac.parse_body().map_err(|_| { + syn::Error::new_spanned( + mac, + format!( + "`{PREFIX_MACRO}!` takes a single string-literal suffix, \ + e.g. `{PREFIX_MACRO}!(\"x.core.events.topic.v1~\")`" + ), + ) + })?; + Ok(build_prefixed_lit(&suffix)) + } + other => Err(syn::Error::new_spanned( + other, + format!("expected a string literal or `{PREFIX_MACRO}!(\"...\")`"), + )), + } +} diff --git a/gts-macros/src/instance.rs b/gts-macros/src/instance.rs index 8c21087..5c77187 100644 --- a/gts-macros/src/instance.rs +++ b/gts-macros/src/instance.rs @@ -33,13 +33,14 @@ //! [`expand_gts_instance`] and [`expand_gts_instance_raw`] here. use crate::ID_FIELD_NAMES; +use gts_id::GTS_ID_PREFIX; use proc_macro2::TokenStream as TokenStream2; use quote::quote; use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; use syn::{ - Attribute, Expr, ExprLit, ExprStruct, FieldValue, GenericArgument, Ident, Lit, LitStr, Path, - PathArguments, Token, Type, parse2, + Attribute, Expr, ExprStruct, FieldValue, GenericArgument, Ident, LitStr, Path, PathArguments, + Token, Type, parse2, }; /// Validate an `instance_id` literal against the full GTS spec via the @@ -181,7 +182,9 @@ impl Parse for TypedInstanceArgs { let instance: ExprStruct = input.parse().map_err(|e| { syn::Error::new( e.span(), - "expected a struct literal: `StructPath { id: \"gts...\", ...other fields... }`", + format!( + "expected a struct literal: `StructPath {{ id: \"{GTS_ID_PREFIX}...\", ...other fields... }}`" + ), ) })?; if !input.is_empty() { @@ -226,19 +229,17 @@ fn extract_id_field(instance: &ExprStruct) -> syn::Result<(usize, Ident, LitStr) ), )); } - let Expr::Lit(ExprLit { - lit: Lit::Str(lit_str), - .. - }) = &field.expr - else { - return Err(syn::Error::new_spanned( + let lit_str = crate::id_arg::gts_id_lit_from_expr(&field.expr).map_err(|_| { + syn::Error::new_spanned( &field.expr, format!( - "`{name}:` must be a string literal containing the full GTS instance id (e.g. \"gts.acme.core.events.topic.v1~vendor.app.x.v1\")" + "`{name}:` must be a string literal containing the full GTS instance id \ + (e.g. \"{GTS_ID_PREFIX}acme.core.events.topic.v1~vendor.app.x.v1\") or the \ + prefix-less marker `gts_id!(\"...\")`" ), - )); - }; - found = Some((idx, ident.clone(), lit_str.clone())); + ) + })?; + found = Some((idx, ident.clone(), lit_str)); } found.ok_or_else(|| { @@ -431,12 +432,18 @@ impl Parse for RawInstanceArgs { err.combine(syn::Error::new_spanned(prev, "first `\"id\"` key was here")); return Err(err); } - let id_lit: LitStr = parse2(entry.value.clone()).map_err(|_| { + let bad_id = || { syn::Error::new_spanned( &entry.value, - "`\"id\"` must be a string literal containing the full GTS instance id (e.g. \"gts.acme.core.events.topic.v1~vendor.app.x.v1\")", + format!( + "`\"id\"` must be a string literal containing the full GTS instance id \ + (e.g. \"{GTS_ID_PREFIX}acme.core.events.topic.v1~vendor.app.x.v1\") or the \ + prefix-less marker `gts_id!(\"...\")`" + ), ) - })?; + }; + let id_expr: Expr = parse2(entry.value.clone()).map_err(|_| bad_id())?; + let id_lit = crate::id_arg::gts_id_lit_from_expr(&id_expr).map_err(|_| bad_id())?; found_id = Some(id_lit); } diff --git a/gts-macros/src/lib.rs b/gts-macros/src/lib.rs index 60d4bff..5f92a5e 100644 --- a/gts-macros/src/lib.rs +++ b/gts-macros/src/lib.rs @@ -1,6 +1,7 @@ // Proc macros run at compile time, so panics become compile errors #![allow(clippy::expect_used, clippy::unwrap_used)] +use gts_id::GTS_ID_PREFIX; use proc_macro::TokenStream; use quote::quote; use syn::{ @@ -324,7 +325,7 @@ fn validate_version_match(struct_ident: &syn::Ident, type_id: &str) -> syn::Resu format!( "struct_to_gts_schema: Struct '{struct_name}' has version suffix '{}' but \ cannot extract version from type_id '{type_id}'. \ - Expected format with version like 'gts.x.foo.v1~' or 'gts.x.foo.v1.0~'", + Expected format with version like '{GTS_ID_PREFIX}x.foo.v1~' or '{GTS_ID_PREFIX}x.foo.v1.0~'", sv.to_struct_suffix() ), )), @@ -333,7 +334,7 @@ fn validate_version_match(struct_ident: &syn::Ident, type_id: &str) -> syn::Resu format!( "struct_to_gts_schema: Both struct name and type_id must have a version. \ Struct '{struct_name}' has no version suffix (e.g., V1) and type_id '{type_id}' \ - has no version (e.g., v1~). Add version to both (e.g., '{struct_name}V1' with 'gts.x.foo.v1~')" + has no version (e.g., v1~). Add version to both (e.g., '{struct_name}V1' with '{GTS_ID_PREFIX}x.foo.v1~')" ), )), } @@ -701,7 +702,9 @@ impl Parse for GtsSchemaArgs { `schema_id`, not both", )); } - let value: LitStr = input.parse()?; + // Accepts a full id string literal or the prefix-less + // `gts_id!("...")` marker form. + let value = id_arg::parse_gts_id_arg(input)?; let id = value.value(); // Schema-specific check: must end with ~ if !id.ends_with('~') { @@ -2278,8 +2281,31 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream // points must live at the crate root (Rust restriction), so they are // thin shims here that delegate into the module. +mod id_arg; mod instance; +/// Construct a full GTS identifier string from a prefix-less suffix. +/// +/// `gts_id!("x.core.events.topic.v1~")` expands to a `&'static str` literal +/// equal to `concat!(GTS_ID_PREFIX, "x.core.events.topic.v1~")` — i.e. the +/// configured prefix (`gts.` by default, overridable via the `GTS_ID_PREFIX` +/// environment variable) followed by the given suffix. +/// +/// The same `gts_id!("...")` form is also recognized as a marker inside the +/// `type_id`/`id` arguments of [`struct_to_gts_schema`], [`gts_instance!`], and +/// [`gts_instance_raw!`], so identifiers can be written prefix-free everywhere. +/// +/// ```ignore +/// let id: &str = gts_macros::gts_id!("acme.core.events.topic.v1~ven.app.x.v1"); +/// // With the default prefix: "gts.acme.core.events.topic.v1~ven.app.x.v1" +/// ``` +#[proc_macro] +pub fn gts_id(input: TokenStream) -> TokenStream { + let suffix = parse_macro_input!(input as LitStr); + let full = id_arg::build_prefixed_lit(&suffix); + quote!(#full).into() +} + /// Typed GTS instance. /// /// The macro takes a Rust struct literal and rewrites the GTS instance-id diff --git a/gts-macros/tests/compile_fail/instance_id_not_literal.stderr b/gts-macros/tests/compile_fail/instance_id_not_literal.stderr index 6009af8..2cf5853 100644 --- a/gts-macros/tests/compile_fail/instance_id_not_literal.stderr +++ b/gts-macros/tests/compile_fail/instance_id_not_literal.stderr @@ -1,4 +1,4 @@ -error: `id:` must be a string literal containing the full GTS instance id (e.g. "gts.acme.core.events.topic.v1~vendor.app.x.v1") +error: `id:` must be a string literal containing the full GTS instance id (e.g. "gts.acme.core.events.topic.v1~vendor.app.x.v1") or the prefix-less marker `gts_id!("...")` --> tests/compile_fail/instance_id_not_literal.rs:24:13 | 24 | id: runtime_id, diff --git a/gts-macros/tests/compile_fail/instance_raw_id_not_literal.stderr b/gts-macros/tests/compile_fail/instance_raw_id_not_literal.stderr index 93017b3..31207f8 100644 --- a/gts-macros/tests/compile_fail/instance_raw_id_not_literal.stderr +++ b/gts-macros/tests/compile_fail/instance_raw_id_not_literal.stderr @@ -1,4 +1,4 @@ -error: `"id"` must be a string literal containing the full GTS instance id (e.g. "gts.acme.core.events.topic.v1~vendor.app.x.v1") +error: `"id"` must be a string literal containing the full GTS instance id (e.g. "gts.acme.core.events.topic.v1~vendor.app.x.v1") or the prefix-less marker `gts_id!("...")` --> tests/compile_fail/instance_raw_id_not_literal.rs:10:15 | 10 | "id": runtime_id, diff --git a/gts-macros/tests/golden/traits_bool_false.rs b/gts-macros/tests/golden/traits_bool_false.rs index c777b26..95aa920 100644 --- a/gts-macros/tests/golden/traits_bool_false.rs +++ b/gts-macros/tests/golden/traits_bool_false.rs @@ -8,7 +8,7 @@ use schemars::JsonSchema; #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.test.golden.closed.v1~", + type_id = gts_id!("x.test.golden.closed.v1~"), description = "Traits-prohibited host", properties = "id", traits_schema = false diff --git a/gts-macros/tests/golden/traits_bool_true.rs b/gts-macros/tests/golden/traits_bool_true.rs index fe2fa25..136b15b 100644 --- a/gts-macros/tests/golden/traits_bool_true.rs +++ b/gts-macros/tests/golden/traits_bool_true.rs @@ -9,7 +9,7 @@ use schemars::JsonSchema; #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.test.golden.open.v1~", + type_id = gts_id!("x.test.golden.open.v1~"), description = "Open traits host", properties = "id", traits_schema = true, diff --git a/gts-macros/tests/golden/traits_generic_child.rs b/gts-macros/tests/golden/traits_generic_child.rs index 59049da..b7e0ba3 100644 --- a/gts-macros/tests/golden/traits_generic_child.rs +++ b/gts-macros/tests/golden/traits_generic_child.rs @@ -18,7 +18,7 @@ pub struct EventTraits { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.test.gen.event.v1~", + type_id = gts_id!("x.test.gen.event.v1~"), description = "Abstract generic base", properties = "id,payload", traits_schema = inline(EventTraits), @@ -33,7 +33,7 @@ pub struct EventV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = EventV1, - type_id = "gts.x.test.gen.event.v1~x.test.audit.event.v1~", + type_id = gts_id!("x.test.gen.event.v1~x.test.audit.event.v1~"), description = "Still-generic abstract mid resolving the inherited topic trait", properties = "user_id,data", traits = serde_json::json!({ diff --git a/gts-macros/tests/golden/traits_inline_chain.rs b/gts-macros/tests/golden/traits_inline_chain.rs index 2fe33c1..9373d45 100644 --- a/gts-macros/tests/golden/traits_inline_chain.rs +++ b/gts-macros/tests/golden/traits_inline_chain.rs @@ -47,7 +47,7 @@ pub struct EventTraits { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.test.golden.event.v1~", + type_id = gts_id!("x.test.golden.event.v1~"), description = "Base event", properties = "id,payload", traits_schema = inline(EventTraits), @@ -62,7 +62,7 @@ pub struct EventV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = EventV1, - type_id = "gts.x.test.golden.event.v1~x.test.order.placed.v1~", + type_id = gts_id!("x.test.golden.event.v1~x.test.order.placed.v1~"), description = "Order placed", properties = "order_id", traits = serde_json::json!({ diff --git a/gts-macros/tests/golden/traits_referenced_chain.rs b/gts-macros/tests/golden/traits_referenced_chain.rs index 35d6cc9..ec079c6 100644 --- a/gts-macros/tests/golden/traits_referenced_chain.rs +++ b/gts-macros/tests/golden/traits_referenced_chain.rs @@ -28,7 +28,7 @@ pub enum Priority { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.test.traits.priority.v1~", + type_id = gts_id!("x.test.traits.priority.v1~"), description = "Reusable priority trait with an open payload slot", properties = "id,label,priority,payload" )] @@ -44,7 +44,7 @@ pub struct PriorityTraitV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = PriorityTraitV1, - type_id = "gts.x.test.traits.priority.v1~x.test.urgent.detail.v1~", + type_id = gts_id!("x.test.traits.priority.v1~x.test.urgent.detail.v1~"), description = "Priority trait specifying the payload slot", properties = "category" )] @@ -58,7 +58,7 @@ pub struct UrgentDetailTraitV1 { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.test.golden.refchain.v1~", + type_id = gts_id!("x.test.golden.refchain.v1~"), description = "Base host referencing the base priority trait type", properties = "id,payload", traits_schema = PriorityTraitV1::<()>, @@ -76,7 +76,7 @@ pub struct RefBaseV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = RefBaseV1, - type_id = "gts.x.test.golden.refchain.v1~x.test.urgent.event.v1~", + type_id = gts_id!("x.test.golden.refchain.v1~x.test.urgent.event.v1~"), description = "Derived host referencing the derived priority trait type", properties = "order_id", traits_schema = UrgentDetailTraitV1, diff --git a/gts-macros/tests/golden/traits_schema_narrowing.rs b/gts-macros/tests/golden/traits_schema_narrowing.rs index a75b96f..6a0f851 100644 --- a/gts-macros/tests/golden/traits_schema_narrowing.rs +++ b/gts-macros/tests/golden/traits_schema_narrowing.rs @@ -23,7 +23,7 @@ pub struct NarrowPriorityTraits { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.test.golden.narrow.v1~", + type_id = gts_id!("x.test.golden.narrow.v1~"), description = "Base declaring an open-string priority trait", properties = "id,payload", traits_schema = inline(BasePriorityTraits), @@ -38,7 +38,7 @@ pub struct NarrowBaseV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = NarrowBaseV1, - type_id = "gts.x.test.golden.narrow.v1~x.test.urgent.event.v1~", + type_id = gts_id!("x.test.golden.narrow.v1~x.test.urgent.event.v1~"), description = "Derived narrowing priority to an enum and resolving it", properties = "order_id", traits_schema = inline(NarrowPriorityTraits), diff --git a/gts-macros/tests/golden/traits_struct_literal.rs b/gts-macros/tests/golden/traits_struct_literal.rs index fc69cbc..7b618a0 100644 --- a/gts-macros/tests/golden/traits_struct_literal.rs +++ b/gts-macros/tests/golden/traits_struct_literal.rs @@ -21,7 +21,7 @@ pub struct OrderTraits { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.test.golden.litevent.v1~", + type_id = gts_id!("x.test.golden.litevent.v1~"), description = "Abstract base declaring the order trait shape", properties = "id,payload", traits_schema = inline(OrderTraits), @@ -36,7 +36,7 @@ pub struct LitEventV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = LitEventV1, - type_id = "gts.x.test.golden.litevent.v1~x.test.order.placed.v1~", + type_id = gts_id!("x.test.golden.litevent.v1~x.test.order.placed.v1~"), description = "Leaf resolving every trait via a struct literal", properties = "order_id", traits = OrderTraits { diff --git a/gts-macros/tests/instance_macro_tests.rs b/gts-macros/tests/instance_macro_tests.rs index be344a3..f0aebf0 100644 --- a/gts-macros/tests/instance_macro_tests.rs +++ b/gts-macros/tests/instance_macro_tests.rs @@ -20,7 +20,7 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use gts::GtsInstanceId; -use gts_macros::{gts_instance, gts_instance_raw, struct_to_gts_schema}; +use gts_macros::{gts_id, gts_instance, gts_instance_raw, struct_to_gts_schema}; // ---------- Local test types ---------- // @@ -30,7 +30,7 @@ use gts_macros::{gts_instance, gts_instance_raw, struct_to_gts_schema}; #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.acme.core.events.topic.v1~", + type_id = gts_id!("acme.core.events.topic.v1~"), description = "Test topic type used to exercise gts_instance!", properties = "id,name,retention" )] @@ -49,7 +49,7 @@ pub struct TopicV1 { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.acme.core.test.base.v1~", + type_id = gts_id!("acme.core.test.base.v1~"), description = "Generic base type for chained-instance turbofish tests", properties = "id,payload" )] @@ -62,7 +62,7 @@ pub struct BaseV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = BaseV1, - type_id = "gts.acme.core.test.base.v1~acme.core.test.leaf.v1~", + type_id = gts_id!("acme.core.test.base.v1~acme.core.test.leaf.v1~"), description = "Derived leaf for chained-instance turbofish tests", properties = "name" )] @@ -83,7 +83,7 @@ pub struct LeafV1 { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.acme.core.test.l1.v1~", + type_id = gts_id!("acme.core.test.l1.v1~"), description = "Level-1 base for three-level chained-instance tests", properties = "id,payload" )] @@ -96,7 +96,7 @@ pub struct L1OuterV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = L1OuterV1, - type_id = "gts.acme.core.test.l1.v1~acme.core.test.l2.v1~", + type_id = gts_id!("acme.core.test.l1.v1~acme.core.test.l2.v1~"), description = "Level-2 mid for three-level chained-instance tests", properties = "data" )] @@ -108,7 +108,7 @@ pub struct L2MidV1 { #[struct_to_gts_schema( dir_path = "schemas", base = L2MidV1, - type_id = "gts.acme.core.test.l1.v1~acme.core.test.l2.v1~acme.core.test.l3.v1~", + type_id = gts_id!("acme.core.test.l1.v1~acme.core.test.l2.v1~acme.core.test.l3.v1~"), description = "Level-3 leaf for three-level chained-instance tests", properties = "value" )] @@ -124,7 +124,7 @@ pub struct L3LeafV1 { #[test] fn typed_form_constructs_value_with_rewritten_id() { let t: TopicV1 = gts_instance!(TopicV1 { - id: "gts.acme.core.events.topic.v1~vendor.app.orders.created.v1", + id: gts_id!("acme.core.events.topic.v1~vendor.app.orders.created.v1"), name: "orders".to_owned(), retention: "P30D".to_owned(), }); @@ -133,14 +133,14 @@ fn typed_form_constructs_value_with_rewritten_id() { assert_eq!(t.retention, "P30D"); assert_eq!( t.id.as_ref(), - "gts.acme.core.events.topic.v1~vendor.app.orders.created.v1" + gts_id!("acme.core.events.topic.v1~vendor.app.orders.created.v1") ); } #[test] fn typed_form_serialises_with_id_field() { let t: TopicV1 = gts_instance!(TopicV1 { - id: "gts.acme.core.events.topic.v1~vendor.app.events.audit.v1", + id: gts_id!("acme.core.events.topic.v1~vendor.app.events.audit.v1"), name: "audit".to_owned(), retention: "P7D".to_owned(), }); @@ -148,7 +148,7 @@ fn typed_form_serialises_with_id_field() { let v = serde_json::to_value(&t).unwrap(); assert_eq!( v["id"].as_str().unwrap(), - "gts.acme.core.events.topic.v1~vendor.app.events.audit.v1" + gts_id!("acme.core.events.topic.v1~vendor.app.events.audit.v1") ); assert_eq!(v["name"].as_str().unwrap(), "audit"); } @@ -159,7 +159,7 @@ fn typed_form_chained_derives_target_from_turbofish() { // const-assert target is derived as `LeafV1` and the literal must // match `::TYPE_ID` (the full chain prefix). let v: BaseV1 = gts_instance!(BaseV1:: { - id: "gts.acme.core.test.base.v1~acme.core.test.leaf.v1~vendor.app.things.example.v1", + id: gts_id!("acme.core.test.base.v1~acme.core.test.leaf.v1~vendor.app.things.example.v1"), payload: LeafV1 { name: "ex".to_owned() }, @@ -167,7 +167,7 @@ fn typed_form_chained_derives_target_from_turbofish() { assert_eq!( v.id.as_ref(), - "gts.acme.core.test.base.v1~acme.core.test.leaf.v1~vendor.app.things.example.v1" + gts_id!("acme.core.test.base.v1~acme.core.test.leaf.v1~vendor.app.things.example.v1") ); } @@ -180,7 +180,9 @@ fn typed_form_three_level_chain_picks_deepest_generic() { // also reject (its `TYPE_ID` is the L1~L2~ prefix, leaving the L3 // segment in the suffix and tripping the no-tilde-in-segment check). let v: L1OuterV1> = gts_instance!(L1OuterV1::> { - id: "gts.acme.core.test.l1.v1~acme.core.test.l2.v1~acme.core.test.l3.v1~vendor.app.things.deep.v1", + id: gts_id!( + "acme.core.test.l1.v1~acme.core.test.l2.v1~acme.core.test.l3.v1~vendor.app.things.deep.v1" + ), payload: L2MidV1 { data: L3LeafV1 { value: "deep".to_owned() @@ -190,7 +192,9 @@ fn typed_form_three_level_chain_picks_deepest_generic() { assert_eq!( v.id.as_ref(), - "gts.acme.core.test.l1.v1~acme.core.test.l2.v1~acme.core.test.l3.v1~vendor.app.things.deep.v1" + gts_id!( + "acme.core.test.l1.v1~acme.core.test.l2.v1~acme.core.test.l3.v1~vendor.app.things.deep.v1" + ) ); assert_eq!(v.payload.data.value, "deep"); } @@ -200,13 +204,13 @@ fn typed_form_unit_param_keeps_carrier_as_target() { // `BaseV1::<()>` denotes a base-level instance — the descent stops on // `()` (no `TYPE_ID`) and the carrier is kept as the target. let v: BaseV1<()> = gts_instance!(BaseV1::<()> { - id: "gts.acme.core.test.base.v1~vendor.app.things.bare.v1", + id: gts_id!("acme.core.test.base.v1~vendor.app.things.bare.v1"), payload: (), }); assert_eq!( v.id.as_ref(), - "gts.acme.core.test.base.v1~vendor.app.things.bare.v1" + gts_id!("acme.core.test.base.v1~vendor.app.things.bare.v1") ); } @@ -217,7 +221,7 @@ fn typed_form_unit_param_keeps_carrier_as_target() { gts_instance! { #[gts_static(ORDERS_TOPIC)] TopicV1 { - id: "gts.acme.core.events.topic.v1~vendor.app.orders.created.v1", + id: gts_id!("acme.core.events.topic.v1~vendor.app.orders.created.v1"), name: "orders".to_owned(), retention: "P30D".to_owned(), } @@ -230,7 +234,7 @@ fn static_form_exposes_typed_static_value() { assert_eq!(t.retention, "P30D"); assert_eq!( t.id.as_ref(), - "gts.acme.core.events.topic.v1~vendor.app.orders.created.v1" + gts_id!("acme.core.events.topic.v1~vendor.app.orders.created.v1") ); } @@ -247,7 +251,7 @@ fn static_form_static_is_lazy_and_stable() { gts_instance! { #[gts_static(EXAMPLE_LEAF)] BaseV1:: { - id: "gts.acme.core.test.base.v1~acme.core.test.leaf.v1~vendor.app.things.example.v1", + id: gts_id!("acme.core.test.base.v1~acme.core.test.leaf.v1~vendor.app.things.example.v1"), payload: LeafV1 { name: "ex".to_owned() }, } } @@ -257,7 +261,7 @@ fn static_form_chained_carrier_with_auto_derivation() { let v: &BaseV1 = &EXAMPLE_LEAF; assert_eq!( v.id.as_ref(), - "gts.acme.core.test.base.v1~acme.core.test.leaf.v1~vendor.app.things.example.v1" + gts_id!("acme.core.test.base.v1~acme.core.test.leaf.v1~vendor.app.things.example.v1") ); assert_eq!(v.payload.name, "ex"); } @@ -269,14 +273,14 @@ fn static_form_chained_carrier_with_auto_derivation() { #[test] fn raw_form_constructs_json_value_with_validated_id() { let v: serde_json::Value = gts_instance_raw!({ - "id": "gts.acme.core.events.topic.v1~vendor.app.events.audit.v1", + "id": gts_id!("acme.core.events.topic.v1~vendor.app.events.audit.v1"), "name": "audit", "description": "Audit log events" }); assert_eq!( v["id"].as_str().unwrap(), - "gts.acme.core.events.topic.v1~vendor.app.events.audit.v1" + gts_id!("acme.core.events.topic.v1~vendor.app.events.audit.v1") ); assert_eq!(v["name"].as_str().unwrap(), "audit"); assert_eq!(v["description"].as_str().unwrap(), "Audit log events"); @@ -285,13 +289,13 @@ fn raw_form_constructs_json_value_with_validated_id() { #[test] fn raw_form_supports_chained_instance_ids() { let v: serde_json::Value = gts_instance_raw!({ - "id": "gts.acme.core.test.base.v1~acme.core.test.leaf.v1~vendor.app.things.x.v1", + "id": gts_id!("acme.core.test.base.v1~acme.core.test.leaf.v1~vendor.app.things.x.v1"), "value": 42, }); assert_eq!( v["id"].as_str().unwrap(), - "gts.acme.core.test.base.v1~acme.core.test.leaf.v1~vendor.app.things.x.v1" + gts_id!("acme.core.test.base.v1~acme.core.test.leaf.v1~vendor.app.things.x.v1") ); assert_eq!(v["value"].as_i64().unwrap(), 42); } @@ -301,14 +305,14 @@ fn raw_form_supports_nested_objects_and_arrays() { // Top-level `id` is the only key the macro inspects; nested objects // and arrays pass through to `json!` untouched. let v: serde_json::Value = gts_instance_raw!({ - "id": "gts.acme.core.events.topic.v1~vendor.app.events.audit.v1", + "id": gts_id!("acme.core.events.topic.v1~vendor.app.events.audit.v1"), "tags": ["a", "b", "c"], "meta": { "id": "nested-not-touched", "count": 3 }, }); assert_eq!( v["id"].as_str().unwrap(), - "gts.acme.core.events.topic.v1~vendor.app.events.audit.v1" + gts_id!("acme.core.events.topic.v1~vendor.app.events.audit.v1") ); assert_eq!(v["tags"][1].as_str().unwrap(), "b"); assert_eq!(v["meta"]["id"].as_str().unwrap(), "nested-not-touched"); @@ -322,7 +326,7 @@ fn raw_form_supports_nested_objects_and_arrays() { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.acme.core.events.legacy_topic.v1~", + type_id = gts_id!("acme.core.events.legacy_topic.v1~"), description = "Legacy-style base struct using gts_id instead of id", properties = "gts_id,name" )] @@ -337,14 +341,14 @@ fn typed_form_picks_up_gts_id_field_automatically() { // Schema struct uses `gts_id` instead of `id` — the macro picks // whichever reserved id-field name appears in the literal. let t: LegacyTopicV1 = gts_instance!(LegacyTopicV1 { - gts_id: "gts.acme.core.events.legacy_topic.v1~vendor.app.legacy.example.v1", + gts_id: gts_id!("acme.core.events.legacy_topic.v1~vendor.app.legacy.example.v1"), name: "legacy".to_owned(), }); assert_eq!(t.name, "legacy"); assert_eq!( t.gts_id.as_ref(), - "gts.acme.core.events.legacy_topic.v1~vendor.app.legacy.example.v1" + gts_id!("acme.core.events.legacy_topic.v1~vendor.app.legacy.example.v1") ); } @@ -354,7 +358,7 @@ fn typed_form_picks_up_gts_id_field_automatically() { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.acme.core.events.legacy_topic_camel.v1~", + type_id = gts_id!("acme.core.events.legacy_topic_camel.v1~"), description = "Legacy-style base struct using the gtsId camelCase alias", properties = "gtsId,name" )] @@ -370,13 +374,68 @@ fn typed_form_picks_up_gtsid_camel_case_field_automatically() { // gtsId is the camelCase alias accepted alongside id / gts_id; the // macro should pick it up identically without any extra modifier. let t: LegacyTopicCamelV1 = gts_instance!(LegacyTopicCamelV1 { - gtsId: "gts.acme.core.events.legacy_topic_camel.v1~vendor.app.legacy.example.v1", + gtsId: gts_id!("acme.core.events.legacy_topic_camel.v1~vendor.app.legacy.example.v1"), name: "legacy".to_owned(), }); assert_eq!(t.name, "legacy"); assert_eq!( t.gtsId.as_ref(), - "gts.acme.core.events.legacy_topic_camel.v1~vendor.app.legacy.example.v1" + gts_id!("acme.core.events.legacy_topic_camel.v1~vendor.app.legacy.example.v1") + ); +} + +// ===================================================================== +// gts_id! marker / helper macro +// ===================================================================== + +#[test] +fn gts_id_macro_prepends_configured_prefix() { + // In expression position the macro expands to a `&'static str` literal + // equal to `concat!(GTS_ID_PREFIX, suffix)`. + let expected = format!("{}acme.core.events.topic.v1~", gts::GTS_ID_PREFIX); + assert_eq!(gts_id!("acme.core.events.topic.v1~"), expected); +} + +#[test] +fn full_literal_id_still_accepted_for_backward_compat() { + // The pre-existing form — a complete id string literal including the + // prefix — must keep working alongside the gts_id!(...) marker. + let t: TopicV1 = gts_instance!(TopicV1 { + id: gts_id!("acme.core.events.topic.v1~vendor.app.compat.literal.v1"), + name: "compat".to_owned(), + retention: "P1D".to_owned(), + }); + assert_eq!( + t.id.as_ref(), + gts_id!("acme.core.events.topic.v1~vendor.app.compat.literal.v1") + ); +} + +#[test] +fn raw_form_accepts_gts_id_marker() { + let v: serde_json::Value = gts_instance_raw!({ + "id": gts_id!("acme.core.events.topic.v1~vendor.app.events.marker.v1"), + "name": "marker", + }); + assert_eq!( + v["id"].as_str().unwrap(), + gts_id!("acme.core.events.topic.v1~vendor.app.events.marker.v1") + ); + assert_eq!(v["name"].as_str().unwrap(), "marker"); +} + +#[test] +fn typed_form_accepts_qualified_gts_id_marker() { + // A fully-qualified `gts_macros::gts_id!(...)` path must be recognized + // just like the bare `gts_id!(...)` form inside macro arguments. + let t: TopicV1 = gts_instance!(TopicV1 { + id: gts_macros::gts_id!("acme.core.events.topic.v1~vendor.app.qualified.evt.v1"), + name: "qualified".to_owned(), + retention: "P1D".to_owned(), + }); + assert_eq!( + t.id.as_ref(), + gts_id!("acme.core.events.topic.v1~vendor.app.qualified.evt.v1") ); } diff --git a/gts-validator/src/format/json.rs b/gts-validator/src/format/json.rs index 2f684ba..725724a 100644 --- a/gts-validator/src/format/json.rs +++ b/gts-validator/src/format/json.rs @@ -4,6 +4,7 @@ use std::path::Path; +use gts::{GTS_ID_PREFIX, GTS_ID_URI_PREFIX}; use serde_json::Value; use crate::error::{ScanError, ScanErrorKind, ValidationError}; @@ -83,7 +84,7 @@ pub fn walk_json_value( // Only consider strings that look like GTS identifiers // Skip filenames that contain GTS IDs (e.g., "gts.x.core.type.v1~.schema.json") // A string is likely a filename if it contains a tilde followed by a dot and extension - let looks_like_filename = !candidate_str.starts_with("gts://") + let looks_like_filename = !candidate_str.starts_with(GTS_ID_URI_PREFIX) && candidate_str.contains("~.") && candidate_str .rfind('.') @@ -94,7 +95,7 @@ pub fn walk_json_value( return; } - if candidate_str.starts_with("gts://gts.") || candidate_str.starts_with("gts.") { + if looks_like_gts_candidate(candidate_str) { match normalize_candidate(candidate_str) { Ok(candidate) => { let allow_wildcards = is_xgts_ref; @@ -131,7 +132,7 @@ pub fn walk_json_value( Value::Object(map) => { for (key, val) in map { // Optionally scan keys - if scan_keys && (key.starts_with("gts://") || key.starts_with("gts.")) { + if scan_keys && looks_like_gts_candidate(key) { match normalize_candidate(key) { Ok(candidate) => { let validation_errors = validate_candidate(&candidate, vendor, false); @@ -188,6 +189,15 @@ pub fn walk_json_value( } } +/// Returns `true` if a string looks like a GTS identifier worth normalizing, +/// either as a bare id (`...`) or wrapped in the `gts://` URI scheme +/// (`gts://...`). Honors the configured [`GTS_ID_PREFIX`]. +fn looks_like_gts_candidate(s: &str) -> bool { + s.strip_prefix(GTS_ID_URI_PREFIX) + .unwrap_or(s) + .starts_with(GTS_ID_PREFIX) +} + #[cfg(test)] mod tests { use super::*; diff --git a/gts-validator/src/format/markdown.rs b/gts-validator/src/format/markdown.rs index 8d4b88c..5a4cfba 100644 --- a/gts-validator/src/format/markdown.rs +++ b/gts-validator/src/format/markdown.rs @@ -8,6 +8,7 @@ use std::collections::HashSet; use std::path::Path; use std::sync::LazyLock; +use gts::{GTS_ID_PREFIX, GTS_ID_URI_PREFIX}; use regex::Regex; use crate::error::ValidationError; @@ -51,16 +52,21 @@ fn parse_fence(trimmed_line: &str) -> Option<(char, usize)> { /// This catches both valid and malformed IDs for validation (more errors reported). /// Stops at tilde followed by non-alphanumeric to avoid matching filenames like "id.v1~.schema.json" static GTS_DISCOVERY_PATTERN_RELAXED: LazyLock = LazyLock::new(|| { - match Regex::new(concat!( - r"(?:gts://)?", // optional URI prefix - r"\bgts\.", // mandatory gts. prefix (word boundary prevents xgts. match) - r"(?:[a-z_*][a-z0-9_*.-]*\.){3,}", // at least 3 segments (permissive: allows -, .) - r"[a-z_*][a-z0-9_*.-]*", // final segment before version - r"\.v[0-9]+", // version segment (required anchor) - r"(?:\.[0-9]+)?", // optional minor version - r"(?:~[a-z_][a-z0-9_.-]*)*", // optional chained segments (permissive) - r"~?", // optional trailing tilde (but not if followed by .) - )) { + let pattern = format!( + concat!( + r"(?:{uri})?", // optional URI prefix + r"\b{prefix}", // mandatory configured prefix (word boundary prevents xgts. match) + r"(?:[a-z_*][a-z0-9_*.-]*\.){{3,}}", // at least 3 segments (permissive: allows -, .) + r"[a-z_*][a-z0-9_*.-]*", // final segment before version + r"\.v[0-9]+", // version segment (required anchor) + r"(?:\.[0-9]+)?", // optional minor version + r"(?:~[a-z_][a-z0-9_.-]*)*", // optional chained segments (permissive) + r"~?", // optional trailing tilde (but not if followed by .) + ), + uri = regex::escape(GTS_ID_URI_PREFIX), + prefix = regex::escape(GTS_ID_PREFIX), + ); + match Regex::new(&pattern) { Ok(regex) => regex, Err(err) => panic!("Invalid discovery regex: {err}"), } @@ -69,18 +75,23 @@ static GTS_DISCOVERY_PATTERN_RELAXED: LazyLock = LazyLock::new(|| { /// Discovery regex (well-formed): only matches well-formed GTS identifiers. /// Requires exactly 5 segments with proper structure (fewer errors reported). static GTS_DISCOVERY_PATTERN_WELL_FORMED: LazyLock = LazyLock::new(|| { - match Regex::new(concat!( - r"(?:gts://)?", // optional URI prefix - r"\bgts\.", // mandatory gts. prefix (word boundary prevents xgts. match) - r"[a-z_*][a-z0-9_*]*\.", // vendor - r"[a-z_*][a-z0-9_*]*\.", // package - r"[a-z_*][a-z0-9_*]*\.", // namespace - r"[a-z_*][a-z0-9_*]*\.", // type - r"v[0-9]+", // major version (required) - r"(?:\.[0-9]+)?", // optional minor version - r"(?:~[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*\.v[0-9]+(?:\.[0-9]+)?)*", // chained segments - r"~?", // optional trailing tilde - )) { + let pattern = format!( + concat!( + r"(?:{uri})?", // optional URI prefix + r"\b{prefix}", // mandatory configured prefix (word boundary prevents xgts. match) + r"[a-z_*][a-z0-9_*]*\.", // vendor + r"[a-z_*][a-z0-9_*]*\.", // package + r"[a-z_*][a-z0-9_*]*\.", // namespace + r"[a-z_*][a-z0-9_*]*\.", // type + r"v[0-9]+", // major version (required) + r"(?:\.[0-9]+)?", // optional minor version + r"(?:~[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*\.v[0-9]+(?:\.[0-9]+)?)*", // chained segments + r"~?", // optional trailing tilde + ), + uri = regex::escape(GTS_ID_URI_PREFIX), + prefix = regex::escape(GTS_ID_PREFIX), + ); + match Regex::new(&pattern) { Ok(regex) => regex, Err(err) => panic!("Invalid discovery regex: {err}"), } diff --git a/gts-validator/src/normalize.rs b/gts-validator/src/normalize.rs index c82ad48..dc34bab 100644 --- a/gts-validator/src/normalize.rs +++ b/gts-validator/src/normalize.rs @@ -4,9 +4,11 @@ //! before passing candidates to the validator. It handles: //! - Trimming whitespace //! - Stripping surrounding quotes -//! - Stripping `gts://` URI prefix +//! - Stripping the `gts://` URI prefix //! - Rejecting URI fragments (#) and query strings (?) -//! - Verifying the `gts.` prefix +//! - Verifying the configured GTS identifier prefix ([`GTS_ID_PREFIX`]) + +use gts::{GTS_ID_PREFIX, GTS_ID_URI_PREFIX}; /// Result of normalizing a raw candidate string. #[derive(Debug, Clone, PartialEq, Eq)] @@ -41,7 +43,7 @@ pub fn normalize_candidate(raw: &str) -> Result { trimmed = &trimmed[1..trimmed.len() - 1]; } - let gts_id = if let Some(stripped) = trimmed.strip_prefix("gts://") { + let gts_id = if let Some(stripped) = trimmed.strip_prefix(GTS_ID_URI_PREFIX) { // Reject URI fragments and query strings — spec section 9.1 says // remainder must be a plain GTS identifier if stripped.contains('#') || stripped.contains('?') { @@ -54,8 +56,8 @@ pub fn normalize_candidate(raw: &str) -> Result { trimmed.to_owned() }; - if !gts_id.starts_with("gts.") { - return Err(format!("Does not start with 'gts.': '{raw}'")); + if !gts_id.starts_with(GTS_ID_PREFIX) { + return Err(format!("Does not start with '{GTS_ID_PREFIX}': '{raw}'")); } Ok(NormalizedCandidate { diff --git a/gts-validator/src/output.rs b/gts-validator/src/output.rs index ac3d778..4ad4f1d 100644 --- a/gts-validator/src/output.rs +++ b/gts-validator/src/output.rs @@ -97,7 +97,8 @@ pub fn write_human(report: &ValidationReport, writer: &mut dyn Write) -> anyhow: if has_parse_error { writeln!( writer, - " - Schema IDs must end with ~ (e.g., gts.x.core.type.v1~)" + " - Schema IDs must end with ~ (e.g., {}x.core.type.v1~)", + gts::GTS_ID_PREFIX )?; writeln!( writer, diff --git a/gts/src/entities.rs b/gts/src/entities.rs index 6d79a12..a90506a 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_ID_PREFIX, GTS_ID_URI_PREFIX, GtsId}; use crate::path_resolver::JsonPathResolver; use crate::schema_cast::{GtsEntityCastResult, SchemaCastError}; @@ -228,15 +228,15 @@ impl GtsEntity { { let trimmed = id_str.trim(); - // Validate that schema $id uses gts:// URI format, not plain gts. prefix + // Validate that schema $id uses gts:// URI format, not the bare GTS_ID_PREFIX form. // According to spec: "Do not place the canonical gts. string directly in $id" - if trimmed.starts_with("gts.") { + if trimmed.starts_with(GTS_ID_PREFIX) { // This is invalid - schemas must use gts:// URI format // We'll leave gts_id as None, which will cause registration to fail return; } - let normalized = trimmed.strip_prefix(GTS_URI_PREFIX).unwrap_or(trimmed); + let normalized = trimmed.strip_prefix(GTS_ID_URI_PREFIX).unwrap_or(trimmed); if let Ok(gts_id) = GtsId::try_new(normalized) { // A Type Schema must be keyed by a type id (ending in `~`). if !gts_id.is_type() { @@ -540,7 +540,7 @@ impl GtsEntity { }; // Normalize: strip gts:// prefix for canonical GTS ID storage let normalized_ref = ref_str - .strip_prefix(GTS_URI_PREFIX) + .strip_prefix(GTS_ID_URI_PREFIX) .unwrap_or(ref_str) .to_owned(); return Some(GtsRef { @@ -562,9 +562,9 @@ impl GtsEntity { { let trimmed = s.trim(); if !trimmed.is_empty() { - // For schema $id fields, validate that they use gts:// URI format, not plain gts. prefix + // For schema $id fields, validate that they use gts:// URI format, not the bare GTS_ID_PREFIX form. // According to spec: "Do not place the canonical gts. string directly in $id" - if field == "$id" && self.is_schema && trimmed.starts_with("gts.") { + if field == "$id" && self.is_schema && trimmed.starts_with(GTS_ID_PREFIX) { // Invalid: schema $id must use gts:// URI format return None; } @@ -572,7 +572,7 @@ impl GtsEntity { // Strip the "gts://" URI prefix ONLY for $id field (JSON Schema compatibility) // The gts:// prefix is ONLY valid in the $id field of JSON Schema let normalized = if field == "$id" { - trimmed.strip_prefix(GTS_URI_PREFIX).unwrap_or(trimmed) + trimmed.strip_prefix(GTS_ID_URI_PREFIX).unwrap_or(trimmed) } else { trimmed }; @@ -1515,7 +1515,7 @@ mod tests { !entity .schema_refs .iter() - .any(|r| r.id.starts_with("gts://")), + .any(|r| r.id.starts_with(crate::GTS_ID_URI_PREFIX)), "No ref should contain gts:// prefix" ); } diff --git a/gts/src/gts.rs b/gts/src/gts.rs index 29be2a7..2738486 100644 --- a/gts/src/gts.rs +++ b/gts/src/gts.rs @@ -15,8 +15,8 @@ use std::fmt; pub use gts_id::{ - GTS_PREFIX, GtsId, GtsIdError, GtsIdPattern, GtsIdPatternSegment, GtsIdSegment, - GtsIdSegmentParts, GtsUuidTail, + DEFAULT_GTS_ID_PREFIX, GTS_ID_MAX_LENGTH, GTS_ID_PREFIX, GTS_ID_PREFIX_ENV, GtsId, GtsIdError, + GtsIdPattern, GtsIdPatternSegment, GtsIdSegment, GtsIdSegmentParts, GtsUuidTail, }; /// A type-safe wrapper for GTS entity identifiers. @@ -55,7 +55,7 @@ impl AsRef for GtsEntityId { /// 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://"; +pub const GTS_ID_URI_PREFIX: &str = "gts://"; /// A type-safe wrapper for GTS instance identifiers. /// @@ -138,7 +138,7 @@ impl GtsInstanceId { /// let schema = GtsInstanceId::json_schema_value(); /// assert_eq!(schema["type"], "string"); /// assert_eq!(schema["format"], "gts-instance-id"); - /// assert_eq!(schema["x-gts-ref"], "gts.*"); + /// assert_eq!(schema["x-gts-ref"], format!("{}*", gts::GTS_ID_PREFIX)); /// ``` #[must_use] pub fn json_schema_value() -> serde_json::Value { @@ -147,7 +147,7 @@ impl GtsInstanceId { "format": "gts-instance-id", "title": "GTS Instance ID", "description": "GTS instance identifier", - "x-gts-ref": "gts.*" + "x-gts-ref": format!("{GTS_ID_PREFIX}*") }) } @@ -342,7 +342,7 @@ impl GtsTypeId { /// let schema = GtsTypeId::json_schema_value(); /// assert_eq!(schema["type"], "string"); /// assert_eq!(schema["format"], "gts-type-id"); - /// assert_eq!(schema["x-gts-ref"], "gts.*"); + /// assert_eq!(schema["x-gts-ref"], format!("{}*", gts::GTS_ID_PREFIX)); /// ``` #[must_use] pub fn json_schema_value() -> serde_json::Value { @@ -351,7 +351,7 @@ impl GtsTypeId { "format": "gts-type-id", "title": "GTS Type ID", "description": "GTS type identifier", - "x-gts-ref": "gts.*" + "x-gts-ref": format!("{GTS_ID_PREFIX}*") }) } @@ -513,4 +513,11 @@ mod tests { "a non-'~' instance id must not deserialize as a type id" ); } + + #[test] + fn test_json_schema_values_use_configured_id_prefix() { + let expected = format!("{GTS_ID_PREFIX}*"); + assert_eq!(GtsInstanceId::json_schema_value()["x-gts-ref"], expected); + assert_eq!(GtsTypeId::json_schema_value()["x-gts-ref"], expected); + } } diff --git a/gts/src/lib.rs b/gts/src/lib.rs index c4ac37b..0e3b698 100644 --- a/gts/src/lib.rs +++ b/gts/src/lib.rs @@ -22,6 +22,7 @@ pub use files_reader::GtsFileReader; #[allow(deprecated)] pub use gts::GtsSchemaId; pub use gts::{ + DEFAULT_GTS_ID_PREFIX, GTS_ID_MAX_LENGTH, GTS_ID_PREFIX, GTS_ID_PREFIX_ENV, GTS_ID_URI_PREFIX, GtsId, GtsIdError, GtsIdPattern, GtsIdPatternSegment, GtsIdSegment, GtsIdSegmentParts, GtsInstanceId, GtsTypeId, GtsUuidTail, }; diff --git a/gts/src/schema.rs b/gts/src/schema.rs index 7a77a33..516e692 100644 --- a/gts/src/schema.rs +++ b/gts/src/schema.rs @@ -5,6 +5,8 @@ use serde_json::Value; +use crate::GTS_ID_URI_PREFIX; + /// The JSON Schema **draft-07** dialect URI that GTS Type Schemas declare via /// `$schema`. Single source of truth for the value emitted by the schema /// generators (the `struct_to_gts_schema` macro, the CLI generator). @@ -491,12 +493,12 @@ pub fn build_gts_allof_schema( required: &[&str], ) -> Value { serde_json::json!({ - "$id": format!("gts://{}", innermost_type_id), + "$id": format!("{GTS_ID_URI_PREFIX}{}", innermost_type_id), "$schema": "http://json-schema.org/draft-07/schema#", "title": title, "type": "object", "allOf": [ - { "$ref": format!("gts://{}", base_type_id) }, + { "$ref": format!("{GTS_ID_URI_PREFIX}{}", base_type_id) }, { "type": "object", "properties": own_properties, diff --git a/gts/src/schema_modifiers.rs b/gts/src/schema_modifiers.rs index e9030e5..a777b70 100644 --- a/gts/src/schema_modifiers.rs +++ b/gts/src/schema_modifiers.rs @@ -407,11 +407,14 @@ mod tests { fn reg_schema(store: &mut GtsStore, content: Value) { // Ensure $id has gts:// prefix for entity detection let content = if let Some(id) = content.get("$id").and_then(|v| v.as_str()) { - if id.starts_with("gts://") { + if id.starts_with(crate::GTS_ID_URI_PREFIX) { content } else { let mut c = content.as_object().unwrap().clone(); - c.insert("$id".to_owned(), json!(format!("gts://{id}"))); + c.insert( + "$id".to_owned(), + json!(format!("{}{id}", crate::GTS_ID_URI_PREFIX)), + ); Value::Object(c) } } else { diff --git a/gts/src/schema_refs.rs b/gts/src/schema_refs.rs index 934ede9..481f0c3 100644 --- a/gts/src/schema_refs.rs +++ b/gts/src/schema_refs.rs @@ -1,7 +1,7 @@ use serde_json::Value; use std::collections::BTreeSet; -use crate::gts::{GTS_URI_PREFIX, GtsTypeId}; +use crate::gts::{GTS_ID_URI_PREFIX, GtsTypeId}; /// Why a single `$ref` string is not a valid GTS reference. /// @@ -57,7 +57,7 @@ fn classify_ref(ref_uri: &str) -> Result, InvalidRefReason> { // Everything else must be a `gts://` URI; a bare id or any other scheme is // not a ref the store can resolve. - let Some(rest) = ref_uri.strip_prefix(GTS_URI_PREFIX) else { + let Some(rest) = ref_uri.strip_prefix(GTS_ID_URI_PREFIX) else { return Err(InvalidRefReason::NotGtsUri); }; diff --git a/gts/src/schema_resolver.rs b/gts/src/schema_resolver.rs index 7167791..252cf9b 100644 --- a/gts/src/schema_resolver.rs +++ b/gts/src/schema_resolver.rs @@ -11,7 +11,7 @@ use serde_json::Value; -use crate::gts::GTS_URI_PREFIX; +use crate::gts::GTS_ID_URI_PREFIX; use crate::store::StoreError; /// Read-only schema lookup the resolver needs from its host. @@ -144,7 +144,7 @@ impl<'a> SchemaResolver<'a> { } // Normalize the ref: strip gts:// prefix to get canonical GTS ID - let canonical_ref = ref_uri.strip_prefix(GTS_URI_PREFIX).unwrap_or(ref_uri); + let canonical_ref = ref_uri.strip_prefix(GTS_ID_URI_PREFIX).unwrap_or(ref_uri); let (lookup_ref, pointer_fragment) = if let Some((id, fragment)) = canonical_ref.split_once('#') { let pointer = if fragment.is_empty() { diff --git a/gts/src/schema_traits.rs b/gts/src/schema_traits.rs index ae85420..5e86e3a 100644 --- a/gts/src/schema_traits.rs +++ b/gts/src/schema_traits.rs @@ -2511,6 +2511,6 @@ mod inline_traits_schema_tests { let prop = &schema["properties"]["topic_ref"]; assert_eq!(prop["type"], "string"); assert_eq!(prop["format"], "gts-instance-id"); - assert_eq!(prop["x-gts-ref"], "gts.*"); + assert_eq!(prop["x-gts-ref"], format!("{}*", crate::GTS_ID_PREFIX)); } } diff --git a/gts/src/store.rs b/gts/src/store.rs index ec06874..9c95352 100644 --- a/gts/src/store.rs +++ b/gts/src/store.rs @@ -399,28 +399,13 @@ impl GtsStore { } // Build pairs of (base_id, derived_id) for each adjacent level - // Note: segment.segment already includes the trailing '~' for type segments - let segments = &gid.segments(); - for i in 0..segments.len() - 1 { - let base_id = format!( - "gts.{}", - segments[..=i] - .iter() - .map(gts_id::GtsIdSegment::raw) - .collect::>() - .join("") - ); - let derived_id = format!( - "gts.{}", - segments[..=i + 1] - .iter() - .map(gts_id::GtsIdSegment::raw) - .collect::>() - .join("") - ); + let chain_ids = gid.chain_ids(); + for i in 0..chain_ids.len() - 1 { + let base_id = &chain_ids[i]; + let derived_id = &chain_ids[i + 1]; // Check x-gts-final: if the base type is final, derivation is not allowed. - if let Some(base_entity) = self.get(&base_id) + if let Some(base_entity) = self.get(base_id) && base_entity .content .get(crate::schema_modifiers::X_GTS_FINAL) @@ -438,12 +423,12 @@ impl GtsStore { ); // Get and resolve both schemas - let base_content = self.get_schema_content(&base_id).map_err(|_| { + let base_content = self.get_schema_content(base_id).map_err(|_| { StoreError::ValidationError(format!( "Base schema '{base_id}' not found for chain validation" )) })?; - let derived_content = self.get_schema_content(&derived_id).map_err(|_| { + let derived_content = self.get_schema_content(derived_id).map_err(|_| { StoreError::ValidationError(format!( "Derived schema '{derived_id}' not found for chain validation" )) @@ -459,8 +444,8 @@ impl GtsStore { let errors = crate::schema_compat::validate_schema_compatibility( &base_resolved, &derived_resolved, - &base_id, - &derived_id, + base_id, + derived_id, ); if !errors.is_empty() { @@ -518,22 +503,12 @@ impl GtsStore { ) -> Result { let gid = GtsId::try_new(type_id) .map_err(|e| StoreError::ValidationError(format!("Invalid GTS ID: {e}")))?; - let segments = &gid.segments(); let mut trait_schemas: Vec = Vec::new(); let mut merged_traits = serde_json::Map::new(); - for i in 0..segments.len() { - let schema_id = format!( - "gts.{}", - segments[..=i] - .iter() - .map(gts_id::GtsIdSegment::raw) - .collect::>() - .join("") - ); - - let content = self.get_schema_content(&schema_id).map_err(|_| { + for schema_id in &gid.chain_ids() { + let content = self.get_schema_content(schema_id).map_err(|_| { StoreError::ValidationError(format!( "Schema '{schema_id}' not found for trait validation" )) diff --git a/gts/src/testing.rs b/gts/src/testing.rs index ccee036..b6d9603 100644 --- a/gts/src/testing.rs +++ b/gts/src/testing.rs @@ -7,6 +7,7 @@ use serde_json::Value; +use crate::GTS_ID_URI_PREFIX; use crate::ops::GtsOps; /// Register a base→leaf chain of GTS type schemas and run OP#13 trait validation @@ -66,7 +67,7 @@ pub fn validate_all(schemas: &[&Value]) -> Result<(), String> { let Some(id) = schema.get("$id").and_then(Value::as_str) else { return Err("schema is missing a string `$id`".to_owned()); }; - let gts_id = id.strip_prefix("gts://").unwrap_or(id); + let gts_id = id.strip_prefix(GTS_ID_URI_PREFIX).unwrap_or(id); let result = ops.validate_schema(gts_id); if !result.ok { return Err(format!("{gts_id}: {}", result.error)); diff --git a/gts/src/x_gts_ref.rs b/gts/src/x_gts_ref.rs index 0481ef5..0bc3cd4 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, GtsIdPattern}; +use crate::gts::{GTS_ID_PREFIX, GTS_ID_URI_PREFIX, GtsId, GtsIdPattern}; /// Error type for x-gts-ref validation failures #[derive(Debug, Clone)] @@ -361,7 +361,7 @@ impl XGtsRefValidator { let resolved_pattern = if ref_pattern.starts_with('/') { match Self::resolve_pointer(schema, ref_pattern) { Some(resolved) => { - if !resolved.starts_with("gts.") { + if !resolved.starts_with(GTS_ID_PREFIX) { return Some(XGtsRefValidationError::new( field_path.to_owned(), value.to_owned(), @@ -401,7 +401,7 @@ impl XGtsRefValidator { // concrete GTS identifier or a trailing-`*` wildcard pattern; both forms // are validated by the canonical pattern parser, which rejects malformed // patterns such as `gts.x.*.events.*` (mid-string / multiple wildcards). - if ref_pattern.starts_with("gts.") { + if ref_pattern.starts_with(GTS_ID_PREFIX) { return GtsIdPattern::try_new(ref_pattern).err().map(|e| { XGtsRefValidationError::new( field_path.to_owned(), @@ -446,7 +446,9 @@ impl XGtsRefValidator { field_path.to_owned(), ref_pattern.to_owned(), ref_pattern.to_owned(), - format!("Invalid x-gts-ref value: '{ref_pattern}' must start with 'gts.' or '/'"), + format!( + "Invalid x-gts-ref value: '{ref_pattern}' must start with '{GTS_ID_PREFIX}' or '/'" + ), )) } } @@ -550,7 +552,10 @@ impl XGtsRefValidator { /// contains a full GTS URI (e.g., `gts://gts.x.example._.user.v1~`) but the /// instance value should match without the prefix (e.g., `gts.x.example._.user.v1~`). fn strip_gts_uri_prefix(value: &str) -> String { - value.strip_prefix("gts://").unwrap_or(value).to_owned() + value + .strip_prefix(GTS_ID_URI_PREFIX) + .unwrap_or(value) + .to_owned() } } From 02a675235b77fdff474c1e4f46ebf00f9cec8b5a Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Mon, 29 Jun 2026 21:54:24 +0300 Subject: [PATCH 2/2] feat: add Dylint for hard-coded GTS prefixes - Add a gts-dylint crate that warns on hard-coded "gts." string literals. - Wire the lint into workspace metadata, CI, release checks, and make check. - Document lint usage and add targeted suppressions for intentional prefix constants. Signed-off-by: Aviator 5 --- .github/workflows/ci.yml | 34 + .github/workflows/release.yml | 1 + CLAUDE.md | 5 +- Cargo.toml | 6 + Makefile | 22 +- README.md | 22 + gts-dylint/.cargo/config.toml | 11 + gts-dylint/Cargo.lock | 2178 +++++++++++++++++ gts-dylint/Cargo.toml | 22 + gts-dylint/README.md | 174 ++ gts-dylint/examples/no_trigger_gts_id.rs | 6 + .../examples/no_trigger_gts_instance.rs | 26 + .../examples/no_trigger_gts_instance_raw.rs | 9 + gts-dylint/examples/no_trigger_non_gts.rs | 5 + .../no_trigger_struct_to_gts_schema.rs | 26 + gts-dylint/examples/no_trigger_with_allow.rs | 7 + gts-dylint/examples/trigger_concat.rs | 6 + gts-dylint/examples/trigger_concat.stderr | 16 + gts-dylint/examples/trigger_gts_instance.rs | 26 + .../examples/trigger_gts_instance.stderr | 10 + .../examples/trigger_gts_instance_raw.rs | 9 + .../examples/trigger_gts_instance_raw.stderr | 10 + gts-dylint/examples/trigger_gts_prefix.rs | 4 + gts-dylint/examples/trigger_gts_prefix.stderr | 10 + .../examples/trigger_struct_to_gts_schema.rs | 25 + .../trigger_struct_to_gts_schema.stderr | 10 + gts-dylint/rust-toolchain.toml | 3 + gts-dylint/src/lib.rs | 229 ++ gts-id/src/prefix.rs | 1 + gts-macros-cli/src/main.rs | 16 +- gts-macros/src/id_arg.rs | 8 +- gts-macros/src/instance.rs | 16 +- gts-macros/src/lib.rs | 50 +- .../compile_fail/gts_id_invalid_pattern.rs | 7 + .../gts_id_invalid_pattern.stderr | 5 + .../compile_fail/gts_id_missing_prefix.rs | 8 + .../compile_fail/gts_id_missing_prefix.stderr | 5 + .../tests/deprecated_schema_id_alias.rs | 8 +- .../tests/golden/traits_generic_child.rs | 8 +- .../tests/golden/traits_inline_chain.rs | 8 +- .../tests/golden/traits_struct_literal.rs | 8 +- gts-macros/tests/inheritance_tests.rs | 155 +- gts-macros/tests/inheritance_tests_mixed.rs | 24 +- gts-macros/tests/integration_tests.rs | 63 +- gts-macros/tests/serde_rename_tests.rs | 44 +- gts-macros/tests/traits_tests.rs | 46 +- gts-macros/tests/value_dispatch_tests.rs | 66 +- gts/src/ops.rs | 1 + 48 files changed, 3242 insertions(+), 217 deletions(-) create mode 100644 gts-dylint/.cargo/config.toml create mode 100644 gts-dylint/Cargo.lock create mode 100644 gts-dylint/Cargo.toml create mode 100644 gts-dylint/README.md create mode 100644 gts-dylint/examples/no_trigger_gts_id.rs create mode 100644 gts-dylint/examples/no_trigger_gts_instance.rs create mode 100644 gts-dylint/examples/no_trigger_gts_instance_raw.rs create mode 100644 gts-dylint/examples/no_trigger_non_gts.rs create mode 100644 gts-dylint/examples/no_trigger_struct_to_gts_schema.rs create mode 100644 gts-dylint/examples/no_trigger_with_allow.rs create mode 100644 gts-dylint/examples/trigger_concat.rs create mode 100644 gts-dylint/examples/trigger_concat.stderr create mode 100644 gts-dylint/examples/trigger_gts_instance.rs create mode 100644 gts-dylint/examples/trigger_gts_instance.stderr create mode 100644 gts-dylint/examples/trigger_gts_instance_raw.rs create mode 100644 gts-dylint/examples/trigger_gts_instance_raw.stderr create mode 100644 gts-dylint/examples/trigger_gts_prefix.rs create mode 100644 gts-dylint/examples/trigger_gts_prefix.stderr create mode 100644 gts-dylint/examples/trigger_struct_to_gts_schema.rs create mode 100644 gts-dylint/examples/trigger_struct_to_gts_schema.stderr create mode 100644 gts-dylint/rust-toolchain.toml create mode 100644 gts-dylint/src/lib.rs create mode 100644 gts-macros/tests/compile_fail/gts_id_invalid_pattern.rs create mode 100644 gts-macros/tests/compile_fail/gts_id_invalid_pattern.stderr create mode 100644 gts-macros/tests/compile_fail/gts_id_missing_prefix.rs create mode 100644 gts-macros/tests/compile_fail/gts_id_missing_prefix.stderr diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a676fee..442ce8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -233,3 +233,37 @@ jobs: # host.docker.internal:$PORT. See Makefile. - name: Run gts-spec tests via docker run: make gts-spec-tests + + dylint: + name: Dylint + Prefix Tests (nightly, Ubuntu) + runs-on: ubuntu-latest + env: + RUSTUP_TOOLCHAIN: nightly-2026-04-16 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Install pinned nightly toolchain + uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly + with: + toolchain: ${{ env.RUSTUP_TOOLCHAIN }} + components: llvm-tools-preview,rustc-dev + + - name: Cache Cargo/target + uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8 + with: + shared-key: dylint + + - name: Install cargo-dylint and dylint-link from source + run: cargo install --locked cargo-dylint dylint-link + + - name: Run dylint + run: cargo +${{ env.RUSTUP_TOOLCHAIN }} dylint --all + + - name: Run dylint UI tests + run: cargo +${{ env.RUSTUP_TOOLCHAIN }} test --manifest-path gts-dylint/Cargo.toml + + - name: Run prefix-aware tests + run: make test-gts-id-prefix diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c84d420..4b513b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,6 +80,7 @@ jobs: check_version "gts-cli/Cargo.toml" check_version "gts-macros-cli/Cargo.toml" check_version "gts-validator/Cargo.toml" + check_version "gts-dylint/Cargo.toml" # Verify workspace.dependencies versions for all known internal crates. # Uses explicit key list to avoid false negatives from pattern drift. diff --git a/CLAUDE.md b/CLAUDE.md index 4dab0af..825ae1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Guidance for AI Agents when working in this repository. ## Project Overview -`gts-rust` is the Rust reference implementation of [GTS](https://github.com/GlobalTypeSystem/gts-spec) — library (`gts/`), CLI and HTTP server (`gts-cli/`, binary name `gts`), plus supporting crates (`gts-id`, `gts-macros`, `gts-macros-cli`, `gts-validator`). The server answers the REST API exercised by the shared gts-spec conformance suite. +`gts-rust` is the Rust reference implementation of [GTS](https://github.com/GlobalTypeSystem/gts-spec) — library (`gts/`), CLI and HTTP server (`gts-cli/`, binary name `gts`), plus supporting crates (`gts-id`, `gts-macros`, `gts-macros-cli`, `gts-validator`, `gts-dylint`). The server answers the REST API exercised by the shared gts-spec conformance suite. The conformance suite is shipped as a Docker image — `ghcr.io/globaltypesystem/gts-spec-tests` — and the spec version this implementation targets is pinned in `.gts-spec-version` (the file's contents are used verbatim as the image tag, format `vMAJOR.MINOR.PATCH`). The pin is immutable on purpose: every commit reproduces the same test run, and rolling forward requires a deliberate bump. @@ -51,4 +51,5 @@ The server holds state in memory with no reset endpoint — restart it between f - `.gts-spec-version` is the canonical pin (`vMAJOR.MINOR.PATCH`). Bump it (commit + push) to roll the spec forward — both CI and `make gts-spec-tests` pick it up. Local cache survives across runs; `docker rmi $(GTS_SPEC_REF)` if you ever need to force a refetch. - Handlers in `gts-cli/src/server.rs` stay thin — logic goes in `gts/` where it is unit-testable. New REST behavior usually already has coverage in the gts-spec suite; run the relevant file (`make gts-spec-tests TEST=...`) before and after to confirm. -- `make check` is the full local gate: fmt + clippy + test + gts-spec-tests. +- `make check` is the full local gate: fmt + clippy + test + test-gts-id-prefix + dylint + gts-spec-tests. +- `gts-dylint` is a Dylint lint (requires nightly) that flags hard-coded `"gts."` string literals in production code. Run it with `make dylint`. Use `#[allow(unknown_lints, gts_id_hardcoded_prefix)]` to suppress in specific locations. Prefixes can be customized via `GTS_DYLINT_PREFIXES` env var. Tests for the lint itself live in `gts-dylint/ui/` and run with `cargo +nightly test` inside the crate. diff --git a/Cargo.toml b/Cargo.toml index 351a404..4a43beb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,8 +17,14 @@ members = [ "gts-macros-cli", "gts-validator", ] +exclude = ["gts-dylint"] resolver = "2" +[workspace.metadata.dylint] +libraries = [ + { path = "gts-dylint" }, +] + [workspace.lints.rust] deprecated = "deny" non_ascii_idents = "forbid" diff --git a/Makefile b/Makefile index 678e665..0ed1460 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ CI := 1 # Default target - show help .DEFAULT_GOAL := help -.PHONY: help build dev-fmt dev-clippy all check fmt clippy test deny security generate-schemas coverage +.PHONY: help build dev-fmt dev-clippy all check fmt clippy test test-gts-id-prefix dylint dylint-tests deny security generate-schemas coverage # Show this help message help: @@ -41,6 +41,24 @@ clippy: test: cargo test --workspace +# Re-run gts-id unit tests with a non-default GTS_ID_PREFIX to catch +# hard-coded "gts." literals that should use the GTS_ID_PREFIX constant. +# The prefix is read at compile time (option_env!), so this is a clean +# rebuild + test cycle. Currently scoped to gts-id (whose tests are +# prefix-aware); expand to more crates as their test data is cleaned up. +test-gts-id-prefix: + GTS_ID_PREFIX=acme. cargo test -p gts-id + +# Run dylint lints (requires nightly toolchain + cargo-dylint) +# Detects hard-coded "gts." / "gts://" string literals in production code +dylint: + @command -v cargo-dylint >/dev/null || (echo "Installing cargo-dylint..." && cargo install cargo-dylint) + cargo +nightly-2026-04-16 dylint --all + +# Run dylint UI/example tests (requires nightly toolchain) +dylint-tests: + cargo +nightly-2026-04-16 test --manifest-path gts-dylint/Cargo.toml + # Check licenses and dependencies deny: @command -v cargo-deny >/dev/null || (echo "Installing cargo-deny..." && cargo install cargo-deny) @@ -56,7 +74,7 @@ coverage: cargo llvm-cov report # Run all quality checks -check: fmt clippy test gts-spec-tests +check: fmt clippy test test-gts-id-prefix dylint dylint-tests gts-spec-tests # ============================================================================== diff --git a/README.md b/README.md index 04b6d9d..efaf2cf 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,10 @@ Command-line tool and HTTP server: - **server.rs** - Axum-based HTTP server - **main.rs** - Entry point +### `gts-dylint` (Dylint Library Crate) + +A [Dylint](https://github.com/trailofbits/dylint) lint that flags hard-coded GTS ID prefixes in production code, encouraging use of the configurable `GTS_ID_PREFIX` constant or the `gts_id!` macro instead. Prefixes can be customized via `GTS_DYLINT_PREFIXES`, the active `GTS_ID_PREFIX` is included automatically, and trusted wrapper macros can be registered with `GTS_DYLINT_ALLOWED_MACROS`. Requires nightly Rust. See [`gts-dylint/README.md`](gts-dylint/README.md) for details. + ## Installation ### From Source @@ -1033,6 +1037,24 @@ cargo fmt cargo clippy ``` +### Dylint (custom lints) + +Run the `gts-dylint` lint to detect hard-coded GTS prefixes (requires nightly): + +```bash +make dylint +``` + +See [`gts-dylint/README.md`](gts-dylint/README.md) for setup and usage details. + +### Prefix-aware tests + +Re-run `gts-id` tests with a non-default `GTS_ID_PREFIX` to catch hard-coded prefixes: + +```bash +make test-gts-id-prefix +``` + ## License Apache-2.0 diff --git a/gts-dylint/.cargo/config.toml b/gts-dylint/.cargo/config.toml new file mode 100644 index 0000000..12df6c2 --- /dev/null +++ b/gts-dylint/.cargo/config.toml @@ -0,0 +1,11 @@ +[target.aarch64-apple-darwin] +linker = "dylint-link" + +[target.x86_64-apple-darwin] +linker = "dylint-link" + +[target.x86_64-unknown-linux-gnu] +linker = "dylint-link" + +[target.aarch64-unknown-linux-gnu] +linker = "dylint-link" diff --git a/gts-dylint/Cargo.lock b/gts-dylint/Cargo.lock new file mode 100644 index 0000000..d8bdf84 --- /dev/null +++ b/gts-dylint/Cargo.lock @@ -0,0 +1,2178 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compiletest_rs" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f150fe9105fcd2a57cad53f0c079a24de65195903ef670990f5909f695eac04c" +dependencies = [ + "diff", + "filetime", + "getopts", + "lazy_static", + "libc", + "log", + "miow", + "regex", + "rustfix", + "serde", + "serde_derive", + "serde_json", + "tester", + "windows-sys 0.59.0", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "defmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dylint" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c738fb72ea7d248df2995a31b3698beb63d0f1f9ca5a5dc0188c4b64cd0e86e4" +dependencies = [ + "anstyle", + "anyhow", + "cargo_metadata", + "dylint_internal", + "log", + "once_cell", + "semver", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "dylint_internal" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9796d3441d7894cbaf4992640799efffc1661978f0cc1266ec788c32254fdfb3" +dependencies = [ + "anstyle", + "anyhow", + "bitflags 2.13.0", + "cargo_metadata", + "git2", + "home", + "log", + "regex", + "serde", + "tar", + "thiserror 2.0.18", + "toml", +] + +[[package]] +name = "dylint_linting" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c213635b3eaa84f43b9b497377f17d9a8d4feb413bab1d82e03ad1c73e4f6d8c" +dependencies = [ + "cargo_metadata", + "dylint_internal", + "paste", + "rustversion", + "serde", + "thiserror 2.0.18", + "toml", +] + +[[package]] +name = "dylint_testing" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f5f50ee06be9ebfb5b9538ba0d7bb08adc33e8f31c901e7d398de2a9b1ae290" +dependencies = [ + "anyhow", + "cargo_metadata", + "compiletest_rs", + "dylint", + "dylint_internal", + "env_logger", + "once_cell", + "regex", + "serde_json", + "tempfile", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fancy-regex" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags 2.13.0", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + +[[package]] +name = "gts" +version = "0.11.0" +dependencies = [ + "gts-id", + "jsonschema", + "schemars", + "serde", + "serde-saphyr", + "serde_json", + "shellexpand", + "thiserror 2.0.18", + "tracing", + "walkdir", +] + +[[package]] +name = "gts-dylint" +version = "0.11.0" +dependencies = [ + "dylint_linting", + "dylint_testing", + "gts", + "gts-id", + "gts-macros", + "schemars", + "serde", + "serde_json", +] + +[[package]] +name = "gts-id" +version = "0.11.0" +dependencies = [ + "thiserror 2.0.18", + "uuid", +] + +[[package]] +name = "gts-macros" +version = "0.11.0" +dependencies = [ + "gts-id", + "proc-macro2", + "quote", + "serde_json", + "syn", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46" +dependencies = [ + "defmt", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba783d17473c27cfd4d1d72785dc1c26d5faba8072f50fec4ebea179bec8f33d" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libgit2-sys" +version = "0.18.5+1.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "miow" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "referencing" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef39a30a317e883d1ef4c43aa849f90f480d90bb24904fd38266e61d6be58f2" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustfix" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82fa69b198d894d84e23afde8e9ab2af4400b2cba20d6bf2b428a8b01c222c5a" +dependencies = [ + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "saphyr-parser-bw" +version = "0.0.611" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dec0c833db75dc98957956b303fe447ffc5eb13f2325ef4c2350f7f3aa69e3" +dependencies = [ + "arraydeque", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-saphyr" +version = "0.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83ad47c2f14654528a89495f8d0dbc64173176f8512c7c72386cbe81009f661" +dependencies = [ + "ahash", + "annotate-snippets", + "base64", + "encoding_rs_io", + "getrandom 0.3.4", + "nohash-hasher", + "num-traits", + "saphyr-parser-bw", + "serde", + "smallvec", + "zmij", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + +[[package]] +name = "tester" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e8bf7e0eb2dd7b4228cc1b6821fc5114cd6841ae59f652a85488c016091e5f" +dependencies = [ + "cfg-if", + "getopts", + "libc", + "num_cpus", + "term", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/gts-dylint/Cargo.toml b/gts-dylint/Cargo.toml new file mode 100644 index 0000000..50365a3 --- /dev/null +++ b/gts-dylint/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "gts-dylint" +version = "0.11.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +dylint_linting = "6.0" + +[dev-dependencies] +dylint_testing = "6.0" +gts-macros = { path = "../gts-macros" } +gts = { path = "../gts" } +gts-id = { path = "../gts-id" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +schemars = "1" + +[workspace] diff --git a/gts-dylint/README.md b/gts-dylint/README.md new file mode 100644 index 0000000..2a99797 --- /dev/null +++ b/gts-dylint/README.md @@ -0,0 +1,174 @@ +# gts-dylint + +A [Dylint](https://github.com/trailofbits/dylint) lint that flags hard-coded GTS identifier prefixes in string literals. + +## What it does + +String literals starting with a configured prefix (default: `"gts."`) are flagged. These should use the `GTS_ID_PREFIX` constant or the `gts_id!` macro instead, so that the prefix remains overridable at **compile time** via the `GTS_ID_PREFIX` environment variable. + +The set of flagged prefixes can be customized via the `GTS_DYLINT_PREFIXES` environment variable (comma-separated, e.g. `GTS_DYLINT_PREFIXES="gts.,acme."`). Defaults to `gts.`. If `GTS_ID_PREFIX` is set, that active prefix is also flagged automatically. + +This lint intentionally matches by prefix, not by fully validating each string as a GTS ID. That means non-ID strings such as `"gts.config.json"` are also flagged if they start with a configured prefix. Treat those cases as either naming collisions to avoid or suppress them locally with `#[allow(unknown_lints, gts_id_hardcoded_prefix)]`. + +Expansions from the built-in GTS macros (`gts_id`, `struct_to_gts_schema`, `gts_instance`, `gts_instance_raw`) are allowed because those macros apply the configured prefix deliberately. Project-specific wrapper macros can be added via `GTS_DYLINT_ALLOWED_MACROS` (comma-separated, e.g. `GTS_DYLINT_ALLOWED_MACROS="my_gts_id,my_schema"`). + +Only add trusted wrappers to `GTS_DYLINT_ALLOWED_MACROS`: allowing a macro name suppresses every prefixed string literal produced anywhere in that macro's expansion. Wrapper macros should delegate to `gts_id!` or the official GTS macros instead of emitting full hard-coded IDs themselves. + +The default lint level is **deny** (compilation error). To downgrade to a warning, use `#![warn(gts_id_hardcoded_prefix)]` at the crate level or `--cap-lints warn` on the command line. + +### Suggested replacements + +| Pattern | Replacement | +|---------|-------------| +| `"gts.x.core.events.topic.v1~"` | `GTS_ID_PREFIX` compile-time constant from the `gts-id` crate | +| Constructing GTS IDs at compile time | `gts_id!` macro from the `gts-macros` crate | + +### Suppressing + +Use `#[allow(gts_id_hardcoded_prefix)]` on specific items or `#![allow(gts_id_hardcoded_prefix)]` at the crate level. Since the lint is only known when dylint is loaded, pair it with `#[allow(unknown_lints)]` to avoid "unknown lint" warnings during normal `cargo check`: + +```rust +#[allow(unknown_lints, gts_id_hardcoded_prefix)] +pub const DEFAULT_GTS_ID_PREFIX: &str = "gts."; +``` + +For test code, add at the crate root: + +```rust +#![cfg_attr(test, allow(unknown_lints, gts_id_hardcoded_prefix))] +``` + +## Examples + +### `gts_id!` as a standalone expression + +Expands to a `&'static str` literal with the configured prefix prepended at compile time: + +```rust +use gts_macros::gts_id; + +// With the default prefix "gts.": +let id: &str = gts_id!("x.core.events.topic.v1~"); +assert_eq!(id, "gts.x.core.events.topic.v1~"); +``` + +### `gts_id!` inside `gts_instance!` + +The `gts_id!("...")` marker is recognized inside `gts_instance!` — write the suffix without the prefix: + +```rust +use gts_macros::{gts_id, gts_instance}; + +let t: TopicV1 = gts_instance!(TopicV1 { + id: gts_id!("x.core.events.topic.v1~vendor.app.orders.created.v1"), + name: "orders".to_owned(), + retention: "P30D".to_owned(), +}); +``` + +### `gts_id!` inside `#[struct_to_gts_schema]` + +The same marker works in the `type_id` argument of `#[struct_to_gts_schema]`: + +```rust +use gts_macros::{struct_to_gts_schema, gts_id}; + +#[struct_to_gts_schema( + dir_path = "schemas", + base = true, + type_id = gts_id!("x.core.events.topic.v1~"), + description = "Topic type", + properties = "id,name" +)] +pub struct TopicV1 { + pub id: gts::GtsInstanceId, + pub name: String, +} +``` + +### `gts_id!` inside `gts_instance_raw!` + +For JSON-shaped instances without a Rust struct: + +```rust +use gts_macros::{gts_id, gts_instance_raw}; + +let v: serde_json::Value = gts_instance_raw!({ + "id": gts_id!("x.core.events.topic.v1~vendor.app.events.audit.v1"), + "name": "audit", +}); +``` + +## Requirements + +- **Nightly Rust** with `rustc-dev` and `llvm-tools-preview` components: + ```bash + rustup toolchain install nightly + rustup component add rustc-dev llvm-tools-preview --toolchain nightly + ``` + +- **cargo-dylint** and **dylint-link**: + ```bash + cargo install cargo-dylint dylint-link + ``` + +## Usage + +### In an external project + +Add to your workspace `Cargo.toml`: + +```toml +[workspace.metadata.dylint] +libraries = [ + { git = "https://github.com/GlobalTypeSystem/gts-rust", tag = "v0.11.0", pattern = "gts-dylint" }, +] +``` + +Run the lint: + +```bash +cargo +nightly dylint --all +``` + +With a custom prefix and wrapper macros: + +```bash +GTS_ID_PREFIX=acme. \ +GTS_DYLINT_ALLOWED_MACROS=my_gts_id,my_schema \ +cargo +nightly dylint --all +``` + +Run the lint with the same `GTS_ID_PREFIX` value used for build and test. The prefix is read from the Dylint process environment; if your project builds with `GTS_ID_PREFIX=acme.` but runs Dylint without that variable, hard-coded `"acme...."` literals will not be flagged automatically. + +`GTS_ID_PREFIX=acme.` is enough for the lint to flag both `"gts...."` and `"acme...."` literals during that run. Set `GTS_DYLINT_PREFIXES` only when you want to scan additional legacy prefixes: + +```bash +GTS_DYLINT_PREFIXES=gts.,legacy.,acme. cargo +nightly dylint --all +``` + +To also lint test code, examples, and benchmarks: + +```bash +cargo +nightly dylint --all -- --all-targets +``` + +> `gts-dylint` is **not published to crates.io**. Dylint loads it as a `cdylib` via the rustc wrapper, not as a regular crate dependency, so git is the correct distribution method. + +### In this repository + +```bash +make dylint +``` + +## Testing + +UI tests use [`dylint_testing`](https://docs.rs/dylint_testing): + +```bash +cd gts-dylint && cargo +nightly test +``` + +## License + +Same as the gts-rust project. diff --git a/gts-dylint/examples/no_trigger_gts_id.rs b/gts-dylint/examples/no_trigger_gts_id.rs new file mode 100644 index 0000000..c92aa36 --- /dev/null +++ b/gts-dylint/examples/no_trigger_gts_id.rs @@ -0,0 +1,6 @@ +// Should NOT trigger: gts_id! macro applies the configured prefix +use gts_macros::gts_id; + +fn main() { + let _id = gts_id!("x.core.events.topic.v1~"); +} diff --git a/gts-dylint/examples/no_trigger_gts_instance.rs b/gts-dylint/examples/no_trigger_gts_instance.rs new file mode 100644 index 0000000..15ba46b --- /dev/null +++ b/gts-dylint/examples/no_trigger_gts_instance.rs @@ -0,0 +1,26 @@ +// Should NOT trigger: gts_instance! with gts_id! for id field +#![allow(unused_imports)] +use gts::gts::GtsInstanceId; +use gts_macros::{gts_id, gts_instance, struct_to_gts_schema}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[struct_to_gts_schema( + dir_path = "schemas", + base = true, + type_id = gts_id!("x.core.events.type.v1~"), + description = "Test base type", + properties = "id", +)] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct TestBaseV1

{ + pub id: GtsInstanceId, + pub payload: P, +} + +fn main() { + let _ = gts_instance!(TestBaseV1::<()> { + id: gts_id!("x.core.events.type.v1~demo.app.events.test.v1"), + payload: (), + }); +} diff --git a/gts-dylint/examples/no_trigger_gts_instance_raw.rs b/gts-dylint/examples/no_trigger_gts_instance_raw.rs new file mode 100644 index 0000000..d04bf6f --- /dev/null +++ b/gts-dylint/examples/no_trigger_gts_instance_raw.rs @@ -0,0 +1,9 @@ +// Should NOT trigger: gts_instance_raw! with gts_id! for id field +use gts_macros::{gts_id, gts_instance_raw}; + +fn main() { + let _ = gts_instance_raw!({ + "id": gts_id!("x.core.events.type.v1~demo.app.events.test.v1"), + "name": "test" + }); +} diff --git a/gts-dylint/examples/no_trigger_non_gts.rs b/gts-dylint/examples/no_trigger_non_gts.rs new file mode 100644 index 0000000..4d82f6e --- /dev/null +++ b/gts-dylint/examples/no_trigger_non_gts.rs @@ -0,0 +1,5 @@ +// Should NOT trigger: string does not start with "gts." or "gts://" +fn main() { + let _s = "not a gts id"; + let _other = "gts_config.json"; +} diff --git a/gts-dylint/examples/no_trigger_struct_to_gts_schema.rs b/gts-dylint/examples/no_trigger_struct_to_gts_schema.rs new file mode 100644 index 0000000..8f977be --- /dev/null +++ b/gts-dylint/examples/no_trigger_struct_to_gts_schema.rs @@ -0,0 +1,26 @@ +// Should NOT trigger: struct_to_gts_schema with gts_id! for type_id +#![allow(unused_imports)] +use gts::gts::GtsTypeId; +use gts::GtsSchema; +use gts_macros::{gts_id, struct_to_gts_schema}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[struct_to_gts_schema( + dir_path = "schemas", + base = true, + type_id = gts_id!("x.core.events.type.v1~"), + description = "Test base type", + properties = "event_type,id", +)] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct TestBaseV1

{ + #[serde(rename = "type")] + pub event_type: GtsTypeId, + pub id: String, + pub payload: P, +} + +fn main() { + let _ = as GtsSchema>::TYPE_ID; +} diff --git a/gts-dylint/examples/no_trigger_with_allow.rs b/gts-dylint/examples/no_trigger_with_allow.rs new file mode 100644 index 0000000..2145a2b --- /dev/null +++ b/gts-dylint/examples/no_trigger_with_allow.rs @@ -0,0 +1,7 @@ +// The allow attribute suppresses the lint on specific items. +#[allow(unknown_lints, gts_id_hardcoded_prefix)] +pub const MY_PREFIX: &str = "gts."; + +fn main() { + let _ = MY_PREFIX; +} diff --git a/gts-dylint/examples/trigger_concat.rs b/gts-dylint/examples/trigger_concat.rs new file mode 100644 index 0000000..42e174d --- /dev/null +++ b/gts-dylint/examples/trigger_concat.rs @@ -0,0 +1,6 @@ +// Should trigger: concat! producing a hardcoded "gts." prefix +fn main() { + const GTS_PREFIX: &str = concat!("gts.", ""); + let _ = GTS_PREFIX; + let _x = concat!("gts.", "x"); +} diff --git a/gts-dylint/examples/trigger_concat.stderr b/gts-dylint/examples/trigger_concat.stderr new file mode 100644 index 0000000..ae7dc96 --- /dev/null +++ b/gts-dylint/examples/trigger_concat.stderr @@ -0,0 +1,16 @@ +error: hard-coded GTS ID prefix in string literal - use GTS_ID_PREFIX constant or the gts_id! macro instead + --> $DIR/trigger_concat.rs:3:30 + | +LL | const GTS_PREFIX: &str = concat!("gts.", ""); + | ^^^^^^^^^^^^^^^^^^^ + | + = note: `#[deny(gts_id_hardcoded_prefix)]` on by default + +error: hard-coded GTS ID prefix in string literal - use GTS_ID_PREFIX constant or the gts_id! macro instead + --> $DIR/trigger_concat.rs:5:14 + | +LL | let _x = concat!("gts.", "x"); + | ^^^^^^^^^^^^^^^^^^^^ + +error: aborting due to 2 previous errors + diff --git a/gts-dylint/examples/trigger_gts_instance.rs b/gts-dylint/examples/trigger_gts_instance.rs new file mode 100644 index 0000000..52d6160 --- /dev/null +++ b/gts-dylint/examples/trigger_gts_instance.rs @@ -0,0 +1,26 @@ +// Should trigger: hardcoded "gts." prefix inside gts_instance! id field +#![allow(unused_imports)] +use gts::gts::GtsInstanceId; +use gts_macros::{gts_instance, struct_to_gts_schema}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[struct_to_gts_schema( + dir_path = "schemas", + base = true, + type_id = gts_id!("x.core.events.type.v1~"), + description = "Test base type", + properties = "id", +)] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct TestBaseV1

{ + pub id: GtsInstanceId, + pub payload: P, +} + +fn main() { + let _ = gts_instance!(TestBaseV1::<()> { + id: "gts.x.core.events.type.v1~demo.app.events.test.v1", + payload: (), + }); +} diff --git a/gts-dylint/examples/trigger_gts_instance.stderr b/gts-dylint/examples/trigger_gts_instance.stderr new file mode 100644 index 0000000..381d565 --- /dev/null +++ b/gts-dylint/examples/trigger_gts_instance.stderr @@ -0,0 +1,10 @@ +error: hard-coded GTS ID prefix in string literal - use GTS_ID_PREFIX constant or the gts_id! macro instead + --> $DIR/trigger_gts_instance.rs:23:13 + | +LL | id: "gts.x.core.events.type.v1~demo.app.events.test.v1", + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: `#[deny(gts_id_hardcoded_prefix)]` on by default + +error: aborting due to 1 previous error + diff --git a/gts-dylint/examples/trigger_gts_instance_raw.rs b/gts-dylint/examples/trigger_gts_instance_raw.rs new file mode 100644 index 0000000..f2e2b62 --- /dev/null +++ b/gts-dylint/examples/trigger_gts_instance_raw.rs @@ -0,0 +1,9 @@ +// Should trigger: hardcoded "gts." prefix inside gts_instance_raw! id field +use gts_macros::gts_instance_raw; + +fn main() { + let _ = gts_instance_raw!({ + "id": "gts.x.core.events.type.v1~demo.app.events.test.v1", + "name": "test" + }); +} diff --git a/gts-dylint/examples/trigger_gts_instance_raw.stderr b/gts-dylint/examples/trigger_gts_instance_raw.stderr new file mode 100644 index 0000000..76bec48 --- /dev/null +++ b/gts-dylint/examples/trigger_gts_instance_raw.stderr @@ -0,0 +1,10 @@ +error: hard-coded GTS ID prefix in string literal - use GTS_ID_PREFIX constant or the gts_id! macro instead + --> $DIR/trigger_gts_instance_raw.rs:6:15 + | +LL | "id": "gts.x.core.events.type.v1~demo.app.events.test.v1", + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: `#[deny(gts_id_hardcoded_prefix)]` on by default + +error: aborting due to 1 previous error + diff --git a/gts-dylint/examples/trigger_gts_prefix.rs b/gts-dylint/examples/trigger_gts_prefix.rs new file mode 100644 index 0000000..4d4608b --- /dev/null +++ b/gts-dylint/examples/trigger_gts_prefix.rs @@ -0,0 +1,4 @@ +// Should trigger gts_id_hardcoded_prefix: hard-coded "gts." prefix +fn main() { + let _id = "gts.x.core.events.topic.v1~"; +} diff --git a/gts-dylint/examples/trigger_gts_prefix.stderr b/gts-dylint/examples/trigger_gts_prefix.stderr new file mode 100644 index 0000000..1bfedd8 --- /dev/null +++ b/gts-dylint/examples/trigger_gts_prefix.stderr @@ -0,0 +1,10 @@ +error: hard-coded GTS ID prefix in string literal - use GTS_ID_PREFIX constant or the gts_id! macro instead + --> $DIR/trigger_gts_prefix.rs:3:15 + | +LL | let _id = "gts.x.core.events.topic.v1~"; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: `#[deny(gts_id_hardcoded_prefix)]` on by default + +error: aborting due to 1 previous error + diff --git a/gts-dylint/examples/trigger_struct_to_gts_schema.rs b/gts-dylint/examples/trigger_struct_to_gts_schema.rs new file mode 100644 index 0000000..e302800 --- /dev/null +++ b/gts-dylint/examples/trigger_struct_to_gts_schema.rs @@ -0,0 +1,25 @@ +// Should trigger: hardcoded "gts." prefix inside struct_to_gts_schema type_id +use gts::gts::GtsTypeId; +use gts::GtsSchema; +use gts_macros::struct_to_gts_schema; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[struct_to_gts_schema( + dir_path = "schemas", + base = true, + type_id = "gts.x.core.events.type.v1~", + description = "Test base type", + properties = "event_type,id", +)] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct TestBaseV1

{ + #[serde(rename = "type")] + pub event_type: GtsTypeId, + pub id: String, + pub payload: P, +} + +fn main() { + let _ = as GtsSchema>::TYPE_ID; +} diff --git a/gts-dylint/examples/trigger_struct_to_gts_schema.stderr b/gts-dylint/examples/trigger_struct_to_gts_schema.stderr new file mode 100644 index 0000000..af2f9ed --- /dev/null +++ b/gts-dylint/examples/trigger_struct_to_gts_schema.stderr @@ -0,0 +1,10 @@ +error: hard-coded GTS ID prefix in string literal - use GTS_ID_PREFIX constant or the gts_id! macro instead + --> $DIR/trigger_struct_to_gts_schema.rs:11:15 + | +LL | type_id = "gts.x.core.events.type.v1~", + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: `#[deny(gts_id_hardcoded_prefix)]` on by default + +error: aborting due to 1 previous error + diff --git a/gts-dylint/rust-toolchain.toml b/gts-dylint/rust-toolchain.toml new file mode 100644 index 0000000..c3a67be --- /dev/null +++ b/gts-dylint/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "nightly-2026-04-16" +components = ["rustc-dev", "llvm-tools-preview"] diff --git a/gts-dylint/src/lib.rs b/gts-dylint/src/lib.rs new file mode 100644 index 0000000..1447ec3 --- /dev/null +++ b/gts-dylint/src/lib.rs @@ -0,0 +1,229 @@ +#![feature(rustc_private)] + +dylint_linting::dylint_library!(); + +extern crate rustc_ast; +extern crate rustc_errors; +extern crate rustc_hir; +extern crate rustc_lint; +extern crate rustc_session; +extern crate rustc_span; + +use rustc_ast::LitKind; +use rustc_hir::ExprKind; +use rustc_lint::{LateContext, LateLintPass, LintContext}; +use rustc_session::declare_lint; + +const LINT_MESSAGE: &str = "hard-coded GTS ID prefix in string literal - use GTS_ID_PREFIX constant or the gts_id! macro instead"; + +declare_lint! { + /// Lint that flags hard-coded GTS identifier prefixes in string literals. + /// + /// String literals starting with a configured prefix (default: `"gts."`) + /// should use the `GTS_ID_PREFIX` constant from the `gts-id` crate, so that + /// the prefix remains configurable via the `GTS_ID_PREFIX` environment variable. + /// + /// For constructing GTS IDs at compile time, use the `gts_id!` macro from + /// the `gts-macros` crate, which automatically applies the configured prefix. + /// + /// The set of flagged prefixes can be customized at lint-load time via the + /// `GTS_DYLINT_PREFIXES` environment variable (comma-separated, e.g. + /// `GTS_DYLINT_PREFIXES="gts.,acme."`). Defaults to `gts.` and also includes + /// the active `GTS_ID_PREFIX` value when that environment variable is set. + /// + /// Macros whose expansions may legitimately produce prefixed literals + /// (`gts_id`, `struct_to_gts_schema`, `gts_instance`, `gts_instance_raw`) + /// are always allowed. Additional wrapper macro names can be registered + /// via the `GTS_DYLINT_ALLOWED_MACROS` environment variable (comma-separated, + /// e.g. `GTS_DYLINT_ALLOWED_MACROS="my_gts_id,my_schema"`). + /// + /// The default level is `deny`. To override, use standard Rust lint + /// attributes: `#[warn(gts_id_hardcoded_prefix)]`, `#[allow(...)]`, or + /// `--cap-lints` on the command line. + /// + /// To suppress this lint in specific cases (e.g. constant definitions or + /// test data), use `#[allow(gts_id_hardcoded_prefix)]`. + pub GTS_ID_HARDCODED_PREFIX, + Deny, + LINT_MESSAGE +} + +rustc_session::declare_lint_pass!(GtsIdHardcodedPrefix => [GTS_ID_HARDCODED_PREFIX]); + +fn push_unique(values: &mut Vec, value: &str) { + let trimmed = value.trim(); + if !trimmed.is_empty() && !values.iter().any(|v| v == trimmed) { + values.push(trimmed.to_owned()); + } +} + +fn comma_separated_values(value: &str) -> impl Iterator { + value.split(',').map(str::trim).filter(|s| !s.is_empty()) +} + +fn configured_prefixes_from( + lint_prefixes: Option<&str>, + active_gts_id_prefix: Option<&str>, +) -> Vec { + let mut prefixes = Vec::new(); + match lint_prefixes { + Some(value) if !value.trim().is_empty() => { + for prefix in comma_separated_values(value) { + push_unique(&mut prefixes, prefix); + } + } + _ => prefixes.push("gts.".to_owned()), + } + if let Some(prefix) = active_gts_id_prefix { + push_unique(&mut prefixes, prefix); + } + prefixes +} + +/// Returns the list of prefixes to flag, read from `GTS_DYLINT_PREFIXES` and +/// `GTS_ID_PREFIX`. Defaults to `["gts."]`. +fn configured_prefixes() -> Vec { + let lint_prefixes = std::env::var("GTS_DYLINT_PREFIXES").ok(); + let active_prefix = std::env::var("GTS_ID_PREFIX").ok(); + configured_prefixes_from(lint_prefixes.as_deref(), active_prefix.as_deref()) +} + +/// Returns the list of macro names whose expansions are allowed to produce +/// prefixed string literals without triggering the lint. Built-in GTS macros +/// are always included; additional names (e.g. project-specific wrapper +/// macros) can be added via the `GTS_DYLINT_ALLOWED_MACROS` environment +/// variable (comma-separated, e.g. `GTS_DYLINT_ALLOWED_MACROS="my_gts_id,my_schema"`). +fn configured_allowed_macros_from(extra_names: Option<&str>) -> Vec { + let mut names: Vec = vec![ + "gts_id".into(), + "struct_to_gts_schema".into(), + "gts_instance".into(), + "gts_instance_raw".into(), + ]; + if let Some(value) = extra_names { + for name in comma_separated_values(value) { + push_unique(&mut names, name); + } + } + names +} + +fn configured_allowed_macros() -> Vec { + let extra_names = std::env::var("GTS_DYLINT_ALLOWED_MACROS").ok(); + configured_allowed_macros_from(extra_names.as_deref()) +} + +static PREFIXES: std::sync::OnceLock> = std::sync::OnceLock::new(); +static ALLOWED_MACROS: std::sync::OnceLock> = std::sync::OnceLock::new(); + +fn get_prefixes() -> &'static [String] { + PREFIXES.get_or_init(configured_prefixes) +} + +fn get_allowed_macros() -> &'static [String] { + ALLOWED_MACROS.get_or_init(configured_allowed_macros) +} + +#[unsafe(no_mangle)] +pub fn register_lints(_sess: &rustc_session::Session, lint_store: &mut rustc_lint::LintStore) { + lint_store.register_lints(&[GTS_ID_HARDCODED_PREFIX]); + lint_store.register_late_pass(|_| Box::new(GtsIdHardcodedPrefix)); +} + +impl<'tcx> LateLintPass<'tcx> for GtsIdHardcodedPrefix { + fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx rustc_hir::Expr<'tcx>) { + if let ExprKind::Lit(lit) = &expr.kind + && let LitKind::Str(symbol, _) = lit.node + { + let s = symbol.as_str(); + if !get_prefixes().iter().any(|p| s.starts_with(p.as_str())) { + return; + } + + // Skip string literals produced by the `gts_id!` macro, which + // legitimately applies the configured prefix at compile time. + // Other macros (e.g. `concat!`) that produce prefixed strings are + // still flagged: they bypass the configurable prefix mechanism. + if expr.span.from_expansion() { + let allowed = get_allowed_macros(); + let is_from_allowed_macro = expr.span.macro_backtrace().any(|expn| { + if let rustc_span::ExpnKind::Macro(_, name) = expn.kind { + allowed.iter().any(|a| name.as_str() == a.as_str()) + } else { + false + } + }); + if is_from_allowed_macro { + return; + } + // For non-gts_id macros (e.g. concat!), emit the lint at the + // call-site span so the lint level is resolved there, not at + // the macro definition site (which may suppress it). + let span = expr.span.source_callsite(); + cx.opt_span_lint( + GTS_ID_HARDCODED_PREFIX, + Some(span), + rustc_errors::DiagDecorator(|diag| { + diag.primary_message(LINT_MESSAGE); + }), + ); + return; + } + + let span = expr.span; + cx.opt_span_lint( + GTS_ID_HARDCODED_PREFIX, + Some(span), + rustc_errors::DiagDecorator(|diag| { + diag.primary_message(LINT_MESSAGE); + }), + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::{configured_allowed_macros_from, configured_prefixes_from}; + + #[test] + fn examples() { + dylint_testing::ui_test_examples(env!("CARGO_PKG_NAME")); + } + + #[test] + fn prefixes_default_to_gts_and_include_active_gts_id_prefix() { + assert_eq!(configured_prefixes_from(None, None), ["gts."]); + assert_eq!( + configured_prefixes_from(None, Some("acme.")), + ["gts.", "acme."] + ); + } + + #[test] + fn prefixes_honor_explicit_list_and_deduplicate_active_prefix() { + assert_eq!( + configured_prefixes_from(Some("gts., acme., acme., "), Some("acme.")), + ["gts.", "acme."] + ); + assert_eq!( + configured_prefixes_from(Some("corp."), Some("acme.")), + ["corp.", "acme."] + ); + } + + #[test] + fn allowed_macros_include_builtins_and_configured_wrappers() { + assert_eq!( + configured_allowed_macros_from(Some("my_gts_id, my_schema, gts_id")), + [ + "gts_id", + "struct_to_gts_schema", + "gts_instance", + "gts_instance_raw", + "my_gts_id", + "my_schema", + ] + ); + } +} diff --git a/gts-id/src/prefix.rs b/gts-id/src/prefix.rs index d3ed516..a900e2e 100644 --- a/gts-id/src/prefix.rs +++ b/gts-id/src/prefix.rs @@ -9,6 +9,7 @@ //! `rerun-if-env-changed` so Cargo rebuilds when the variable changes. /// The default identifier prefix for all GTS identifiers. +#[allow(unknown_lints, gts_id_hardcoded_prefix)] pub const DEFAULT_GTS_ID_PREFIX: &str = "gts."; /// Environment variable used to override the GTS identifier prefix at compile time. diff --git a/gts-macros-cli/src/main.rs b/gts-macros-cli/src/main.rs index c4a44df..26801a5 100644 --- a/gts-macros-cli/src/main.rs +++ b/gts-macros-cli/src/main.rs @@ -12,9 +12,11 @@ const SEPARATOR: &str = // Include test structs to access their generated constants mod test_structs { use super::{Deserialize, GtsTypeId, Serialize}; - use gts_macros::{GtsTraitsSchema, struct_to_gts_schema}; + use gts_macros::{GtsTraitsSchema, gts_id, struct_to_gts_schema}; use schemars::JsonSchema; + const TOPIC_TYPE_REF: &str = gts_id!("x.core.events.topic.v1~"); + // Inline trait-shape declaring the system-behaviour properties shared by // every event type. An ordinary `JsonSchema` struct marked with // `#[derive(GtsTraitsSchema)]` - `x-gts-ref` / `default` come from standard @@ -28,7 +30,7 @@ mod test_structs { pub struct EventTypeTraitsV1 { // Required trait: no default, so every non-abstract type in the chain // must resolve it explicitly via `x-gts-traits` (OP#13 completeness). - #[schemars(extend("x-gts-ref" = "gts.x.core.events.topic.v1~"))] + #[schemars(extend("x-gts-ref" = TOPIC_TYPE_REF))] pub topic_ref: String, #[serde(default = "default_retention")] pub retention: String, @@ -40,7 +42,7 @@ mod test_structs { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.core.events.type.v1~", + type_id = gts_id!("x.core.events.type.v1~"), description = "Base event type definition", properties = "event_type,id,tenant_id,sequence_id,payload", traits_schema = inline(EventTypeTraitsV1), @@ -59,7 +61,7 @@ mod test_structs { #[struct_to_gts_schema( dir_path = "schemas", base = BaseEventV1, - type_id = "gts.x.core.events.type.v1~x.core.audit.event.v1~", + type_id = gts_id!("x.core.events.type.v1~x.core.audit.event.v1~"), description = "Audit event with user context", properties = "user_agent,user_id,ip_address,data", gts_abstract = true, @@ -75,11 +77,11 @@ mod test_structs { #[struct_to_gts_schema( dir_path = "schemas", base = AuditPayloadV1, - type_id = "gts.x.core.events.type.v1~x.core.audit.event.v1~x.marketplace.orders.purchase.v1~", + type_id = gts_id!("x.core.events.type.v1~x.core.audit.event.v1~x.marketplace.orders.purchase.v1~"), description = "Order placement audit event", properties = "order_id,product_id", traits = serde_json::json!({ - "topic_ref": "gts.x.core.events.topic.v1~x.marketplace._.orders.v1" + "topic_ref": gts_id!("x.core.events.topic.v1~x.marketplace._.orders.v1") }), )] #[derive(Debug, JsonSchema)] @@ -92,7 +94,7 @@ mod test_structs { #[struct_to_gts_schema( dir_path = "schemas", base = PlaceOrderDataV1, - type_id = "gts.x.core.events.type.v1~x.core.audit.event.v1~x.marketplace.orders.purchase.v1~x.marketplace.order_purchase.payload.v1~", + type_id = gts_id!("x.core.events.type.v1~x.core.audit.event.v1~x.marketplace.orders.purchase.v1~x.marketplace.order_purchase.payload.v1~"), description = "Order placement audit event", properties = "order_id" )] diff --git a/gts-macros/src/id_arg.rs b/gts-macros/src/id_arg.rs index f416c7a..897faaa 100644 --- a/gts-macros/src/id_arg.rs +++ b/gts-macros/src/id_arg.rs @@ -32,9 +32,13 @@ fn is_prefix_macro_path(path: &Path) -> bool { } /// Build the full id literal from a suffix written inside `gts_id!("...")`, -/// preserving the suffix literal's span for diagnostics. +/// using a macro-definition span so lint passes can distinguish it from a +/// user-written hardcoded prefixed literal. pub fn build_prefixed_lit(suffix: &LitStr) -> LitStr { - LitStr::new(&format!("{GTS_ID_PREFIX}{}", suffix.value()), suffix.span()) + LitStr::new( + &format!("{GTS_ID_PREFIX}{}", suffix.value()), + proc_macro2::Span::mixed_site(), + ) } /// Parse a GTS-id macro argument from a parse stream, accepting either a full diff --git a/gts-macros/src/instance.rs b/gts-macros/src/instance.rs index 5c77187..89bf395 100644 --- a/gts-macros/src/instance.rs +++ b/gts-macros/src/instance.rs @@ -262,8 +262,13 @@ fn build_typed_instance_block(args: &TypedInstanceArgs) -> syn::Result syn::Result::TYPE_ID, ), "instance id literal must equal the type's GtsSchema::TYPE_ID followed by a single non-empty segment (no extra `~`); for chained schemas, write the full type as a turbofish on the struct literal (e.g. `BaseV1::` rather than bare `BaseV1`) so the macro can derive the conforming schema" @@ -469,6 +474,11 @@ pub fn expand_gts_instance_raw(input: TokenStream2) -> syn::Result let _ = split_instance_id(&args.instance_id)?; let instance_id_lit = &args.instance_id; let body_tokens = &args.body; + // Derived literal for the `insert` call uses `mixed_site` span so downstream + // lint passes don't fire a duplicate error. Only the user-written literal in + // the `json!` body should be flagged, not the macro-generated overwrite. + let instance_id_lit_derived = + LitStr::new(&instance_id_lit.value(), proc_macro2::Span::mixed_site()); // Build the JSON object first, then unconditionally overwrite the // `"id"` key with the validated literal. The user's body already // contains an `"id"` entry (we rejected the macro otherwise), and @@ -483,7 +493,7 @@ pub fn expand_gts_instance_raw(input: TokenStream2) -> syn::Result .expect("gts_instance_raw! body must be a JSON object") .insert( "id".to_owned(), - ::serde_json::Value::String((#instance_id_lit).to_owned()), + ::serde_json::Value::String((#instance_id_lit_derived).to_owned()), ); __gts_value } diff --git a/gts-macros/src/lib.rs b/gts-macros/src/lib.rs index 5f92a5e..9464f76 100644 --- a/gts-macros/src/lib.rs +++ b/gts-macros/src/lib.rs @@ -655,6 +655,11 @@ enum TraitsSchemaSpec { struct GtsSchemaArgs { dir_path: String, type_id: String, + /// The original `LitStr` for `type_id`, preserving its span so that + /// downstream lint passes can distinguish `gts_id!("...")` + /// (macro-generated span) from hardcoded `"gts...."` literals (call-site + /// span). + type_id_lit: LitStr, description: String, properties: String, base: BaseAttr, @@ -676,6 +681,7 @@ impl Parse for GtsSchemaArgs { fn parse(input: ParseStream) -> syn::Result { let mut dir_path: Option = None; let mut type_id: Option = None; + let mut type_id_lit: Option = None; let mut schema_id_alias_used = false; let mut description: Option = None; let mut properties: Option = None; @@ -723,6 +729,7 @@ impl Parse for GtsSchemaArgs { )); } type_id = Some(id); + type_id_lit = Some(value); if key_str == "schema_id" { schema_id_alias_used = true; } @@ -801,6 +808,8 @@ impl Parse for GtsSchemaArgs { .ok_or_else(|| input.error("Missing required attribute: dir_path"))?, type_id: type_id .ok_or_else(|| input.error("Missing required attribute: type_id"))?, + type_id_lit: type_id_lit + .ok_or_else(|| input.error("Missing required attribute: type_id"))?, description: description .ok_or_else(|| input.error("Missing required attribute: description"))?, properties: properties @@ -1096,9 +1105,22 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream let struct_name = &input.ident; let dir_path = &args.dir_path; let type_id = &args.type_id; + let type_id_lit = &args.type_id_lit; + // Derived uses (gts_type_id(), gts_make_instance_id()) get a mixed_site + // span so downstream lint passes don't fire duplicate errors for the same + // literal. + // Only TYPE_ID keeps the original span: one error per hardcoded id. + let type_id_lit_derived = LitStr::new(&args.type_id, proc_macro2::Span::mixed_site()); let description = &args.description; let properties_str = &args.properties; + // Wrap the derived parent type ID in a LitStr with mixed_site span so + // downstream lint passes treat it as macro-generated (not a hand-written + // hardcoded prefix). + let parent_id_lit = expected_parent_type_id + .as_ref() + .map(|pid| LitStr::new(pid, proc_macro2::Span::mixed_site())); + // If the user wrote the deprecated `schema_id = "..."` form, emit a // compile-time deprecation warning at the macro call site. let deprecation_warning = if args.schema_id_alias_used { @@ -1157,12 +1179,12 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream }; // Generate BASE_TYPE_ID constant (private) and compile-time assertion for base struct matching - let base_schema_id_const = if let Some(parent_id) = &expected_parent_type_id { + let base_schema_id_const = if let Some(parent_id_lit) = &parent_id_lit { quote! { /// Parent type ID (extracted from `type_id` segments). Use `gts_base_type_id()` instead. #[doc(hidden)] #[allow(dead_code)] - const BASE_TYPE_ID: Option<&'static str> = Some(#parent_id); + const BASE_TYPE_ID: Option<&'static str> = Some(#parent_id_lit); /// Deprecated alias for `BASE_TYPE_ID`. #[doc(hidden)] @@ -1186,8 +1208,8 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream }; // Generate the literal option value for use in static initializers (avoids Self::BASE_SCHEMA_ID) - let base_schema_id_option = if let Some(parent_id) = &expected_parent_type_id { - quote! { Some(#parent_id) } + let base_schema_id_option = if let Some(parent_id_lit) = &parent_id_lit { + quote! { Some(#parent_id_lit) } } else { quote! { None::<&'static str> } }; @@ -1216,7 +1238,7 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream const _: () = { // Use a const assertion to verify at compile time const PARENT_ID: &'static str = <#parent_ident<()> as ::gts::GtsSchema>::TYPE_ID; - const EXPECTED_ID: &'static str = #parent_id; + const EXPECTED_ID: &'static str = #parent_id_lit; // Use a manual string comparison for const context const _: () = { // Manual string equality check for const context @@ -2175,7 +2197,7 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream #[must_use] pub fn gts_type_id() -> &'static ::gts::gts::GtsTypeId { static GTS_TYPE_ID: std::sync::LazyLock<::gts::gts::GtsTypeId> = - std::sync::LazyLock::new(|| ::gts::gts::GtsTypeId::new(#type_id)); + std::sync::LazyLock::new(|| ::gts::gts::GtsTypeId::new(#type_id_lit_derived)); >S_TYPE_ID } @@ -2211,13 +2233,13 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream #[allow(dead_code)] #[must_use] pub fn gts_make_instance_id(segment: &str) -> ::gts::GtsInstanceId { - ::gts::GtsInstanceId::new(#type_id, segment) + ::gts::GtsInstanceId::new(#type_id_lit_derived, segment) } } // Implement GtsSchema trait for runtime schema composition impl #impl_generics ::gts::GtsSchema for #struct_name #ty_generics #gts_schema_where_clause { - const TYPE_ID: &'static str = #type_id; + const TYPE_ID: &'static str = #type_id_lit; const GENERIC_FIELD: Option<&'static str> = #generic_field_option; // Modifiers, so children / `gts_instance!` can guard at compile time // on the parent's / target's finality / abstractness. @@ -2287,9 +2309,9 @@ mod instance; /// Construct a full GTS identifier string from a prefix-less suffix. /// /// `gts_id!("x.core.events.topic.v1~")` expands to a `&'static str` literal -/// equal to `concat!(GTS_ID_PREFIX, "x.core.events.topic.v1~")` — i.e. the -/// configured prefix (`gts.` by default, overridable via the `GTS_ID_PREFIX` -/// environment variable) followed by the given suffix. +/// containing the configured prefix (`gts.` by default, overridable via the +/// `GTS_ID_PREFIX` environment variable at compile time) followed by the given +/// suffix. /// /// The same `gts_id!("...")` form is also recognized as a marker inside the /// `type_id`/`id` arguments of [`struct_to_gts_schema`], [`gts_instance!`], and @@ -2303,6 +2325,12 @@ mod instance; pub fn gts_id(input: TokenStream) -> TokenStream { let suffix = parse_macro_input!(input as LitStr); let full = id_arg::build_prefixed_lit(&suffix); + let full_str = full.value(); + if let Err(e) = gts_id::GtsIdPattern::try_new(&full_str) { + return syn::Error::new_spanned(&suffix, format!("gts_id!: invalid GTS ID pattern: {e}")) + .to_compile_error() + .into(); + } quote!(#full).into() } diff --git a/gts-macros/tests/compile_fail/gts_id_invalid_pattern.rs b/gts-macros/tests/compile_fail/gts_id_invalid_pattern.rs new file mode 100644 index 0000000..ddd656b --- /dev/null +++ b/gts-macros/tests/compile_fail/gts_id_invalid_pattern.rs @@ -0,0 +1,7 @@ +//! Test: `gts_id!` macro rejects invalid GTS ID patterns at compile time. + +use gts_macros::gts_id; + +fn main() { + let _ = gts_id!("invalid..bad"); +} diff --git a/gts-macros/tests/compile_fail/gts_id_invalid_pattern.stderr b/gts-macros/tests/compile_fail/gts_id_invalid_pattern.stderr new file mode 100644 index 0000000..5eaa50f --- /dev/null +++ b/gts-macros/tests/compile_fail/gts_id_invalid_pattern.stderr @@ -0,0 +1,5 @@ +error: gts_id!: invalid GTS ID pattern: Invalid GTS segment #1 @ offset 4: 'invalid..bad': Too few tokens (got 3, min 5). Expected format: gts.vendor.package.namespace.type.vMAJOR[.MINOR] + --> tests/compile_fail/gts_id_invalid_pattern.rs:6:21 + | +6 | let _ = gts_id!("invalid..bad"); + | ^^^^^^^^^^^^^^ diff --git a/gts-macros/tests/compile_fail/gts_id_missing_prefix.rs b/gts-macros/tests/compile_fail/gts_id_missing_prefix.rs new file mode 100644 index 0000000..79cbc66 --- /dev/null +++ b/gts-macros/tests/compile_fail/gts_id_missing_prefix.rs @@ -0,0 +1,8 @@ +//! Test: `gts_id!` macro rejects a suffix that produces an invalid GTS ID +//! (too few tokens in the segment). + +use gts_macros::gts_id; + +fn main() { + let _ = gts_id!("x.foo"); +} diff --git a/gts-macros/tests/compile_fail/gts_id_missing_prefix.stderr b/gts-macros/tests/compile_fail/gts_id_missing_prefix.stderr new file mode 100644 index 0000000..c9ecfc3 --- /dev/null +++ b/gts-macros/tests/compile_fail/gts_id_missing_prefix.stderr @@ -0,0 +1,5 @@ +error: gts_id!: invalid GTS ID pattern: Invalid GTS segment #1 @ offset 4: 'x.foo': Too few tokens (got 2, min 5). Expected format: gts.vendor.package.namespace.type.vMAJOR[.MINOR] + --> tests/compile_fail/gts_id_missing_prefix.rs:7:21 + | +7 | let _ = gts_id!("x.foo"); + | ^^^^^^^ diff --git a/gts-macros/tests/deprecated_schema_id_alias.rs b/gts-macros/tests/deprecated_schema_id_alias.rs index 0970ff4..3196da3 100644 --- a/gts-macros/tests/deprecated_schema_id_alias.rs +++ b/gts-macros/tests/deprecated_schema_id_alias.rs @@ -8,13 +8,13 @@ #![allow(deprecated, clippy::unwrap_used)] use gts::{GtsInstanceId, GtsSchema}; -use gts_macros::struct_to_gts_schema; +use gts_macros::{gts_id, struct_to_gts_schema}; #[derive(Debug, Clone)] #[struct_to_gts_schema( dir_path = "schemas", base = true, - schema_id = "gts.x.test.deprecated.alias.v1~", + schema_id = gts_id!("x.test.deprecated.alias.v1~"), description = "Verifies the deprecated `schema_id` alias still parses", properties = "id" )] @@ -26,10 +26,10 @@ pub struct DeprecatedAliasV1 { fn deprecated_schema_id_alias_still_works() { assert_eq!( DeprecatedAliasV1::gts_schema_id().as_ref(), - "gts.x.test.deprecated.alias.v1~" + gts_id!("x.test.deprecated.alias.v1~") ); assert_eq!( ::SCHEMA_ID, - "gts.x.test.deprecated.alias.v1~" + gts_id!("x.test.deprecated.alias.v1~") ); } diff --git a/gts-macros/tests/golden/traits_generic_child.rs b/gts-macros/tests/golden/traits_generic_child.rs index b7e0ba3..045ce7a 100644 --- a/gts-macros/tests/golden/traits_generic_child.rs +++ b/gts-macros/tests/golden/traits_generic_child.rs @@ -6,12 +6,14 @@ // generic param is the nested payload field carried forward down the chain. use gts::{GtsInstanceId, GtsSchema}; -use gts_macros::{struct_to_gts_schema, GtsTraitsSchema}; +use gts_macros::{gts_id, struct_to_gts_schema, GtsTraitsSchema}; use schemars::JsonSchema; +const TOPIC_REF: &str = gts_id!("x.core.events.topic.v1~"); + #[derive(JsonSchema, serde::Serialize, GtsTraitsSchema)] pub struct EventTraits { - #[schemars(extend("x-gts-ref" = "gts.x.core.events.topic.v1~"))] + #[schemars(extend("x-gts-ref" = TOPIC_REF))] pub topic_ref: String, } @@ -37,7 +39,7 @@ pub struct EventV1

{ description = "Still-generic abstract mid resolving the inherited topic trait", properties = "user_id,data", traits = serde_json::json!({ - "topic_ref": "gts.x.core.events.topic.v1~x.test._.audit.v1" + "topic_ref": gts_id!("x.core.events.topic.v1~x.test._.audit.v1") }), gts_abstract = true, )] diff --git a/gts-macros/tests/golden/traits_inline_chain.rs b/gts-macros/tests/golden/traits_inline_chain.rs index 9373d45..8b832a1 100644 --- a/gts-macros/tests/golden/traits_inline_chain.rs +++ b/gts-macros/tests/golden/traits_inline_chain.rs @@ -11,9 +11,11 @@ // `#[schemars(inline)]` cannot do, only generator-wide `inline_subschemas` can. use gts::{GtsInstanceId, GtsSchema}; -use gts_macros::{struct_to_gts_schema, GtsTraitsSchema}; +use gts_macros::{gts_id, struct_to_gts_schema, GtsTraitsSchema}; use schemars::JsonSchema; +const TOPIC_REF: &str = gts_id!("x.core.events.topic.v1~"); + fn default_retention() -> String { "P30D".to_owned() } @@ -34,7 +36,7 @@ pub struct Escalation { #[derive(JsonSchema, serde::Serialize, GtsTraitsSchema)] pub struct EventTraits { - #[schemars(extend("x-gts-ref" = "gts.x.core.events.topic.v1~"))] + #[schemars(extend("x-gts-ref" = TOPIC_REF))] pub topic_ref: String, #[serde(default = "default_retention")] pub retention: String, @@ -66,7 +68,7 @@ pub struct EventV1

{ description = "Order placed", properties = "order_id", traits = serde_json::json!({ - "topic_ref": "gts.x.core.events.topic.v1~x.test._.orders.v1", + "topic_ref": gts_id!("x.core.events.topic.v1~x.test._.orders.v1"), "indexed": true, "severity": "High", "escalation": { "after": "PT5M", "to": "Low" } diff --git a/gts-macros/tests/golden/traits_struct_literal.rs b/gts-macros/tests/golden/traits_struct_literal.rs index 7b618a0..4bff76d 100644 --- a/gts-macros/tests/golden/traits_struct_literal.rs +++ b/gts-macros/tests/golden/traits_struct_literal.rs @@ -6,12 +6,14 @@ // `x-gts-traits-schema`, so the leaf's values are an instance of that shape. use gts::{GtsInstanceId, GtsSchema}; -use gts_macros::{struct_to_gts_schema, GtsTraitsSchema}; +use gts_macros::{gts_id, struct_to_gts_schema, GtsTraitsSchema}; use schemars::JsonSchema; +const TOPIC_REF: &str = gts_id!("x.core.events.topic.v1~"); + #[derive(JsonSchema, serde::Serialize, GtsTraitsSchema)] pub struct OrderTraits { - #[schemars(extend("x-gts-ref" = "gts.x.core.events.topic.v1~"))] + #[schemars(extend("x-gts-ref" = TOPIC_REF))] pub topic_ref: String, pub retention: String, pub indexed: bool, @@ -40,7 +42,7 @@ pub struct LitEventV1

{ description = "Leaf resolving every trait via a struct literal", properties = "order_id", traits = OrderTraits { - topic_ref: "gts.x.core.events.topic.v1~x.test._.orders.v1".to_owned(), + topic_ref: gts_id!("x.core.events.topic.v1~x.test._.orders.v1").to_owned(), retention: "P90D".to_owned(), indexed: true, partition_count: 8, diff --git a/gts-macros/tests/inheritance_tests.rs b/gts-macros/tests/inheritance_tests.rs index abcaea0..747753d 100644 --- a/gts-macros/tests/inheritance_tests.rs +++ b/gts-macros/tests/inheritance_tests.rs @@ -9,7 +9,7 @@ use gts::gts::GtsTypeId; use gts::{GtsInstanceId, GtsSchema, GtsStore}; -use gts_macros::struct_to_gts_schema; +use gts_macros::{gts_id, struct_to_gts_schema}; use uuid::Uuid; /* ============================================================ @@ -19,7 +19,7 @@ Chained inheritance #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.core.events.type.v1~", + type_id = gts_id!("x.core.events.type.v1~"), description = "Base event type definition", properties = "event_type,id,tenant_id,sequence_id,payload" )] @@ -36,7 +36,7 @@ pub struct BaseEventV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = BaseEventV1, - type_id = "gts.x.core.events.type.v1~x.core.audit.event.v1~", + type_id = gts_id!("x.core.events.type.v1~x.core.audit.event.v1~"), description = "Audit event with user context", properties = "user_agent,user_id,ip_address,data" )] @@ -51,7 +51,7 @@ pub struct AuditPayloadV1 { #[struct_to_gts_schema( dir_path = "schemas", base = AuditPayloadV1, - type_id = "gts.x.core.events.type.v1~x.core.audit.event.v1~x.marketplace.orders.purchase.v1~", + type_id = gts_id!("x.core.events.type.v1~x.core.audit.event.v1~x.marketplace.orders.purchase.v1~"), description = "Order placement audit event", properties = "order_id,product_id" )] @@ -68,7 +68,7 @@ pub struct PlaceOrderDataV1 { #[struct_to_gts_schema( dir_path = "schemas", base = BaseEventV1, - type_id = "gts.x.core.events.type.v1~x.core.simple.event.v1~", + type_id = gts_id!("x.core.events.type.v1~x.core.simple.event.v1~"), description = "Simple event payload with just a message", properties = "message,severity" )] @@ -85,7 +85,7 @@ Base struct ID field validation tests #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.core.events.topic.v1~", + type_id = gts_id!("x.core.events.topic.v1~"), description = "Base topic type definition with id field", properties = "id,name,description" )] @@ -100,7 +100,7 @@ pub struct TopicV1WithIdV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.core.events.topic.v1~", + type_id = gts_id!("x.core.events.topic.v1~"), description = "Base topic type definition with gts_id field", properties = "gts_id,name,description" )] @@ -115,7 +115,7 @@ pub struct TopicV1WithGtsIdV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.core.events.topic.v1~", + type_id = gts_id!("x.core.events.topic.v1~"), description = "Base topic type definition with gtsId field", properties = "gts_id,name,description" )] @@ -130,7 +130,7 @@ pub struct TopicV1WithGtsIdCamelV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.core.events.topic.v1~", + type_id = gts_id!("x.core.events.topic.v1~"), description = "Base topic type definition with gts_type field", properties = "gts_type,name,description" )] @@ -145,7 +145,7 @@ pub struct TopicV1WithGtsTypeV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.core.events.topic.v1~", + type_id = gts_id!("x.core.events.topic.v1~"), description = "Base topic type definition with gtsType field", properties = "gts_type,name,description" )] @@ -164,7 +164,7 @@ Chained inheritance w/o new attributes #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.core.events.topic.v1~", + type_id = gts_id!("x.core.events.topic.v1~"), description = "Base topic type definition", properties = "name,description" )] @@ -179,7 +179,7 @@ pub struct TopicV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = TopicV1, - type_id = "gts.x.core.events.topic.v1~x.commerce.orders.topic.v1~", + type_id = gts_id!("x.core.events.topic.v1~x.commerce.orders.topic.v1~"), description = "Order topic configuration", properties = "" )] @@ -194,7 +194,7 @@ Test serde rename on generic field - the serialized name should be used #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.core.events.container.v1~", + type_id = gts_id!("x.core.events.container.v1~"), description = "Container with renamed generic field", properties = "id,name,rust_field_name" )] @@ -209,7 +209,7 @@ pub struct ContainerV1 { #[struct_to_gts_schema( dir_path = "schemas", base = ContainerV1, - type_id = "gts.x.core.events.container.v1~x.app.entities.content.v1~", + type_id = gts_id!("x.core.events.container.v1~x.app.entities.content.v1~"), description = "Content extending container", properties = "content_value" )] @@ -330,7 +330,9 @@ mod tests { let event_json = serde_json::to_value(&event).unwrap(); assert_eq!( event_json["type"], - "gts.x.core.events.type.v1~x.core.audit.event.v1~x.marketplace.orders.purchase.v1~" + gts_id!( + "x.core.events.type.v1~x.core.audit.event.v1~x.marketplace.orders.purchase.v1~" + ) ); assert!(event_json["payload"]["user_agent"].is_string()); assert!(event_json["payload"]["data"]["order_id"].is_string()); @@ -405,12 +407,15 @@ mod tests { // Verify schema IDs are still accessible assert!( - BaseEventV1::<()>::gts_type_id().clone().into_string() == "gts.x.core.events.type.v1~" + BaseEventV1::<()>::gts_type_id().clone().into_string() + == gts_id!("x.core.events.type.v1~") ); let _audit_payload_id = AuditPayloadV1::<()>::gts_type_id().clone().into_string(); assert!( PlaceOrderDataV1::gts_type_id().clone().into_string() - == "gts.x.core.events.type.v1~x.core.audit.event.v1~x.marketplace.orders.purchase.v1~" + == gts_id!( + "x.core.events.type.v1~x.core.audit.event.v1~x.marketplace.orders.purchase.v1~" + ) ); // BaseEventV1 should have direct properties, no allOf @@ -518,7 +523,9 @@ mod tests { // Validate instance field paths match schema structure assert_eq!( json["type"], - "gts.x.core.events.type.v1~x.core.audit.event.v1~x.marketplace.orders.purchase.v1~" + gts_id!( + "x.core.events.type.v1~x.core.audit.event.v1~x.marketplace.orders.purchase.v1~" + ) ); assert!(json["payload"]["user_agent"].is_string()); assert!(json["payload"]["data"]["order_id"].is_string()); @@ -575,7 +582,9 @@ mod tests { // Validate type field matches PlaceOrderDataV1 schema ID assert_eq!( json["type"], - "gts.x.core.events.type.v1~x.core.audit.event.v1~x.marketplace.orders.purchase.v1~" + gts_id!( + "x.core.events.type.v1~x.core.audit.event.v1~x.marketplace.orders.purchase.v1~" + ) ); // The actual JSON has nested objects: @@ -624,15 +633,15 @@ mod tests { // Child types should have gts_base_type_id() = Some(parent's schema ID) assert_eq!( AuditPayloadV1::<()>::gts_base_type_id().map(AsRef::as_ref), - Some("gts.x.core.events.type.v1~") + Some(gts_id!("x.core.events.type.v1~")) ); assert_eq!( PlaceOrderDataV1::gts_base_type_id().map(AsRef::as_ref), - Some("gts.x.core.events.type.v1~x.core.audit.event.v1~") + Some(gts_id!("x.core.events.type.v1~x.core.audit.event.v1~")) ); assert_eq!( OrderTopicConfigV1::gts_base_type_id().map(AsRef::as_ref), - Some("gts.x.core.events.topic.v1~") + Some(gts_id!("x.core.events.topic.v1~")) ); } @@ -664,12 +673,12 @@ mod tests { // Verify the schema IDs are correctly related assert_eq!( TopicV1::<()>::gts_type_id().clone().into_string(), - "gts.x.core.events.topic.v1~" + gts_id!("x.core.events.topic.v1~") ); let order_topic_id = OrderTopicConfigV1::gts_type_id().clone().into_string(); assert_eq!( order_topic_id, - "gts.x.core.events.topic.v1~x.commerce.orders.topic.v1~" + gts_id!("x.core.events.topic.v1~x.commerce.orders.topic.v1~") ); // Test that the GTS schema ID is a valid GTS schema ID @@ -767,14 +776,14 @@ mod tests { // Unit struct should implement GtsSchema assert_eq!( OrderTopicConfigV1::TYPE_ID, - "gts.x.core.events.topic.v1~x.commerce.orders.topic.v1~" + gts_id!("x.core.events.topic.v1~x.commerce.orders.topic.v1~") ); assert_eq!(OrderTopicConfigV1::GENERIC_FIELD, None); // innermost_type_id for a non-generic type returns itself assert_eq!( OrderTopicConfigV1::innermost_type_id(), - "gts.x.core.events.topic.v1~x.commerce.orders.topic.v1~" + gts_id!("x.core.events.topic.v1~x.commerce.orders.topic.v1~") ); } @@ -842,7 +851,10 @@ mod tests { let id_str = instance_id.as_ref(); // 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}")); + assert_eq!( + id_str, + format!("{}{segment}", gts_id!("x.core.events.topic.v1~")) + ); // Note: gts_make_instance_id uses the schema ID of the type it's called on (TopicV1), // not the generic parameter (OrderTopicConfigV1) } @@ -905,7 +917,7 @@ mod tests { // Test query functionality using GtsOps let query_result = ops.query( - "gts.x.core.events.topic.v1~x.commerce.orders.topic.v1~*", + gts_id!("x.core.events.topic.v1~x.commerce.orders.topic.v1~*"), 10, ); @@ -1062,11 +1074,11 @@ mod tests { // Verify the instance IDs are correctly generated assert_eq!( topic_instance.id, - "gts.x.core.events.topic.v1~vendor.app._.topic.v1" + gts_id!("x.core.events.topic.v1~vendor.app._.topic.v1") ); assert_eq!( nested_topic_instance.id, - "gts.x.core.events.topic.v1~vendor.app.nested.topic.v1" + gts_id!("x.core.events.topic.v1~vendor.app.nested.topic.v1") ); // Verify the JSON structure contains expected fields @@ -1166,21 +1178,25 @@ mod tests { // Test that the schema constants are generated correctly assert_eq!( TopicV1WithIdV1::<()>::gts_type_id().clone().into_string(), - "gts.x.core.events.topic.v1~" + gts_id!("x.core.events.topic.v1~") ); assert!(TopicV1WithIdV1::<()>::gts_base_type_id().is_none()); // Test serialization let serialized = serde_json::to_string(&topic).expect("Serialization should succeed"); - assert!(serialized.contains( - "gts.x.core.events.topic.v1~x.commerce.orders.topic.v1~vendor.app._.topic.v1" - )); + assert!(serialized.contains(gts_id!( + "x.core.events.topic.v1~x.commerce.orders.topic.v1~vendor.app._.topic.v1" + ))); assert!(serialized.contains("orders")); // Test instance ID generation let instance_id = TopicV1WithIdV1::::gts_make_instance_id("test-instance"); - assert!(instance_id.as_ref().contains("gts.x.core.events.topic.v1~")); + assert!( + instance_id + .as_ref() + .contains(gts_id!("x.core.events.topic.v1~")) + ); assert!(instance_id.as_ref().ends_with("test-instance")); // Validate instance against schema @@ -1240,15 +1256,15 @@ mod tests { TopicV1WithGtsIdV1::<()>::gts_type_id() .clone() .into_string(), - "gts.x.core.events.topic.v1~" + gts_id!("x.core.events.topic.v1~") ); assert!(TopicV1WithGtsIdV1::<()>::gts_base_type_id().is_none()); // Test serialization let serialized = serde_json::to_string(&topic).expect("Serialization should succeed"); - assert!(serialized.contains( - "gts.x.core.events.topic.v1~x.commerce.orders.topic.v1~vendor.app._.topic.v1" - )); + assert!(serialized.contains(gts_id!( + "x.core.events.topic.v1~x.commerce.orders.topic.v1~vendor.app._.topic.v1" + ))); assert!(serialized.contains("orders")); // Validate instance against schema @@ -1308,15 +1324,15 @@ mod tests { TopicV1WithGtsIdCamelV1::<()>::gts_type_id() .clone() .into_string(), - "gts.x.core.events.topic.v1~" + gts_id!("x.core.events.topic.v1~") ); assert!(TopicV1WithGtsIdCamelV1::<()>::gts_base_type_id().is_none()); // Test serialization let serialized = serde_json::to_string(&topic).expect("Serialization should succeed"); - assert!(serialized.contains( - "gts.x.core.events.topic.v1~x.commerce.orders.topic.v1~vendor.app._.topic.v1" - )); + assert!(serialized.contains(gts_id!( + "x.core.events.topic.v1~x.commerce.orders.topic.v1~vendor.app._.topic.v1" + ))); assert!(serialized.contains("orders")); // Validate instance against schema @@ -1355,7 +1371,7 @@ mod tests { // Test that base structs with 'gts_type' field compile and work correctly let topic = TopicV1WithGtsTypeV1:: { - gts_type: GtsTypeId::new("gts.x.core.events.topic.v1~"), + gts_type: GtsTypeId::new(gts_id!("x.core.events.topic.v1~")), name: "orders".to_string(), description: Some("Order events".to_string()), config: OrderTopicConfigV1, @@ -1366,13 +1382,13 @@ mod tests { TopicV1WithGtsTypeV1::<()>::gts_type_id() .clone() .into_string(), - "gts.x.core.events.topic.v1~" + gts_id!("x.core.events.topic.v1~") ); assert!(TopicV1WithGtsTypeV1::<()>::gts_base_type_id().is_none()); // Test serialization let serialized = serde_json::to_string(&topic).expect("Serialization should succeed"); - assert!(serialized.contains("gts.x.core.events.topic.v1~")); + assert!(serialized.contains(gts_id!("x.core.events.topic.v1~"))); assert!(serialized.contains("orders")); // Validate JSON structure matches schema (no GTS instance ID field, so verify structure) @@ -1407,7 +1423,7 @@ mod tests { // Test that base structs with 'gtsType' field compile and work correctly let topic = TopicV1WithGtsTypeCamelV1:: { - gts_type: GtsTypeId::new("gts.x.core.events.topic.v1~"), + gts_type: GtsTypeId::new(gts_id!("x.core.events.topic.v1~")), name: "orders".to_string(), description: Some("Order events".to_string()), config: OrderTopicConfigV1, @@ -1418,13 +1434,13 @@ mod tests { TopicV1WithGtsTypeCamelV1::<()>::gts_type_id() .clone() .into_string(), - "gts.x.core.events.topic.v1~" + gts_id!("x.core.events.topic.v1~") ); assert!(TopicV1WithGtsTypeCamelV1::<()>::gts_base_type_id().is_none()); // Test serialization let serialized = serde_json::to_string(&topic).expect("Serialization should succeed"); - assert!(serialized.contains("gts.x.core.events.topic.v1~")); + assert!(serialized.contains(gts_id!("x.core.events.topic.v1~"))); assert!(serialized.contains("orders")); // Validate JSON structure matches schema (no GTS instance ID field, so verify structure) @@ -1519,7 +1535,9 @@ mod tests { // Validate instance matches schema structure assert_eq!( json_value["type"], - "gts.x.core.events.type.v1~x.core.audit.event.v1~x.marketplace.orders.purchase.v1~" + gts_id!( + "x.core.events.type.v1~x.core.audit.event.v1~x.marketplace.orders.purchase.v1~" + ) ); assert!(json_value["payload"]["user_agent"].is_string()); assert!(json_value["payload"]["data"]["product_id"].is_string()); @@ -1575,7 +1593,10 @@ mod tests { // Child struct should return Some with correct value let child_id: Option<>s::gts::GtsTypeId> = OrderTopicConfigV1::gts_base_type_id(); assert!(child_id.is_some()); - assert_eq!(child_id.unwrap().as_ref(), "gts.x.core.events.topic.v1~"); + assert_eq!( + child_id.unwrap().as_ref(), + gts_id!("x.core.events.topic.v1~") + ); // Verify it's usable as GtsTypeId let parent_id = child_id.unwrap(); @@ -1591,11 +1612,11 @@ mod tests { // Verify schema IDs for 2-level inheritance chain assert_eq!( BaseEventV1::<()>::gts_type_id().as_ref(), - "gts.x.core.events.type.v1~" + gts_id!("x.core.events.type.v1~") ); assert_eq!( SimplePayloadV1::gts_type_id().as_ref(), - "gts.x.core.events.type.v1~x.core.simple.event.v1~" + gts_id!("x.core.events.type.v1~x.core.simple.event.v1~") ); // Base should have no parent @@ -1604,7 +1625,7 @@ mod tests { // SimplePayloadV1 should have BaseEventV1 as parent assert_eq!( SimplePayloadV1::gts_base_type_id().map(AsRef::as_ref), - Some("gts.x.core.events.type.v1~") + Some(gts_id!("x.core.events.type.v1~")) ); } @@ -1632,7 +1653,7 @@ mod tests { // Verify top-level fields from BaseEventV1 assert_eq!( json["type"], - "gts.x.core.events.type.v1~x.core.simple.event.v1~" + gts_id!("x.core.events.type.v1~x.core.simple.event.v1~") ); assert_eq!(json["id"], "550e8400-e29b-41d4-a716-446655440000"); assert_eq!(json["tenant_id"], "660e8400-e29b-41d4-a716-446655440000"); @@ -1719,11 +1740,13 @@ mod tests { // Validate type fields match their respective schemas assert_eq!( two_json["type"], - "gts.x.core.events.type.v1~x.core.simple.event.v1~" + gts_id!("x.core.events.type.v1~x.core.simple.event.v1~") ); assert_eq!( three_json["type"], - "gts.x.core.events.type.v1~x.core.audit.event.v1~x.marketplace.orders.purchase.v1~" + gts_id!( + "x.core.events.type.v1~x.core.audit.event.v1~x.marketplace.orders.purchase.v1~" + ) ); // 2-level field path: payload.message, payload.severity @@ -2117,7 +2140,7 @@ mod tests { // Instead, we verify the JSON structure matches the schema properties let topic1 = TopicV1WithGtsTypeV1:: { - gts_type: GtsTypeId::new("gts.x.core.events.topic.v1~"), + gts_type: GtsTypeId::new(gts_id!("x.core.events.topic.v1~")), name: "orders".to_string(), description: Some("Order events".to_string()), config: OrderTopicConfigV1, @@ -2140,7 +2163,7 @@ mod tests { ); let topic2 = TopicV1WithGtsTypeCamelV1:: { - gts_type: GtsTypeId::new("gts.x.core.events.topic.v1~"), + gts_type: GtsTypeId::new(gts_id!("x.core.events.topic.v1~")), name: "orders-camel".to_string(), description: Some("Order events camel".to_string()), config: OrderTopicConfigV1, @@ -2208,7 +2231,7 @@ mod tests { // Verify JSON structure matches expected field paths assert_eq!( event_json["type"], - "gts.x.core.events.type.v1~x.core.simple.event.v1~" + gts_id!("x.core.events.type.v1~x.core.simple.event.v1~") ); assert_eq!(event_json["sequence_id"], 100); assert_eq!(event_json["payload"]["message"], "System started"); @@ -2286,7 +2309,9 @@ mod tests { // Verify JSON structure matches expected field paths for 3-level nesting assert_eq!( event_json["type"], - "gts.x.core.events.type.v1~x.core.audit.event.v1~x.marketplace.orders.purchase.v1~" + gts_id!( + "x.core.events.type.v1~x.core.audit.event.v1~x.marketplace.orders.purchase.v1~" + ) ); assert_eq!(event_json["sequence_id"], 42); @@ -2522,7 +2547,7 @@ mod tests { verify_schema_field_path( &schema, - "gts.x.core.events.type.v1~", + gts_id!("x.core.events.type.v1~"), None, // No parent &[], // No nesting path &["type", "id", "tenant_id", "sequence_id", "payload"], @@ -2671,8 +2696,8 @@ mod tests { verify_schema_field_path( &schema, - "gts.x.core.events.type.v1~x.core.simple.event.v1~", - Some("gts.x.core.events.type.v1~"), + gts_id!("x.core.events.type.v1~x.core.simple.event.v1~"), + Some(gts_id!("x.core.events.type.v1~")), &["payload"], // Properties nested under "payload" &["message", "severity"], ); @@ -2689,7 +2714,7 @@ mod tests { verify_schema_field_path( &schema, - "gts.x.core.events.topic.v1~", + gts_id!("x.core.events.topic.v1~"), None, // No parent &[], // No nesting path &["name", "description"], diff --git a/gts-macros/tests/inheritance_tests_mixed.rs b/gts-macros/tests/inheritance_tests_mixed.rs index bb65faa..5ca6403 100644 --- a/gts-macros/tests/inheritance_tests_mixed.rs +++ b/gts-macros/tests/inheritance_tests_mixed.rs @@ -10,7 +10,7 @@ )] use gts::gts::GtsTypeId; -use gts_macros::struct_to_gts_schema; +use gts_macros::{gts_id, struct_to_gts_schema}; use uuid::Uuid; /* ============================================================ @@ -20,7 +20,7 @@ Mixed validation tests - invalid ID fields but valid GTS Type fields #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.core.events.topic.v1~", + type_id = gts_id!("x.core.events.topic.v1~"), description = "Base topic type with invalid ID field but valid GTS Type field", properties = "id,r#type,name,description" )] @@ -36,7 +36,7 @@ pub struct TopicV1MixedValidationV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.core.events.type.v1~", + type_id = gts_id!("x.core.events.type.v1~"), description = "Base event type with invalid ID field but valid GTS Type field", properties = "id,gts_type,name,description" )] @@ -52,7 +52,7 @@ pub struct BaseEventV1MixedV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.core.events.schema.v1~", + type_id = gts_id!("x.core.events.schema.v1~"), description = "Base schema type with invalid ID fields but valid GTS Type field", properties = "gts_id,schema,name,description" )] @@ -79,7 +79,7 @@ mod tests { // the GTS Type field has the correct type let topic = TopicV1MixedValidationV1::<()> { id: "invalid-id".to_string(), - r#type: GtsTypeId::new("gts.x.core.events.topic.v1~"), + r#type: GtsTypeId::new(gts_id!("x.core.events.topic.v1~")), name: "Test Topic".to_string(), description: Some("Test description".to_string()), config: (), @@ -94,7 +94,7 @@ mod tests { // the GTS Type field has the correct type let event = BaseEventV1MixedV1::<()> { id: Uuid::new_v4(), - gts_type: GtsTypeId::new("gts.x.core.events.type.v1~"), + gts_type: GtsTypeId::new(gts_id!("x.core.events.type.v1~")), name: "Test Event".to_string(), description: Some("Test description".to_string()), payload: (), @@ -109,7 +109,7 @@ mod tests { // the GTS Type field has the correct type let schema = BaseSchemaV1MixedV1::<()> { gts_id: "invalid-id".to_string(), - schema: GtsTypeId::new("gts.x.core.events.schema.v1~"), + schema: GtsTypeId::new(gts_id!("x.core.events.schema.v1~")), name: "Test Schema".to_string(), description: Some("Test description".to_string()), config: (), @@ -125,19 +125,19 @@ mod tests { TopicV1MixedValidationV1::<()>::gts_type_id() .clone() .into_string(), - "gts.x.core.events.topic.v1~" + gts_id!("x.core.events.topic.v1~") ); assert_eq!( BaseEventV1MixedV1::<()>::gts_type_id() .clone() .into_string(), - "gts.x.core.events.type.v1~" + gts_id!("x.core.events.type.v1~") ); assert_eq!( BaseSchemaV1MixedV1::<()>::gts_type_id() .clone() .into_string(), - "gts.x.core.events.schema.v1~" + gts_id!("x.core.events.schema.v1~") ); } @@ -146,7 +146,7 @@ mod tests { // Test that serialization works correctly let topic = TopicV1MixedValidationV1::<()> { id: "test-id".to_string(), - r#type: GtsTypeId::new("gts.x.core.events.topic.v1~"), + r#type: GtsTypeId::new(gts_id!("x.core.events.topic.v1~")), name: "Test Topic".to_string(), description: Some("Test description".to_string()), config: (), @@ -156,7 +156,7 @@ mod tests { let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(parsed["id"], "test-id"); - assert_eq!(parsed["type"], "gts.x.core.events.topic.v1~"); + assert_eq!(parsed["type"], gts_id!("x.core.events.topic.v1~")); assert_eq!(parsed["name"], "Test Topic"); } } diff --git a/gts-macros/tests/integration_tests.rs b/gts-macros/tests/integration_tests.rs index 1d2e2f8..e604777 100644 --- a/gts-macros/tests/integration_tests.rs +++ b/gts-macros/tests/integration_tests.rs @@ -10,14 +10,14 @@ mod inheritance_tests; use gts::{GtsConfig, GtsEntity, GtsId, GtsInstanceId, GtsSchema}; -use gts_macros::struct_to_gts_schema; +use gts_macros::{gts_id, 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 #[derive(Debug, Clone)] #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.core.events.topic.v1~", + type_id = gts_id!("x.core.events.topic.v1~"), description = "Event Topic (Stream) definition", properties = "id,name,description,retention,ordering" )] @@ -41,7 +41,7 @@ pub struct EventTopicV1 { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.test.entities.product.v1~", + type_id = gts_id!("x.test.entities.product.v1~"), description = "Product entity with pricing information", properties = "id,name,price,description,in_stock" )] @@ -172,13 +172,13 @@ fn test_gts_instance_id_simple_segment() { let id = EventTopicV1::gts_make_instance_id("x.commerce.orders.orders.v1.0"); assert_eq!( id, - "gts.x.core.events.topic.v1~x.commerce.orders.orders.v1.0" + gts_id!("x.core.events.topic.v1~x.commerce.orders.orders.v1.0") ); let id = ProductV1::gts_make_instance_id("vendor.package.sku.abc.v1"); assert_eq!( id, - "gts.x.test.entities.product.v1~vendor.package.sku.abc.v1" + gts_id!("x.test.entities.product.v1~vendor.package.sku.abc.v1") ); } @@ -186,31 +186,34 @@ fn test_gts_instance_id_simple_segment() { fn test_gts_instance_id_multi_segment() { // Test with multi-part segment like vendor.package.namespace.type.version let id = EventTopicV1::gts_make_instance_id("x.core.idp.contacts.v1"); - assert_eq!(id, "gts.x.core.events.topic.v1~x.core.idp.contacts.v1"); + assert_eq!(id, gts_id!("x.core.events.topic.v1~x.core.idp.contacts.v1")); } #[test] fn test_gts_instance_id_with_wildcard_segment() { // Test with segment containing wildcard "_" let id = EventTopicV1::gts_make_instance_id("x.commerce._.orders.v1.0"); - assert_eq!(id, "gts.x.core.events.topic.v1~x.commerce._.orders.v1.0"); + assert_eq!( + id, + gts_id!("x.core.events.topic.v1~x.commerce._.orders.v1.0") + ); } #[test] fn test_gts_instance_id_versioned_segment() { // Test with versioned segment let id = EventTopicV1::gts_make_instance_id("x.y.z.instance.v1.0"); - assert_eq!(id, "gts.x.core.events.topic.v1~x.y.z.instance.v1.0"); + assert_eq!(id, gts_id!("x.core.events.topic.v1~x.y.z.instance.v1.0")); let id = ProductV1::gts_make_instance_id("x.y.z.sku.v2.1"); - assert_eq!(id, "gts.x.test.entities.product.v1~x.y.z.sku.v2.1"); + assert_eq!(id, gts_id!("x.test.entities.product.v1~x.y.z.sku.v2.1")); } #[test] fn test_gts_instance_id_empty_segment() { // Edge case: empty segment returns just the schema_id let id = EventTopicV1::gts_make_instance_id(""); - assert_eq!(id, "gts.x.core.events.topic.v1~"); + assert_eq!(id, gts_id!("x.core.events.topic.v1~")); } // ============================================================================= @@ -221,11 +224,11 @@ fn test_gts_instance_id_empty_segment() { fn test_schema_id_constant() { assert_eq!( EventTopicV1::gts_type_id().clone().into_string(), - "gts.x.core.events.topic.v1~" + gts_id!("x.core.events.topic.v1~") ); assert_eq!( ProductV1::gts_type_id().clone().into_string(), - "gts.x.test.entities.product.v1~" + gts_id!("x.test.entities.product.v1~") ); } @@ -269,7 +272,9 @@ fn test_event_topic_serialization() { }; let json = serde_json::to_string(&topic).unwrap(); - assert!(json.contains("gts.x.core.events.topic.v1~x.commerce.orders.orders.v1.0")); + assert!(json.contains(gts_id!( + "x.core.events.topic.v1~x.commerce.orders.orders.v1.0" + ))); assert!(json.contains("orders")); assert!(json.contains("P90D")); } @@ -360,7 +365,7 @@ fn test_product_instance_with_absent_optional_field_validates() { // To properly handle optional fields, use #[serde(skip_serializing_if = "Option::is_none")] // or construct the JSON object without the field. let instance_without_description = serde_json::json!({ - "id": "gts.x.test.entities.product.v1~vendor.package.sku.mouse_abc.v1", + "id": gts_id!("x.test.entities.product.v1~vendor.package.sku.mouse_abc.v1"), "name": "Wireless Mouse", "price": 29.99, "in_stock": false, @@ -532,7 +537,7 @@ fn test_instance_id_appears_in_serialized_output() { // Verify the GTS instance ID is properly set in the serialized output assert_eq!( json_value["id"], - "gts.x.core.events.topic.v1~x.core.idp.contacts.v1" + gts_id!("x.core.events.topic.v1~x.core.idp.contacts.v1") ); } @@ -608,7 +613,7 @@ 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_id!("x.core.events.topic.v1~")); // Verify the ID matches what the macro generates assert_eq!( @@ -648,7 +653,7 @@ fn test_instance_parsed_as_gts_entity() { 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~x.commerce.orders.orders.v1.0" + gts_id!("x.core.events.topic.v1~x.commerce.orders.orders.v1.0") ); } @@ -839,7 +844,7 @@ fn test_gts_entity_strips_uri_prefix_from_schema() { 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!("x.core.events.topic.v1~"), "GTS ID should not contain 'gts://' prefix" ); } @@ -987,7 +992,7 @@ fn test_runtime_schema_inline_resolution() { let base_schema = inheritance_tests::BaseEventV1::<()>::gts_schema_with_refs(); store - .register_schema("gts.x.core.events.type.v1~", &base_schema) + .register_schema(gts_id!("x.core.events.type.v1~"), &base_schema) .unwrap(); // Generate the inlined schema using runtime resolution (only for base type) @@ -1057,7 +1062,7 @@ fn test_runtime_schema_inline_resolution_single_segment() { let event_topic_schema: serde_json::Value = serde_json::from_str(&EventTopicV1::gts_schema_with_refs_as_string()).unwrap(); store - .register_schema("gts.x.core.events.topic.v1~", &event_topic_schema) + .register_schema(gts_id!("x.core.events.topic.v1~"), &event_topic_schema) .unwrap(); // Generate the inlined schema @@ -1081,7 +1086,7 @@ fn test_runtime_schema_inline_resolution_single_segment() { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.test.versioned.minor.v1.0~", + type_id = gts_id!("x.test.versioned.minor.v1.0~"), description = "Test struct with minor version", properties = "id,value" )] @@ -1094,7 +1099,7 @@ pub struct MinorVersionV1_0 { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.test.versioned.complex.v2.5~", + type_id = gts_id!("x.test.versioned.complex.v2.5~"), description = "Test struct with complex minor version", properties = "id,data" )] @@ -1140,11 +1145,11 @@ fn test_version_extraction_underscore_format() { // Test that GtsSchema trait properly exposes the schema ID assert_eq!( MinorVersionV1_0::TYPE_ID, - "gts.x.test.versioned.minor.v1.0~" + gts_id!("x.test.versioned.minor.v1.0~") ); assert_eq!( ComplexMinorV2_5::TYPE_ID, - "gts.x.test.versioned.complex.v2.5~" + gts_id!("x.test.versioned.complex.v2.5~") ); } @@ -1152,7 +1157,7 @@ fn test_version_extraction_underscore_format() { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.test.single.segment.v1~", + type_id = gts_id!("x.test.single.segment.v1~"), description = "Base struct with single segment", properties = "id,name" )] @@ -1191,7 +1196,7 @@ fn test_base_true_single_segment_no_parent() { fn test_base_true_single_segment_schema_id() { // Verify schema ID is properly set let schema_id = SingleSegmentBaseV1::gts_type_id(); - assert_eq!(schema_id.as_ref(), "gts.x.test.single.segment.v1~"); + assert_eq!(schema_id.as_ref(), gts_id!("x.test.single.segment.v1~")); } #[test] @@ -1209,7 +1214,7 @@ fn test_base_true_single_segment_instance_id_generation() { #[test] fn test_gts_schema_trait_impl() { // Verify GtsSchema trait is implemented correctly - assert_eq!(EventTopicV1::TYPE_ID, "gts.x.core.events.topic.v1~"); + assert_eq!(EventTopicV1::TYPE_ID, gts_id!("x.core.events.topic.v1~")); assert_eq!(EventTopicV1::GENERIC_FIELD, None); let schema = EventTopicV1::gts_schema(); @@ -1241,7 +1246,7 @@ fn test_schema_string_methods() { #[test] fn test_instance_json_methods() { let topic = EventTopicV1 { - id: GtsInstanceId::new("gts.x.core.events.topic.v1~", "test.topic.v1"), + id: GtsInstanceId::new(gts_id!("x.core.events.topic.v1~"), "test.topic.v1"), name: "TestTopic".to_string(), description: Some("A test topic".to_string()), retention: "P30D".to_string(), @@ -1265,7 +1270,7 @@ fn test_instance_json_methods() { #[test] fn test_optional_fields_serialization() { let topic = EventTopicV1 { - id: GtsInstanceId::new("gts.x.core.events.topic.v1~", "test.topic.v1"), + id: GtsInstanceId::new(gts_id!("x.core.events.topic.v1~"), "test.topic.v1"), name: "TestTopic".to_string(), description: None, // None value retention: "P30D".to_string(), diff --git a/gts-macros/tests/serde_rename_tests.rs b/gts-macros/tests/serde_rename_tests.rs index 5fec4d9..b051ef6 100644 --- a/gts-macros/tests/serde_rename_tests.rs +++ b/gts-macros/tests/serde_rename_tests.rs @@ -10,7 +10,7 @@ )] use gts::gts::GtsTypeId; -use gts_macros::struct_to_gts_schema; +use gts_macros::{gts_id, struct_to_gts_schema}; use uuid::Uuid; /* ============================================================ @@ -20,7 +20,7 @@ Serde rename tests - event_type field with serde(rename = "type") #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.core.events.type.v1~", + type_id = gts_id!("x.core.events.type.v1~"), description = "Base event type with serde(rename = \"type\")", properties = "event_type,id,tenant_id,sequence_id,payload" )] @@ -37,7 +37,7 @@ pub struct BaseEventV1SerdeRenameV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.core.events.gts_type.v1~", + type_id = gts_id!("x.core.events.gts_type.v1~"), description = "Base event type with serde(rename = \"gts_type\")", properties = "event_type,id,tenant_id,sequence_id,payload" )] @@ -54,7 +54,7 @@ pub struct BaseEventV1GtsTypeRenameV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.core.events.schema.v1~", + type_id = gts_id!("x.core.events.schema.v1~"), description = "Base event type with serde(rename = \"schema\")", properties = "event_type,id,tenant_id,sequence_id,payload" )] @@ -80,21 +80,24 @@ mod tests { fn test_serde_rename_type_compiles() { // This should compile because event_type is renamed to "type" and has correct type let event = BaseEventV1SerdeRenameV1::<()> { - event_type: GtsTypeId::new("gts.x.core.events.type.v1~"), + event_type: GtsTypeId::new(gts_id!("x.core.events.type.v1~")), id: Uuid::new_v4(), tenant_id: Uuid::new_v4(), sequence_id: 12345, payload: (), }; - assert_eq!(event.event_type.to_string(), "gts.x.core.events.type.v1~"); + assert_eq!( + event.event_type.to_string(), + gts_id!("x.core.events.type.v1~") + ); } #[test] fn test_serde_rename_gts_type_compiles() { // This should compile because event_type is renamed to "gts_type" and has correct type let event = BaseEventV1GtsTypeRenameV1::<()> { - event_type: GtsTypeId::new("gts.x.core.events.gts_type.v1~"), + event_type: GtsTypeId::new(gts_id!("x.core.events.gts_type.v1~")), id: Uuid::new_v4(), tenant_id: Uuid::new_v4(), sequence_id: 12345, @@ -103,7 +106,7 @@ mod tests { assert_eq!( event.event_type.to_string(), - "gts.x.core.events.gts_type.v1~" + gts_id!("x.core.events.gts_type.v1~") ); } @@ -111,14 +114,17 @@ mod tests { fn test_serde_rename_schema_compiles() { // This should compile because event_type is renamed to "schema" and has correct type let event = BaseEventV1SchemaRenameV1::<()> { - event_type: GtsTypeId::new("gts.x.core.events.schema.v1~"), + event_type: GtsTypeId::new(gts_id!("x.core.events.schema.v1~")), id: Uuid::new_v4(), tenant_id: Uuid::new_v4(), sequence_id: 12345, payload: (), }; - assert_eq!(event.event_type.to_string(), "gts.x.core.events.schema.v1~"); + assert_eq!( + event.event_type.to_string(), + gts_id!("x.core.events.schema.v1~") + ); } #[test] @@ -128,19 +134,19 @@ mod tests { BaseEventV1SerdeRenameV1::<()>::gts_type_id() .clone() .into_string(), - "gts.x.core.events.type.v1~" + gts_id!("x.core.events.type.v1~") ); assert_eq!( BaseEventV1GtsTypeRenameV1::<()>::gts_type_id() .clone() .into_string(), - "gts.x.core.events.gts_type.v1~" + gts_id!("x.core.events.gts_type.v1~") ); assert_eq!( BaseEventV1SchemaRenameV1::<()>::gts_type_id() .clone() .into_string(), - "gts.x.core.events.schema.v1~" + gts_id!("x.core.events.schema.v1~") ); } @@ -148,7 +154,7 @@ mod tests { fn test_serde_rename_serialization() { // Test that serialization works correctly with serde rename let event = BaseEventV1SerdeRenameV1::<()> { - event_type: GtsTypeId::new("gts.x.core.events.type.v1~"), + event_type: GtsTypeId::new(gts_id!("x.core.events.type.v1~")), id: Uuid::new_v4(), tenant_id: Uuid::new_v4(), sequence_id: 12345, @@ -161,14 +167,14 @@ mod tests { // The field should be serialized as "type" (not "event_type") assert!(parsed.get("type").is_some()); assert!(parsed.get("event_type").is_none()); - assert_eq!(parsed["type"], "gts.x.core.events.type.v1~"); + assert_eq!(parsed["type"], gts_id!("x.core.events.type.v1~")); } #[test] fn test_serde_rename_gts_type_serialization() { // Test that serialization works correctly with serde rename to gts_type let event = BaseEventV1GtsTypeRenameV1::<()> { - event_type: GtsTypeId::new("gts.x.core.events.gts_type.v1~"), + event_type: GtsTypeId::new(gts_id!("x.core.events.gts_type.v1~")), id: Uuid::new_v4(), tenant_id: Uuid::new_v4(), sequence_id: 12345, @@ -181,14 +187,14 @@ mod tests { // The field should be serialized as "gts_type" (not "event_type") assert!(parsed.get("gts_type").is_some()); assert!(parsed.get("event_type").is_none()); - assert_eq!(parsed["gts_type"], "gts.x.core.events.gts_type.v1~"); + assert_eq!(parsed["gts_type"], gts_id!("x.core.events.gts_type.v1~")); } #[test] fn test_serde_rename_schema_serialization() { // Test that serialization works correctly with serde rename to schema let event = BaseEventV1SchemaRenameV1::<()> { - event_type: GtsTypeId::new("gts.x.core.events.schema.v1~"), + event_type: GtsTypeId::new(gts_id!("x.core.events.schema.v1~")), id: Uuid::new_v4(), tenant_id: Uuid::new_v4(), sequence_id: 12345, @@ -201,6 +207,6 @@ mod tests { // The field should be serialized as "schema" (not "event_type") assert!(parsed.get("schema").is_some()); assert!(parsed.get("event_type").is_none()); - assert_eq!(parsed["schema"], "gts.x.core.events.schema.v1~"); + assert_eq!(parsed["schema"], gts_id!("x.core.events.schema.v1~")); } } diff --git a/gts-macros/tests/traits_tests.rs b/gts-macros/tests/traits_tests.rs index 93de5e5..a58fe4d 100644 --- a/gts-macros/tests/traits_tests.rs +++ b/gts-macros/tests/traits_tests.rs @@ -8,16 +8,18 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use gts::{GtsInstanceId, GtsSchema}; -use gts_macros::{GtsTraitsSchema, struct_to_gts_schema}; +use gts_macros::{GtsTraitsSchema, gts_id, struct_to_gts_schema}; use schemars::JsonSchema; fn default_retention() -> String { "P30D".to_owned() } +const TOPIC_REF: &str = gts_id!("x.core.events.topic.v1~"); + #[derive(JsonSchema, serde::Serialize, GtsTraitsSchema)] pub struct EventTraits { - #[schemars(extend("x-gts-ref" = "gts.x.core.events.topic.v1~"))] + #[schemars(extend("x-gts-ref" = TOPIC_REF))] pub topic_ref: String, #[serde(default = "default_retention")] pub retention: String, @@ -26,7 +28,7 @@ pub struct EventTraits { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.test.behav.event.v1~", + type_id = gts_id!("x.test.behav.event.v1~"), description = "Base event", properties = "id,payload", traits_schema = inline(EventTraits), @@ -41,11 +43,11 @@ pub struct EventV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = EventV1, - type_id = "gts.x.test.behav.event.v1~x.test.order.placed.v1~", + type_id = gts_id!("x.test.behav.event.v1~x.test.order.placed.v1~"), description = "Order placed", properties = "order_id", traits = serde_json::json!({ - "topic_ref": "gts.x.core.events.topic.v1~x.test._.orders.v1" + "topic_ref": gts_id!("x.core.events.topic.v1~x.test._.orders.v1") }), gts_final = true, )] @@ -69,7 +71,7 @@ pub struct OrderPlacedV1 { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.test.behav.req.v1~", + type_id = gts_id!("x.test.behav.req.v1~"), description = "Non-abstract base with an unresolved required trait", properties = "id,payload", traits_schema = inline(EventTraits), @@ -85,7 +87,7 @@ pub struct ReqV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.test.behav.absreq.v1~", + type_id = gts_id!("x.test.behav.absreq.v1~"), description = "Abstract base with a required trait and no values", properties = "id,payload", traits_schema = inline(EventTraits), @@ -102,7 +104,7 @@ pub struct AbsReqV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = AbsReqV1, - type_id = "gts.x.test.behav.absreq.v1~x.test.absreq.leaf.v1~", + type_id = gts_id!("x.test.behav.absreq.v1~x.test.absreq.leaf.v1~"), description = "Concrete leaf leaving an inherited required trait unresolved", properties = "name", )] @@ -125,7 +127,7 @@ pub struct RetentionInt { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.test.behav.compat.v1~", + type_id = gts_id!("x.test.behav.compat.v1~"), description = "Base declaring retention as a string trait", properties = "id,payload", traits_schema = inline(RetentionString), @@ -143,7 +145,7 @@ pub struct CompatV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = CompatV1, - type_id = "gts.x.test.behav.compat.v1~x.test.compat.bad.v1~", + type_id = gts_id!("x.test.behav.compat.v1~x.test.compat.bad.v1~"), description = "Derived type whose trait schema contradicts the parent's", properties = "name", traits_schema = inline(RetentionInt), @@ -166,7 +168,7 @@ pub struct IndexedTraits { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.test.behav.const.v1~", + type_id = gts_id!("x.test.behav.const.v1~"), description = "Base locking the indexed trait to true", properties = "id,payload", traits_schema = inline(IndexedTraits), @@ -183,7 +185,7 @@ pub struct ConstBaseV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = ConstBaseV1, - type_id = "gts.x.test.behav.const.v1~x.test.const.bad.v1~", + type_id = gts_id!("x.test.behav.const.v1~x.test.const.bad.v1~"), description = "Leaf overriding a const-locked trait", properties = "name", traits = serde_json::json!({ "indexed": false }), @@ -208,7 +210,7 @@ pub struct DefaultedTraits { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.test.behav.defmat.v1~", + type_id = gts_id!("x.test.behav.defmat.v1~"), description = "Concrete base relying on a trait default for completeness", properties = "id", traits_schema = inline(DefaultedTraits), @@ -223,7 +225,7 @@ pub struct DefaultBaseV1 { #[derive(JsonSchema, serde::Serialize, GtsTraitsSchema)] #[schemars(extend("additionalProperties" = false))] pub struct ClosedTraits { - #[schemars(extend("x-gts-ref" = "gts.x.core.events.topic.v1~"))] + #[schemars(extend("x-gts-ref" = TOPIC_REF))] pub topic_ref: String, } @@ -236,7 +238,7 @@ pub struct ExtraTraits { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.test.behav.closed.v1~", + type_id = gts_id!("x.test.behav.closed.v1~"), description = "Base with a closed trait surface", properties = "id,payload", traits_schema = inline(ClosedTraits), @@ -254,12 +256,12 @@ pub struct ClosedBaseV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = ClosedBaseV1, - type_id = "gts.x.test.behav.closed.v1~x.test.extend.bad.v1~", + type_id = gts_id!("x.test.behav.closed.v1~x.test.extend.bad.v1~"), description = "Leaf extending a closed trait surface with a new property", properties = "name", traits_schema = inline(ExtraTraits), traits = serde_json::json!({ - "topic_ref": "gts.x.core.events.topic.v1~x.test._.orders.v1", + "topic_ref": gts_id!("x.core.events.topic.v1~x.test._.orders.v1"), "extra": "nope" }), )] @@ -286,7 +288,7 @@ pub struct NarrowPriorityTraits { #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.test.behav.narrow.v1~", + type_id = gts_id!("x.test.behav.narrow.v1~"), description = "Base declaring an open-string priority trait", properties = "id,payload", traits_schema = inline(BasePriorityTraits), @@ -304,7 +306,7 @@ pub struct NarrowBaseV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = NarrowBaseV1, - type_id = "gts.x.test.behav.narrow.v1~x.test.urgent.ok.v1~", + type_id = gts_id!("x.test.behav.narrow.v1~x.test.urgent.ok.v1~"), description = "Leaf narrowing priority and supplying a valid value", properties = "order_id", traits_schema = inline(NarrowPriorityTraits), @@ -322,7 +324,7 @@ pub struct NarrowOkLeafV1 { #[struct_to_gts_schema( dir_path = "schemas", base = NarrowBaseV1, - type_id = "gts.x.test.behav.narrow.v1~x.test.urgent.bad.v1~", + type_id = gts_id!("x.test.behav.narrow.v1~x.test.urgent.bad.v1~"), description = "Leaf narrowing priority but supplying an out-of-enum value", properties = "order_id", traits_schema = inline(NarrowPriorityTraits), @@ -451,7 +453,7 @@ fn trait_accessors_return_the_declared_values() { let event_ts = json!({ "type": "object", "properties": { - "topic_ref": { "type": "string", "x-gts-ref": "gts.x.core.events.topic.v1~" }, + "topic_ref": { "type": "string", "x-gts-ref": gts_id!("x.core.events.topic.v1~") }, "retention": { "type": "string", "default": "P30D" } }, "required": ["topic_ref"] @@ -466,7 +468,7 @@ fn trait_accessors_return_the_declared_values() { ); // Leaf `OrderPlacedV1`: resolves `traits` values, declares no local schema. - let order_traits = json!({ "topic_ref": "gts.x.core.events.topic.v1~x.test._.orders.v1" }); + let order_traits = json!({ "topic_ref": gts_id!("x.core.events.topic.v1~x.test._.orders.v1") }); assert_eq!(OrderPlacedV1::gts_traits(), Some(order_traits.clone())); assert_eq!(OrderPlacedV1::gts_traits_schema(), None); assert_eq!( diff --git a/gts-macros/tests/value_dispatch_tests.rs b/gts-macros/tests/value_dispatch_tests.rs index 0fa05f1..fdc1144 100644 --- a/gts-macros/tests/value_dispatch_tests.rs +++ b/gts-macros/tests/value_dispatch_tests.rs @@ -22,7 +22,7 @@ use gts::gts::GtsTypeId; use gts::{GtsSchema, NarrowError, try_narrow}; -use gts_macros::struct_to_gts_schema; +use gts_macros::{gts_id, struct_to_gts_schema}; // ============================================================================= // Type hierarchy @@ -36,7 +36,7 @@ use gts_macros::struct_to_gts_schema; #[struct_to_gts_schema( dir_path = "schemas", base = true, - type_id = "gts.x.test.value_dispatch.envelope.v1~", + type_id = gts_id!("x.test.value_dispatch.envelope.v1~"), description = "EnvelopeV1 carrying an opaque (default) or typed payload", properties = "gts_type,payload" )] @@ -51,7 +51,7 @@ pub struct EnvelopeV1

{ #[struct_to_gts_schema( dir_path = "schemas", base = EnvelopeV1, - type_id = "gts.x.test.value_dispatch.envelope.v1~x.test.value_dispatch.alpha.v1~", + type_id = gts_id!("x.test.value_dispatch.envelope.v1~x.test.value_dispatch.alpha.v1~"), description = "Alpha leaf — directly under EnvelopeV1", properties = "alpha_data" )] @@ -65,7 +65,7 @@ pub struct AlphaLeafV1 { #[struct_to_gts_schema( dir_path = "schemas", base = EnvelopeV1, - type_id = "gts.x.test.value_dispatch.envelope.v1~x.test.value_dispatch.gamma.v1~", + type_id = gts_id!("x.test.value_dispatch.envelope.v1~x.test.value_dispatch.gamma.v1~"), description = "Gamma leaf — directly under EnvelopeV1, different shape from Alpha", properties = "gamma_count,gamma_flag" )] @@ -80,7 +80,7 @@ pub struct GammaLeafV1 { #[struct_to_gts_schema( dir_path = "schemas", base = EnvelopeV1, - type_id = "gts.x.test.value_dispatch.envelope.v1~x.test.value_dispatch.intermediate.v1~", + type_id = gts_id!("x.test.value_dispatch.envelope.v1~x.test.value_dispatch.intermediate.v1~"), description = "IntermediateV1 node — common fields plus a generic `extension` for level-3 leaves", properties = "common_label,extension" )] @@ -95,7 +95,7 @@ pub struct IntermediateV1 { #[struct_to_gts_schema( dir_path = "schemas", base = IntermediateV1, - type_id = "gts.x.test.value_dispatch.envelope.v1~x.test.value_dispatch.intermediate.v1~x.test.value_dispatch.beta.v1~", + type_id = gts_id!("x.test.value_dispatch.envelope.v1~x.test.value_dispatch.intermediate.v1~x.test.value_dispatch.beta.v1~"), description = "Beta leaf — under IntermediateV1, 3 segments deep", properties = "beta_value" )] @@ -113,12 +113,14 @@ mod schema_id_contract { use super::*; const ALPHA_CHAIN: &str = - "gts.x.test.value_dispatch.envelope.v1~x.test.value_dispatch.alpha.v1~"; + gts_id!("x.test.value_dispatch.envelope.v1~x.test.value_dispatch.alpha.v1~"); const GAMMA_CHAIN: &str = - "gts.x.test.value_dispatch.envelope.v1~x.test.value_dispatch.gamma.v1~"; + gts_id!("x.test.value_dispatch.envelope.v1~x.test.value_dispatch.gamma.v1~"); const INTERMEDIATE_CHAIN: &str = - "gts.x.test.value_dispatch.envelope.v1~x.test.value_dispatch.intermediate.v1~"; - const BETA_CHAIN: &str = "gts.x.test.value_dispatch.envelope.v1~x.test.value_dispatch.intermediate.v1~x.test.value_dispatch.beta.v1~"; + gts_id!("x.test.value_dispatch.envelope.v1~x.test.value_dispatch.intermediate.v1~"); + const BETA_CHAIN: &str = gts_id!( + "x.test.value_dispatch.envelope.v1~x.test.value_dispatch.intermediate.v1~x.test.value_dispatch.beta.v1~" + ); #[test] fn unit_and_value_are_both_empty_id_placeholders() { @@ -135,16 +137,16 @@ mod schema_id_contract { // — i.e. nothing further is known about the leaf at type level. assert_eq!( as GtsSchema>::TYPE_ID, - "gts.x.test.value_dispatch.envelope.v1~" + gts_id!("x.test.value_dispatch.envelope.v1~") ); assert_eq!( as GtsSchema>::innermost_type_id(), - "gts.x.test.value_dispatch.envelope.v1~", + gts_id!("x.test.value_dispatch.envelope.v1~"), "Value-tail innermost is the envelope's own literal" ); assert_eq!( as GtsSchema>::innermost_type_id(), - "gts.x.test.value_dispatch.envelope.v1~", + gts_id!("x.test.value_dispatch.envelope.v1~"), "(): same protocol -- empty tail collapses to the envelope's literal" ); } @@ -161,7 +163,7 @@ mod schema_id_contract { // innermost walks the chain and returns AlphaLeafV1's id. assert_eq!( as GtsSchema>::TYPE_ID, - "gts.x.test.value_dispatch.envelope.v1~" + gts_id!("x.test.value_dispatch.envelope.v1~") ); assert_eq!( as GtsSchema>::innermost_type_id(), @@ -223,7 +225,7 @@ mod deserialisation { fn envelope_of_value_round_trips_arbitrary_payload() { // Wire shape: gts_type discriminator + opaque JSON payload. let wire = serde_json::json!({ - "gts_type": "gts.x.test.value_dispatch.envelope.v1~x.test.value_dispatch.alpha.v1~", + "gts_type": gts_id!("x.test.value_dispatch.envelope.v1~x.test.value_dispatch.alpha.v1~"), "payload": { "alpha_data": "hello" } }); @@ -232,7 +234,7 @@ mod deserialisation { serde_json::from_value(wire.clone()).expect("deserialize EnvelopeV1"); assert_eq!( envelope.gts_type.as_ref(), - "gts.x.test.value_dispatch.envelope.v1~x.test.value_dispatch.alpha.v1~" + gts_id!("x.test.value_dispatch.envelope.v1~x.test.value_dispatch.alpha.v1~") ); assert_eq!( envelope.payload, @@ -254,9 +256,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.thing.v1~", - ), + gts_type: GtsTypeId::new(gts_id!( + "x.test.value_dispatch.envelope.v1~x.test.unknown.thing.v1~" + )), payload: serde_json::json!({ "anything": ["the", "future", 42, null, { "nested": true }] }), @@ -340,10 +342,12 @@ mod dispatch { #[test] fn dispatches_heterogeneous_batch_across_2_and_3_level_chains() { const ALPHA_CHAIN: &str = - "gts.x.test.value_dispatch.envelope.v1~x.test.value_dispatch.alpha.v1~"; + gts_id!("x.test.value_dispatch.envelope.v1~x.test.value_dispatch.alpha.v1~"); const GAMMA_CHAIN: &str = - "gts.x.test.value_dispatch.envelope.v1~x.test.value_dispatch.gamma.v1~"; - const BETA_CHAIN: &str = "gts.x.test.value_dispatch.envelope.v1~x.test.value_dispatch.intermediate.v1~x.test.value_dispatch.beta.v1~"; + gts_id!("x.test.value_dispatch.envelope.v1~x.test.value_dispatch.gamma.v1~"); + const BETA_CHAIN: &str = gts_id!( + "x.test.value_dispatch.envelope.v1~x.test.value_dispatch.intermediate.v1~x.test.value_dispatch.beta.v1~" + ); // A batch with two direct leaves, one 3-level chain, and one // gts_type the dispatcher was not built against — exactly the @@ -351,17 +355,17 @@ mod dispatch { // catalog with mixed providers. let inputs: Vec = vec![ serde_json::json!({ - "gts_type": "gts.x.test.value_dispatch.envelope.v1~x.test.value_dispatch.alpha.v1~", + "gts_type": gts_id!("x.test.value_dispatch.envelope.v1~x.test.value_dispatch.alpha.v1~"), "payload": { "alpha_data": "first" } }), serde_json::json!({ - "gts_type": "gts.x.test.value_dispatch.envelope.v1~x.test.value_dispatch.gamma.v1~", + "gts_type": gts_id!("x.test.value_dispatch.envelope.v1~x.test.value_dispatch.gamma.v1~"), "payload": { "gamma_count": 7, "gamma_flag": true } }), // 3-level: payload contains intermediate's common_label plus // the leaf nested in `extension` (the generic-field path). serde_json::json!({ - "gts_type": "gts.x.test.value_dispatch.envelope.v1~x.test.value_dispatch.intermediate.v1~x.test.value_dispatch.beta.v1~", + "gts_type": gts_id!("x.test.value_dispatch.envelope.v1~x.test.value_dispatch.intermediate.v1~x.test.value_dispatch.beta.v1~"), "payload": { "common_label": "shared", "extension": { "beta_value": 99 } @@ -370,7 +374,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_id!("x.test.value_dispatch.envelope.v1~x.test.unmodelled.future.v1~"), "payload": { "anything": "goes" } }), ]; @@ -417,7 +421,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_id!("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" })); @@ -432,7 +436,7 @@ mod dispatch { // accessible WITHOUT narrowing — that's the whole point of // keeping Value as the default `P`. let wire = serde_json::json!({ - "gts_type": "gts.x.test.value_dispatch.envelope.v1~x.test.value_dispatch.alpha.v1~", + "gts_type": gts_id!("x.test.value_dispatch.envelope.v1~x.test.value_dispatch.alpha.v1~"), "payload": { "alpha_data": "no narrowing needed" } }); let envelope: EnvelopeV1 = serde_json::from_value(wire).unwrap(); @@ -460,8 +464,10 @@ mod narrow_helper { use super::*; const ALPHA_CHAIN: &str = - "gts.x.test.value_dispatch.envelope.v1~x.test.value_dispatch.alpha.v1~"; - const BETA_CHAIN: &str = "gts.x.test.value_dispatch.envelope.v1~x.test.value_dispatch.intermediate.v1~x.test.value_dispatch.beta.v1~"; + gts_id!("x.test.value_dispatch.envelope.v1~x.test.value_dispatch.alpha.v1~"); + const BETA_CHAIN: &str = gts_id!( + "x.test.value_dispatch.envelope.v1~x.test.value_dispatch.intermediate.v1~x.test.value_dispatch.beta.v1~" + ); #[test] fn try_narrow_succeeds_on_matching_chain_for_2_level_leaf() { diff --git a/gts/src/ops.rs b/gts/src/ops.rs index 9708bb6..97391a5 100644 --- a/gts/src/ops.rs +++ b/gts/src/ops.rs @@ -227,6 +227,7 @@ impl GtsOps { } // Try default path (relative to current directory) + #[allow(unknown_lints, gts_id_hardcoded_prefix)] let default_path = PathBuf::from("gts.config.json"); if let Ok(cfg) = Self::load_config_from_path(&default_path) { return cfg;