gts/
entities.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::HashMap;
4
5use crate::gts::{GtsID, GTS_URI_PREFIX};
6use crate::path_resolver::JsonPathResolver;
7use crate::schema_cast::{GtsEntityCastResult, SchemaCastError};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ValidationError {
11    #[serde(rename = "instancePath")]
12    pub instance_path: String,
13    #[serde(rename = "schemaPath")]
14    pub schema_path: String,
15    pub keyword: String,
16    pub message: String,
17    pub params: HashMap<String, Value>,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub data: Option<Value>,
20}
21
22#[derive(Debug, Clone, Default, Serialize, Deserialize)]
23pub struct ValidationResult {
24    pub errors: Vec<ValidationError>,
25}
26
27#[derive(Debug, Clone)]
28pub struct GtsFile {
29    pub path: String,
30    pub name: String,
31    pub content: Value,
32    pub sequences_count: usize,
33    pub sequence_content: HashMap<usize, Value>,
34    pub validation: ValidationResult,
35}
36
37impl GtsFile {
38    #[must_use]
39    pub fn new(path: String, name: String, content: Value) -> Self {
40        let sequence_content: HashMap<usize, Value> = if let Some(arr) = content.as_array() {
41            arr.iter()
42                .enumerate()
43                .map(|(i, v)| (i, v.clone()))
44                .collect()
45        } else {
46            [(0, content.clone())].into_iter().collect()
47        };
48        let sequences_count = sequence_content.len();
49
50        GtsFile {
51            path,
52            name,
53            content,
54            sequences_count,
55            sequence_content,
56            validation: ValidationResult::default(),
57        }
58    }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct GtsConfig {
63    pub entity_id_fields: Vec<String>,
64    pub schema_id_fields: Vec<String>,
65}
66
67impl Default for GtsConfig {
68    fn default() -> Self {
69        GtsConfig {
70            entity_id_fields: vec![
71                "$id".to_owned(),
72                "gtsId".to_owned(),
73                "gtsIid".to_owned(),
74                "gtsOid".to_owned(),
75                "gtsI".to_owned(),
76                "gts_id".to_owned(),
77                "gts_oid".to_owned(),
78                "gts_iid".to_owned(),
79                "id".to_owned(),
80            ],
81            schema_id_fields: vec![
82                "gtsTid".to_owned(),
83                "gtsType".to_owned(),
84                "gtsT".to_owned(),
85                "gts_t".to_owned(),
86                "gts_tid".to_owned(),
87                "gts_type".to_owned(),
88                "type".to_owned(),
89                "schema".to_owned(),
90            ],
91        }
92    }
93}
94
95#[derive(Debug, Clone)]
96pub struct GtsRef {
97    pub id: String,
98    pub source_path: String,
99}
100
101#[derive(Debug, Clone)]
102pub struct GtsEntity {
103    /// The GTS ID if the entity has one (either from `id` field for well-known instances,
104    /// or from `$id` field for schemas). None for anonymous instances.
105    pub gts_id: Option<GtsID>,
106    /// The instance ID - for anonymous instances this is the UUID from `id` field,
107    /// for well-known instances this equals `gts_id.id`, for schemas this equals `gts_id.id`.
108    pub instance_id: Option<String>,
109    /// True if this is a JSON Schema (has `$schema` field), false if it's an instance.
110    pub is_schema: bool,
111    pub file: Option<GtsFile>,
112    pub list_sequence: Option<usize>,
113    pub label: String,
114    pub content: Value,
115    pub gts_refs: Vec<GtsRef>,
116    pub validation: ValidationResult,
117    /// The schema ID that this entity conforms to:
118    /// - For schemas: the `$schema` field value (e.g., `http://json-schema.org/draft-07/schema#`)
119    ///   OR for GTS schemas, the parent schema from the chain
120    /// - For instances: the `type` field value (the GTS type ID ending with `~`)
121    pub schema_id: Option<String>,
122    pub selected_entity_field: Option<String>,
123    pub selected_schema_id_field: Option<String>,
124    pub description: String,
125    pub schema_refs: Vec<GtsRef>,
126}
127
128impl GtsEntity {
129    #[allow(clippy::too_many_arguments)]
130    #[must_use]
131    pub fn new(
132        file: Option<GtsFile>,
133        list_sequence: Option<usize>,
134        content: &Value,
135        cfg: Option<&GtsConfig>,
136        gts_id: Option<GtsID>,
137        is_schema: bool,
138        label: String,
139        validation: Option<ValidationResult>,
140        schema_id: Option<String>,
141    ) -> Self {
142        let mut entity = GtsEntity {
143            file,
144            list_sequence,
145            content: content.clone(),
146            gts_id,
147            instance_id: None,
148            is_schema,
149            label,
150            validation: validation.unwrap_or_default(),
151            schema_id,
152            selected_entity_field: None,
153            selected_schema_id_field: None,
154            gts_refs: Vec::new(),
155            schema_refs: Vec::new(),
156            description: String::new(),
157        };
158
159        // RULE: A JSON is a schema if and only if it has a "$schema" field
160        // This is the PRIMARY check - $schema presence is the definitive marker
161        entity.is_schema = entity.has_schema_field();
162
163        // Calculate IDs if config provided
164        if let Some(cfg) = cfg {
165            if entity.is_schema {
166                // For schemas: extract GTS ID from $id field
167                entity.extract_schema_ids(cfg);
168            } else {
169                // For instances: extract instance_id and schema_id separately
170                entity.extract_instance_ids(cfg);
171            }
172        }
173
174        // Set label
175        if let Some(ref file) = entity.file {
176            if let Some(seq) = entity.list_sequence {
177                entity.label = format!("{}#{seq}", file.name);
178            } else {
179                entity.label = file.name.clone();
180            }
181        } else if let Some(ref instance_id) = entity.instance_id {
182            entity.label = instance_id.clone();
183        } else if let Some(ref gts_id) = entity.gts_id {
184            entity.label = gts_id.id.clone();
185        } else if entity.label.is_empty() {
186            entity.label = String::new();
187        }
188
189        // Extract description
190        if let Some(obj) = content.as_object() {
191            if let Some(desc) = obj.get("description") {
192                if let Some(s) = desc.as_str() {
193                    s.clone_into(&mut entity.description);
194                }
195            }
196        }
197
198        // Extract references
199        entity.gts_refs = entity.extract_gts_ids_with_paths();
200        if entity.is_schema {
201            entity.schema_refs = entity.extract_ref_strings_with_paths();
202        }
203
204        entity
205    }
206
207    /// Check if the JSON has a "$schema" field - this is the ONLY way to determine if it's a schema.
208    /// Per GTS spec: "if json has "$schema" - it's a schema, always. Otherwise, it's instance, always!"
209    fn has_schema_field(&self) -> bool {
210        if let Some(obj) = self.content.as_object() {
211            if let Some(schema_val) = obj.get("$schema") {
212                if let Some(schema_str) = schema_val.as_str() {
213                    return !schema_str.is_empty();
214                }
215            }
216        }
217        false
218    }
219
220    /// Extract IDs for a schema entity.
221    /// - `gts_id`: from `$id` field (must be `gts://` URI with GTS ID)
222    /// - `schema_id`: the parent schema (from `$schema` field or extracted from chain)
223    /// - `instance_id`: same as `gts_id` for schemas
224    fn extract_schema_ids(&mut self, cfg: &GtsConfig) {
225        // Extract GTS ID from $id field
226        if let Some(obj) = self.content.as_object() {
227            if let Some(id_val) = obj.get("$id") {
228                if let Some(id_str) = id_val.as_str() {
229                    let trimmed = id_str.trim();
230
231                    // Validate that schema $id uses gts:// URI format, not plain gts. prefix
232                    // According to spec: "Do not place the canonical gts. string directly in $id"
233                    if trimmed.starts_with("gts.") {
234                        // This is invalid - schemas must use gts:// URI format
235                        // We'll leave gts_id as None, which will cause registration to fail
236                        return;
237                    }
238
239                    let normalized = trimmed.strip_prefix(GTS_URI_PREFIX).unwrap_or(trimmed);
240                    if GtsID::is_valid(normalized) {
241                        self.gts_id = GtsID::new(normalized).ok();
242                        self.instance_id = Some(normalized.to_owned());
243                        self.selected_entity_field = Some("$id".to_owned());
244                    }
245                }
246            }
247
248            // For schemas, schema_id is the $schema field value
249            // OR for GTS schemas with chains, it's the parent type
250            if let Some(schema_val) = obj.get("$schema") {
251                if let Some(schema_str) = schema_val.as_str() {
252                    self.schema_id = Some(schema_str.to_owned());
253                    self.selected_schema_id_field = Some("$schema".to_owned());
254                }
255            }
256
257            // For chained GTS IDs, extract the parent schema from the chain
258            if let Some(ref gts_id) = self.gts_id {
259                if gts_id.gts_id_segments.len() > 1 {
260                    // Build parent schema ID from all segments except the last
261                    // Each segment.segment already includes the ~ suffix if it's a type
262                    let parent_segments: Vec<&str> = gts_id
263                        .gts_id_segments
264                        .iter()
265                        .take(gts_id.gts_id_segments.len() - 1)
266                        .map(|seg| seg.segment.as_str())
267                        .collect();
268                    if !parent_segments.is_empty() {
269                        // Join segments - they already have ~ at the end if they're types
270                        // The full chain format is: gts.seg1~seg2~seg3~
271                        // For parent, we want: gts.seg1~ (if only one parent segment)
272                        // or gts.seg1~seg2~ (if multiple parent segments)
273                        let parent_id = format!("gts.{}", parent_segments.join("~"));
274                        // Ensure it ends with ~ (parent is always a schema)
275                        let parent_id = if parent_id.ends_with('~') {
276                            parent_id
277                        } else {
278                            format!("{parent_id}~")
279                        };
280                        // Use parent as schema_id if $schema is a standard JSON Schema URL
281                        if self
282                            .schema_id
283                            .as_ref()
284                            .is_some_and(|s| s.starts_with("http"))
285                        {
286                            self.schema_id = Some(parent_id);
287                        }
288                    }
289                }
290            }
291        }
292
293        // Fallback to old logic for entity_id_fields if $id not found
294        if self.gts_id.is_none() {
295            let idv = self.calc_json_entity_id_legacy(cfg);
296            if let Some(ref id) = idv {
297                if GtsID::is_valid(id) {
298                    self.gts_id = GtsID::new(id).ok();
299                    self.instance_id = Some(id.clone());
300                }
301            }
302        }
303    }
304
305    /// Extract IDs for an instance entity.
306    /// There are two types of instances:
307    /// 1. Well-known instances: id field contains a GTS ID (e.g., gts.x.core.events.topic.v1~x.commerce._.orders.v1.0)
308    /// 2. Anonymous instances: id field contains a UUID, type field contains the GTS schema ID
309    ///
310    /// For `schema_id` resolution, explicit `type` field takes priority over the chain-derived schema.
311    /// This allows overriding the implicit parent schema from a chained ID.
312    fn extract_instance_ids(&mut self, cfg: &GtsConfig) {
313        // Only process if content is an object
314        if self.content.as_object().is_none() {
315            return;
316        }
317
318        // First, try to get the id field value (could be UUID or GTS ID)
319        let id_value = self.get_id_field_value(cfg);
320
321        // Check if id is a valid GTS ID (well-known instance)
322        if let Some(ref id) = id_value {
323            if GtsID::is_valid(id) {
324                // Well-known instance: id IS the GTS ID
325                self.gts_id = GtsID::new(id).ok();
326                self.instance_id = Some(id.clone());
327
328                // PRIORITY 1: Extract schema from chained ID (always takes priority)
329                // For well-known instances with CHAINED IDs (multiple segments),
330                // extract schema from the chain. A chained ID has more than one
331                // segment.
332                // Example: gts.x.core.events.type.v1~abc.app._.custom_event.v1.2
333                //          has 2 segments, so schema_id = gts.x.core.events.type.v1~
334                // But: gts.v123.p456.n789.t000.v999.888~ has only 1 segment,
335                //      so we can't determine its schema (it IS a schema ID)
336                if let Some(ref gts_id) = self.gts_id {
337                    // Only extract schema_id if there are multiple segments
338                    if gts_id.gts_id_segments.len() > 1 {
339                        // Extract schema ID: everything up to and including last ~
340                        // For a 2-segment chain, this gives first segment (parent)
341                        if let Some(last_tilde) = gts_id.id.rfind('~') {
342                            self.schema_id = Some(gts_id.id[..=last_tilde].to_string());
343                            // Mark that schema_id was extracted from the id field
344                            self.selected_schema_id_field = self.selected_entity_field.clone();
345                        }
346                    }
347                }
348            } else {
349                // Anonymous instance: id is a UUID or other non-GTS identifier
350                self.instance_id = Some(id.clone());
351                self.gts_id = None; // Anonymous instances don't have a GTS ID
352            }
353        }
354
355        // PRIORITY 2: Fall back to explicit type field (only if no chain-derived)
356        // For anonymous instances or well-known instances without chained IDs,
357        // check for explicit type/gtsTid fields.
358        if self.schema_id.is_none() {
359            self.schema_id = self.get_type_field_value(cfg);
360        }
361
362        // If still no instance_id, fall back to file path
363        if self.instance_id.is_none() {
364            if let Some(ref file) = self.file {
365                if let Some(seq) = self.list_sequence {
366                    self.instance_id = Some(format!("{}#{}", file.path, seq));
367                } else {
368                    self.instance_id = Some(file.path.clone());
369                }
370            }
371        }
372    }
373
374    /// Get the id field value from `entity_id_fields` config
375    fn get_id_field_value(&mut self, cfg: &GtsConfig) -> Option<String> {
376        for f in &cfg.entity_id_fields {
377            // Skip $schema and type fields - they're not entity IDs
378            if f == "$schema" || f == "type" {
379                continue;
380            }
381            if let Some(v) = self.get_field_value(f) {
382                self.selected_entity_field = Some(f.clone());
383                return Some(v);
384            }
385        }
386        None
387    }
388
389    /// Get the type/schema field value from `schema_id_fields` config
390    fn get_type_field_value(&mut self, cfg: &GtsConfig) -> Option<String> {
391        for f in &cfg.schema_id_fields {
392            // Skip $schema for instances - it's not a valid field for instances
393            if f == "$schema" {
394                continue;
395            }
396            if let Some(v) = self.get_field_value(f) {
397                // Only accept valid GTS type IDs (ending with ~)
398                if GtsID::is_valid(&v) && v.ends_with('~') {
399                    self.selected_schema_id_field = Some(f.clone());
400                    return Some(v);
401                }
402            }
403        }
404        None
405    }
406
407    /// Legacy method for backwards compatibility
408    fn calc_json_entity_id_legacy(&mut self, cfg: &GtsConfig) -> Option<String> {
409        self.first_non_empty_field(&cfg.entity_id_fields)
410    }
411
412    #[must_use]
413    pub fn resolve_path(&self, path: &str) -> JsonPathResolver {
414        let gts_id = self
415            .gts_id
416            .as_ref()
417            .map(|g| g.id.clone())
418            .unwrap_or_default();
419        JsonPathResolver::new(gts_id, self.content.clone()).resolve(path)
420    }
421
422    /// Casts this entity to a different schema.
423    ///
424    /// # Errors
425    /// Returns `SchemaCastError` if the cast fails.
426    pub fn cast(
427        &self,
428        to_schema: &GtsEntity,
429        from_schema: &GtsEntity,
430        resolver: Option<&()>,
431    ) -> Result<GtsEntityCastResult, SchemaCastError> {
432        if self.is_schema {
433            // When casting a schema, from_schema might be a standard JSON Schema (no gts_id)
434            if let (Some(self_id), Some(from_id)) = (&self.gts_id, &from_schema.gts_id) {
435                if self_id.id != from_id.id {
436                    return Err(SchemaCastError::InternalError(format!(
437                        "Internal error: {} != {}",
438                        self_id.id, from_id.id
439                    )));
440                }
441            }
442        }
443
444        if !to_schema.is_schema {
445            return Err(SchemaCastError::TargetMustBeSchema);
446        }
447
448        if !from_schema.is_schema {
449            return Err(SchemaCastError::SourceMustBeSchema);
450        }
451
452        let from_id = self
453            .gts_id
454            .as_ref()
455            .map(|g| g.id.clone())
456            .unwrap_or_default();
457        let to_id = to_schema
458            .gts_id
459            .as_ref()
460            .map(|g| g.id.clone())
461            .unwrap_or_default();
462
463        GtsEntityCastResult::cast(
464            &from_id,
465            &to_id,
466            &self.content,
467            &from_schema.content,
468            &to_schema.content,
469            resolver,
470        )
471    }
472
473    fn walk_and_collect<F>(content: &Value, collector: &mut Vec<GtsRef>, matcher: F)
474    where
475        F: Fn(&Value, &str) -> Option<GtsRef> + Copy,
476    {
477        fn walk<F>(node: &Value, current_path: &str, collector: &mut Vec<GtsRef>, matcher: F)
478        where
479            F: Fn(&Value, &str) -> Option<GtsRef> + Copy,
480        {
481            // Try to match current node
482            if let Some(match_result) = matcher(node, current_path) {
483                collector.push(match_result);
484            }
485
486            // Recurse into structures
487            match node {
488                Value::Object(map) => {
489                    for (k, v) in map {
490                        let next_path = if current_path.is_empty() {
491                            k.clone()
492                        } else {
493                            format!("{current_path}.{k}")
494                        };
495                        walk(v, &next_path, collector, matcher);
496                    }
497                }
498                Value::Array(arr) => {
499                    for (idx, item) in arr.iter().enumerate() {
500                        let next_path = format!("{current_path}[{idx}]");
501                        walk(item, &next_path, collector, matcher);
502                    }
503                }
504                _ => {}
505            }
506        }
507
508        walk(content, "", collector, matcher);
509    }
510
511    fn deduplicate_by_id_and_path(items: Vec<GtsRef>) -> Vec<GtsRef> {
512        let mut seen = HashMap::new();
513        let mut result = Vec::new();
514
515        for item in items {
516            let key = format!("{}|{}", item.id, item.source_path);
517            if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(key) {
518                e.insert(true);
519                result.push(item);
520            }
521        }
522
523        result
524    }
525
526    fn extract_gts_ids_with_paths(&self) -> Vec<GtsRef> {
527        let mut found = Vec::new();
528
529        let gts_id_matcher = |node: &Value, path: &str| -> Option<GtsRef> {
530            if let Some(s) = node.as_str() {
531                if GtsID::is_valid(s) {
532                    return Some(GtsRef {
533                        id: s.to_owned(),
534                        source_path: if path.is_empty() {
535                            "root".to_owned()
536                        } else {
537                            path.to_owned()
538                        },
539                    });
540                }
541            }
542            None
543        };
544
545        Self::walk_and_collect(&self.content, &mut found, gts_id_matcher);
546        Self::deduplicate_by_id_and_path(found)
547    }
548
549    fn extract_ref_strings_with_paths(&self) -> Vec<GtsRef> {
550        let mut refs = Vec::new();
551
552        let ref_matcher = |node: &Value, path: &str| -> Option<GtsRef> {
553            if let Some(obj) = node.as_object() {
554                if let Some(ref_val) = obj.get("$ref") {
555                    if let Some(ref_str) = ref_val.as_str() {
556                        let ref_path = if path.is_empty() {
557                            "$ref".to_owned()
558                        } else {
559                            format!("{path}.$ref")
560                        };
561                        // Normalize: strip gts:// prefix for canonical GTS ID storage
562                        let normalized_ref = ref_str
563                            .strip_prefix(GTS_URI_PREFIX)
564                            .unwrap_or(ref_str)
565                            .to_owned();
566                        return Some(GtsRef {
567                            id: normalized_ref,
568                            source_path: ref_path,
569                        });
570                    }
571                }
572            }
573            None
574        };
575
576        Self::walk_and_collect(&self.content, &mut refs, ref_matcher);
577        Self::deduplicate_by_id_and_path(refs)
578    }
579
580    fn get_field_value(&self, field: &str) -> Option<String> {
581        if let Some(obj) = self.content.as_object() {
582            if let Some(v) = obj.get(field) {
583                if let Some(s) = v.as_str() {
584                    let trimmed = s.trim();
585                    if !trimmed.is_empty() {
586                        // For schema $id fields, validate that they use gts:// URI format, not plain gts. prefix
587                        // According to spec: "Do not place the canonical gts. string directly in $id"
588                        if field == "$id" && self.is_schema && trimmed.starts_with("gts.") {
589                            // Invalid: schema $id must use gts:// URI format
590                            return None;
591                        }
592
593                        // Strip the "gts://" URI prefix ONLY for $id field (JSON Schema compatibility)
594                        // The gts:// prefix is ONLY valid in the $id field of JSON Schema
595                        let normalized = if field == "$id" {
596                            trimmed.strip_prefix(GTS_URI_PREFIX).unwrap_or(trimmed)
597                        } else {
598                            trimmed
599                        };
600                        return Some(normalized.to_owned());
601                    }
602                }
603            }
604        }
605        None
606    }
607
608    fn first_non_empty_field(&mut self, fields: &[String]) -> Option<String> {
609        // First pass: look for valid GTS IDs
610        for f in fields {
611            if let Some(v) = self.get_field_value(f) {
612                if GtsID::is_valid(&v) {
613                    self.selected_entity_field = Some(f.clone());
614                    return Some(v);
615                }
616            }
617        }
618
619        // Second pass: any non-empty string
620        for f in fields {
621            if let Some(v) = self.get_field_value(f) {
622                self.selected_entity_field = Some(f.clone());
623                return Some(v);
624            }
625        }
626
627        None
628    }
629
630    /// Returns the effective ID for this entity (for store indexing and CLI output).
631    /// - For schemas: the GTS ID from `$id` field
632    /// - For well-known instances: the GTS ID from `id` field
633    /// - For anonymous instances: the `instance_id` (UUID or other non-GTS identifier)
634    #[must_use]
635    pub fn effective_id(&self) -> Option<String> {
636        // Prefer GTS ID if available
637        if let Some(ref gts_id) = self.gts_id {
638            return Some(gts_id.id.clone());
639        }
640        // Fall back to instance_id for anonymous instances
641        self.instance_id.clone()
642    }
643}
644
645#[cfg(test)]
646#[allow(clippy::unwrap_used, clippy::expect_used)]
647mod tests {
648    use super::*;
649    use serde_json::json;
650
651    #[test]
652    fn test_json_file_with_description() {
653        let content = json!({
654            "id": "gts.vendor.package.namespace.type.v1.0",
655            "description": "Test description"
656        });
657
658        let cfg = GtsConfig::default();
659        let entity = GtsEntity::new(
660            None,
661            None,
662            &content,
663            Some(&cfg),
664            None,
665            false,
666            String::new(),
667            None,
668            None,
669        );
670
671        assert_eq!(entity.description, "Test description");
672    }
673
674    #[test]
675    fn test_json_entity_with_file_and_sequence() {
676        let file_content = json!([
677            {"id": "gts.vendor.package.namespace.type.v1.0"},
678            {"id": "gts.vendor.package.namespace.type.v1.1"}
679        ]);
680
681        let file = GtsFile::new(
682            "/path/to/file.json".to_owned(),
683            "file.json".to_owned(),
684            file_content,
685        );
686
687        let entity_content = json!({"id": "gts.vendor.package.namespace.type.v1.0"});
688        let cfg = GtsConfig::default();
689
690        let entity = GtsEntity::new(
691            Some(file),
692            Some(0),
693            &entity_content,
694            Some(&cfg),
695            None,
696            false,
697            String::new(),
698            None,
699            None,
700        );
701
702        assert_eq!(entity.label, "file.json#0");
703    }
704
705    #[test]
706    fn test_json_entity_with_file_no_sequence() {
707        let file_content = json!({"id": "gts.vendor.package.namespace.type.v1.0"});
708
709        let file = GtsFile::new(
710            "/path/to/file.json".to_owned(),
711            "file.json".to_owned(),
712            file_content,
713        );
714
715        let entity_content = json!({"id": "gts.vendor.package.namespace.type.v1.0"});
716        let cfg = GtsConfig::default();
717
718        let entity = GtsEntity::new(
719            Some(file),
720            None,
721            &entity_content,
722            Some(&cfg),
723            None,
724            false,
725            String::new(),
726            None,
727            None,
728        );
729
730        assert_eq!(entity.label, "file.json");
731    }
732
733    #[test]
734    fn test_json_entity_extract_gts_ids() {
735        let content = json!({
736            "id": "gts.vendor.package.namespace.type.v1.0~a.b.c.d.v1",
737            "nested": {
738                "ref": "gts.other.package.namespace.type.v2.0~e.f.g.h.v2"
739            }
740        });
741
742        let cfg = GtsConfig::default();
743        let entity = GtsEntity::new(
744            None,
745            None,
746            &content,
747            Some(&cfg),
748            None,
749            false,
750            String::new(),
751            None,
752            None,
753        );
754
755        // gts_refs is populated during entity construction
756        assert!(!entity.gts_refs.is_empty());
757    }
758
759    #[test]
760    fn test_json_entity_extract_ref_strings() {
761        let content = json!({
762            "$schema": "http://json-schema.org/draft-07/schema#",
763            "$ref": "gts://gts.vendor.package.namespace.type.v1.0~",
764            "properties": {
765                "user": {
766                    "$ref": "gts://gts.other.package.namespace.type.v2.0~"
767                }
768            }
769        });
770
771        let cfg = GtsConfig::default();
772        let entity = GtsEntity::new(
773            None,
774            None,
775            &content,
776            Some(&cfg),
777            None,
778            false, // Will be auto-detected as schema due to $schema field
779            String::new(),
780            None,
781            None,
782        );
783
784        // Entity should be detected as schema due to $schema field
785        assert!(entity.is_schema);
786        // schema_refs is populated during entity construction for schemas
787        assert!(!entity.schema_refs.is_empty());
788    }
789
790    #[test]
791    fn test_json_entity_is_json_schema_entity() {
792        let schema_content = json!({
793            "$schema": "http://json-schema.org/draft-07/schema#",
794            "type": "object"
795        });
796
797        let entity = GtsEntity::new(
798            None,
799            None,
800            &schema_content,
801            None,
802            None,
803            false,
804            String::new(),
805            None,
806            None,
807        );
808
809        assert!(entity.is_schema);
810    }
811
812    #[test]
813    fn test_json_entity_instance_with_type_field() {
814        // An instance with only a "type" field (no "id" field) should:
815        // - Have schema_id set from the type field
816        // - NOT have gts_id (because there's no entity ID)
817        // - Be marked as instance (not schema)
818        let content = json!({
819            "type": "gts.vendor.package.namespace.type.v1.0~",
820            "name": "test"
821        });
822
823        let cfg = GtsConfig::default();
824        let entity = GtsEntity::new(
825            None,
826            None,
827            &content,
828            Some(&cfg),
829            None,
830            false,
831            String::new(),
832            None,
833            None,
834        );
835
836        // No $schema field means it's an instance
837        assert!(!entity.is_schema);
838        // The type field provides the schema_id
839        assert_eq!(
840            entity.schema_id,
841            Some("gts.vendor.package.namespace.type.v1.0~".to_owned())
842        );
843        // No id field means no gts_id (this is an anonymous instance without an id)
844        assert!(entity.gts_id.is_none());
845        assert!(entity.instance_id.is_none());
846    }
847
848    #[test]
849    fn test_json_entity_with_custom_label() {
850        let content = json!({"name": "test"});
851
852        let entity = GtsEntity::new(
853            None,
854            None,
855            &content,
856            None,
857            None,
858            false,
859            "custom_label".to_owned(),
860            None,
861            None,
862        );
863
864        assert_eq!(entity.label, "custom_label");
865    }
866
867    #[test]
868    fn test_json_entity_empty_label_fallback() {
869        let content = json!({"name": "test"});
870
871        let entity = GtsEntity::new(
872            None,
873            None,
874            &content,
875            None,
876            None,
877            false,
878            String::new(),
879            None,
880            None,
881        );
882
883        assert_eq!(entity.label, "");
884    }
885
886    #[test]
887    fn test_validation_result_default() {
888        let result = ValidationResult::default();
889        assert!(result.errors.is_empty());
890    }
891
892    #[test]
893    fn test_validation_error_creation() {
894        let mut params = std::collections::HashMap::new();
895        params.insert("key".to_owned(), json!("value"));
896
897        let error = ValidationError {
898            instance_path: "/path".to_owned(),
899            schema_path: "/schema".to_owned(),
900            keyword: "required".to_owned(),
901            message: "test error".to_owned(),
902            params,
903            data: Some(json!({"test": "data"})),
904        };
905
906        assert_eq!(error.instance_path, "/path");
907        assert_eq!(error.message, "test error");
908        assert!(error.data.is_some());
909    }
910
911    #[test]
912    fn test_gts_config_entity_id_fields() {
913        let cfg = GtsConfig::default();
914        assert!(cfg.entity_id_fields.contains(&"id".to_owned()));
915        assert!(cfg.entity_id_fields.contains(&"$id".to_owned()));
916        assert!(cfg.entity_id_fields.contains(&"gtsId".to_owned()));
917    }
918
919    #[test]
920    fn test_gts_config_schema_id_fields() {
921        let cfg = GtsConfig::default();
922        assert!(cfg.schema_id_fields.contains(&"type".to_owned()));
923        assert!(cfg.schema_id_fields.contains(&"schema".to_owned()));
924        assert!(cfg.schema_id_fields.contains(&"gtsTid".to_owned()));
925    }
926
927    #[test]
928    fn test_json_entity_with_validation_result() {
929        let content = json!({"id": "gts.vendor.package.namespace.type.v1.0"});
930
931        let mut validation = ValidationResult::default();
932        validation.errors.push(ValidationError {
933            instance_path: "/test".to_owned(),
934            schema_path: "/schema/test".to_owned(),
935            keyword: "type".to_owned(),
936            message: "validation error".to_owned(),
937            params: std::collections::HashMap::new(),
938            data: None,
939        });
940
941        let entity = GtsEntity::new(
942            None,
943            None,
944            &content,
945            None,
946            None,
947            false,
948            String::new(),
949            Some(validation.clone()),
950            None,
951        );
952
953        assert_eq!(entity.validation.errors.len(), 1);
954    }
955
956    #[test]
957    fn test_json_entity_schema_id_field_selection() {
958        let content = json!({
959            "id": "gts.vendor.package.namespace.type.v1.0~instance.v1.0",
960            "type": "gts.vendor.package.namespace.type.v1.0~"
961        });
962
963        let cfg = GtsConfig::default();
964        let entity = GtsEntity::new(
965            None,
966            None,
967            &content,
968            Some(&cfg),
969            None,
970            false,
971            String::new(),
972            None,
973            None,
974        );
975
976        assert!(entity.selected_schema_id_field.is_some());
977    }
978
979    #[test]
980    fn test_json_entity_when_id_is_schema() {
981        let content = json!({
982            "id": "gts.vendor.package.namespace.type.v1.0~",
983            "$schema": "http://json-schema.org/draft-07/schema#"
984        });
985
986        let cfg = GtsConfig::default();
987        let entity = GtsEntity::new(
988            None,
989            None,
990            &content,
991            Some(&cfg),
992            None,
993            false,
994            String::new(),
995            None,
996            None,
997        );
998
999        // When entity ID itself is a schema, selected_schema_id_field should be set to $schema
1000        assert_eq!(entity.selected_schema_id_field, Some("$schema".to_owned()));
1001    }
1002
1003    // =============================================================================
1004    // Tests for URI prefix "gts:" in JSON Schema $id field
1005    // The gts: prefix is used in JSON Schema for URI compatibility.
1006    // GtsEntity strips it when parsing so the GtsID works with normal "gts." format.
1007    // =============================================================================
1008
1009    #[test]
1010    fn test_entity_with_gts_uri_prefix_in_id() {
1011        // Test that the "gts://" prefix is stripped from JSON Schema $id field
1012        let content = json!({
1013            "$id": "gts://gts.vendor.package.namespace.type.v1.0~",
1014            "$schema": "http://json-schema.org/draft-07/schema#",
1015            "type": "object"
1016        });
1017
1018        let cfg = GtsConfig::default();
1019        let entity = GtsEntity::new(
1020            None,
1021            None,
1022            &content,
1023            Some(&cfg),
1024            None,
1025            false,
1026            String::new(),
1027            None,
1028            None,
1029        );
1030
1031        // The gts_id should have the prefix stripped
1032        let gts_id = entity.gts_id.as_ref().expect("Entity should have a GTS ID");
1033        assert_eq!(gts_id.id, "gts.vendor.package.namespace.type.v1.0~");
1034        assert!(entity.is_schema, "Entity should be detected as a schema");
1035    }
1036
1037    #[test]
1038    fn test_entity_schema_id_extraction() {
1039        // Test that schema_id is correctly extracted from the "type" field
1040        // Note: The instance segment must be a valid GTS segment (vendor.package.namespace.type.version)
1041        // The gts: prefix is ONLY used in $id field, NOT in id/type fields
1042        let content = json!({
1043            "id": "gts.vendor.package.namespace.type.v1~other.app.data.item.v1.0",
1044            "type": "gts.vendor.package.namespace.type.v1~"
1045        });
1046
1047        let cfg = GtsConfig::default();
1048        let entity = GtsEntity::new(
1049            None,
1050            None,
1051            &content,
1052            Some(&cfg),
1053            None,
1054            false,
1055            String::new(),
1056            None,
1057            None,
1058        );
1059
1060        let gts_id = entity.gts_id.as_ref().expect("Entity should have a GTS ID");
1061        assert_eq!(
1062            gts_id.id,
1063            "gts.vendor.package.namespace.type.v1~other.app.data.item.v1.0"
1064        );
1065
1066        let schema_id = entity
1067            .schema_id
1068            .as_ref()
1069            .expect("Entity should have a schema ID");
1070        assert_eq!(schema_id, "gts.vendor.package.namespace.type.v1~");
1071    }
1072
1073    #[test]
1074    fn test_is_json_schema_with_standard_schema() {
1075        // Test that entities with standard $schema URLs are detected as schemas
1076        let content = json!({
1077            "$id": "gts://gts.vendor.package.namespace.type.v1.0~",
1078            "$schema": "http://json-schema.org/draft-07/schema#"
1079        });
1080
1081        let entity = GtsEntity::new(
1082            None,
1083            None,
1084            &content,
1085            None,
1086            None,
1087            false,
1088            String::new(),
1089            None,
1090            None,
1091        );
1092
1093        assert!(
1094            entity.is_schema,
1095            "Entity with $schema should be detected as schema"
1096        );
1097    }
1098
1099    #[test]
1100    fn test_gts_colon_prefix_not_valid_in_id_field() {
1101        // "gts:" (without //) is NOT a valid prefix - only "gts://" is valid
1102        // When $id has "gts:" prefix (not "gts://"), it should NOT be stripped
1103        let content = json!({
1104            "$id": "gts:gts.vendor.package.namespace.type.v1.0~",
1105            "$schema": "http://json-schema.org/draft-07/schema#"
1106        });
1107
1108        let cfg = GtsConfig::default();
1109        let entity = GtsEntity::new(
1110            None,
1111            None,
1112            &content,
1113            Some(&cfg),
1114            None,
1115            false,
1116            String::new(),
1117            None,
1118            None,
1119        );
1120
1121        // With "gts:" prefix (not "gts://"), the ID is not stripped and won't be valid
1122        // The entity should NOT have a valid GTS ID
1123        assert!(
1124            entity.gts_id.is_none(),
1125            "gts: prefix (without //) should not be stripped, resulting in invalid GTS ID"
1126        );
1127    }
1128
1129    #[test]
1130    fn test_gts_colon_prefix_not_valid_in_other_fields() {
1131        // "gts:" prefix should never appear in fields other than $id
1132        // These values should be treated as-is (not stripped) and won't be valid GTS IDs
1133        let content = json!({
1134            "id": "gts:gts.vendor.package.namespace.type.v1.0",
1135            "type": "gts:gts.vendor.package.namespace.type.v1~"
1136        });
1137
1138        let cfg = GtsConfig::default();
1139        let entity = GtsEntity::new(
1140            None,
1141            None,
1142            &content,
1143            Some(&cfg),
1144            None,
1145            false,
1146            String::new(),
1147            None,
1148            None,
1149        );
1150
1151        // The entity should NOT have a valid GTS ID since "gts:" prefix is not stripped
1152        assert!(
1153            entity.gts_id.is_none(),
1154            "gts: prefix in 'id' field should not be valid"
1155        );
1156    }
1157
1158    #[test]
1159    fn test_gts_uri_prefix_only_stripped_from_dollar_id() {
1160        // Only $id field should have gts:// prefix stripped
1161        // The "id" field should NOT have the prefix stripped
1162        let content = json!({
1163            "id": "gts://gts.vendor.package.namespace.type.v1.0"
1164        });
1165
1166        let cfg = GtsConfig::default();
1167        let entity = GtsEntity::new(
1168            None,
1169            None,
1170            &content,
1171            Some(&cfg),
1172            None,
1173            false,
1174            String::new(),
1175            None,
1176            None,
1177        );
1178
1179        // The "id" field is not $id, so the gts:// prefix is NOT stripped
1180        // The value "gts://gts.vendor..." is not a valid GTS ID
1181        assert!(
1182            entity.gts_id.is_none(),
1183            "gts:// prefix in 'id' field (not $id) should not be stripped"
1184        );
1185    }
1186
1187    // =============================================================================
1188    // Tests for strict schema/instance distinction (commit 1b536ea)
1189    // =============================================================================
1190
1191    #[test]
1192    fn test_strict_schema_detection_requires_dollar_schema() {
1193        // A document is a schema ONLY if it has $schema field
1194        let content_with_schema = json!({
1195            "$schema": "http://json-schema.org/draft-07/schema#",
1196            "$id": "gts://gts.vendor.package.namespace.type.v1.0~",
1197            "type": "object"
1198        });
1199
1200        let entity_with_schema = GtsEntity::new(
1201            None,
1202            None,
1203            &content_with_schema,
1204            None,
1205            None,
1206            false,
1207            String::new(),
1208            None,
1209            None,
1210        );
1211        assert!(
1212            entity_with_schema.is_schema,
1213            "Document with $schema should be a schema"
1214        );
1215
1216        // Same content without $schema should be an instance
1217        let content_without_schema = json!({
1218            "$id": "gts://gts.vendor.package.namespace.type.v1.0~",
1219            "type": "object"
1220        });
1221
1222        let entity_without_schema = GtsEntity::new(
1223            None,
1224            None,
1225            &content_without_schema,
1226            None,
1227            None,
1228            false,
1229            String::new(),
1230            None,
1231            None,
1232        );
1233        assert!(
1234            !entity_without_schema.is_schema,
1235            "Document without $schema should be an instance"
1236        );
1237    }
1238
1239    #[test]
1240    fn test_well_known_instance_with_chained_gts_id() {
1241        // Well-known instance: id field contains a GTS ID (possibly chained)
1242        let content = json!({
1243            "id": "gts.x.core.events.type.v1~abc.app._.custom_event.v1.2"
1244        });
1245
1246        let cfg = GtsConfig::default();
1247        let entity = GtsEntity::new(
1248            None,
1249            None,
1250            &content,
1251            Some(&cfg),
1252            None,
1253            false,
1254            String::new(),
1255            None,
1256            None,
1257        );
1258
1259        assert!(!entity.is_schema, "Should be an instance");
1260        assert!(
1261            entity.gts_id.is_some(),
1262            "Well-known instance should have gts_id"
1263        );
1264        assert_eq!(
1265            entity.gts_id.as_ref().unwrap().id,
1266            "gts.x.core.events.type.v1~abc.app._.custom_event.v1.2"
1267        );
1268        assert_eq!(
1269            entity.instance_id,
1270            Some("gts.x.core.events.type.v1~abc.app._.custom_event.v1.2".to_owned())
1271        );
1272        // Schema ID should be extracted from chain (parent segment)
1273        assert_eq!(
1274            entity.schema_id,
1275            Some("gts.x.core.events.type.v1~".to_owned())
1276        );
1277        assert_eq!(entity.selected_entity_field, Some("id".to_owned()));
1278        assert_eq!(
1279            entity.selected_schema_id_field,
1280            Some("id".to_owned()),
1281            "selected_schema_id_field should be set when schema_id is derived from id field"
1282        );
1283    }
1284
1285    #[test]
1286    fn test_anonymous_instance_with_uuid_id() {
1287        // Anonymous instance: id field contains UUID, type field has GTS schema ID
1288        let content = json!({
1289            "id": "7a1d2f34-5678-49ab-9012-abcdef123456",
1290            "type": "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~"
1291        });
1292
1293        let cfg = GtsConfig::default();
1294        let entity = GtsEntity::new(
1295            None,
1296            None,
1297            &content,
1298            Some(&cfg),
1299            None,
1300            false,
1301            String::new(),
1302            None,
1303            None,
1304        );
1305
1306        assert!(!entity.is_schema, "Should be an instance");
1307        assert!(
1308            entity.gts_id.is_none(),
1309            "Anonymous instance should not have gts_id"
1310        );
1311        assert_eq!(
1312            entity.instance_id,
1313            Some("7a1d2f34-5678-49ab-9012-abcdef123456".to_owned())
1314        );
1315        assert_eq!(
1316            entity.schema_id,
1317            Some("gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~".to_owned())
1318        );
1319        assert_eq!(entity.selected_entity_field, Some("id".to_owned()));
1320        assert_eq!(entity.selected_schema_id_field, Some("type".to_owned()));
1321    }
1322
1323    #[test]
1324    fn test_effective_id_for_schema() {
1325        // For schemas, effective_id should return the GTS ID
1326        let content = json!({
1327            "$schema": "http://json-schema.org/draft-07/schema#",
1328            "$id": "gts://gts.vendor.package.namespace.type.v1.0~"
1329        });
1330
1331        let cfg = GtsConfig::default();
1332        let entity = GtsEntity::new(
1333            None,
1334            None,
1335            &content,
1336            Some(&cfg),
1337            None,
1338            false,
1339            String::new(),
1340            None,
1341            None,
1342        );
1343
1344        assert_eq!(
1345            entity.effective_id(),
1346            Some("gts.vendor.package.namespace.type.v1.0~".to_owned())
1347        );
1348    }
1349
1350    #[test]
1351    fn test_effective_id_for_well_known_instance() {
1352        // For well-known instances, effective_id should return the GTS ID
1353        let content = json!({
1354            "id": "gts.x.core.events.type.v1~abc.app._.custom_event.v1.2"
1355        });
1356
1357        let cfg = GtsConfig::default();
1358        let entity = GtsEntity::new(
1359            None,
1360            None,
1361            &content,
1362            Some(&cfg),
1363            None,
1364            false,
1365            String::new(),
1366            None,
1367            None,
1368        );
1369
1370        assert_eq!(
1371            entity.effective_id(),
1372            Some("gts.x.core.events.type.v1~abc.app._.custom_event.v1.2".to_owned())
1373        );
1374    }
1375
1376    #[test]
1377    fn test_effective_id_for_anonymous_instance() {
1378        // For anonymous instances, effective_id should return the instance_id (UUID)
1379        let content = json!({
1380            "id": "7a1d2f34-5678-49ab-9012-abcdef123456",
1381            "type": "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~"
1382        });
1383
1384        let cfg = GtsConfig::default();
1385        let entity = GtsEntity::new(
1386            None,
1387            None,
1388            &content,
1389            Some(&cfg),
1390            None,
1391            false,
1392            String::new(),
1393            None,
1394            None,
1395        );
1396
1397        assert_eq!(
1398            entity.effective_id(),
1399            Some("7a1d2f34-5678-49ab-9012-abcdef123456".to_owned())
1400        );
1401    }
1402
1403    #[test]
1404    fn test_effective_id_returns_none_when_no_id() {
1405        // When there's no id field, effective_id should return None
1406        let content = json!({
1407            "type": "gts.vendor.package.namespace.type.v1.0~",
1408            "name": "test"
1409        });
1410
1411        let cfg = GtsConfig::default();
1412        let entity = GtsEntity::new(
1413            None,
1414            None,
1415            &content,
1416            Some(&cfg),
1417            None,
1418            false,
1419            String::new(),
1420            None,
1421            None,
1422        );
1423
1424        assert_eq!(entity.effective_id(), None);
1425    }
1426
1427    #[test]
1428    fn test_well_known_instance_single_segment_no_schema_id() {
1429        // Well-known instance with single-segment GTS ID (no chain)
1430        // Should not have schema_id extracted from chain
1431        let content = json!({
1432            "id": "gts.vendor.package.namespace.type.v1.0~a.b.c.d.v1"
1433        });
1434
1435        let cfg = GtsConfig::default();
1436        let entity = GtsEntity::new(
1437            None,
1438            None,
1439            &content,
1440            Some(&cfg),
1441            None,
1442            false,
1443            String::new(),
1444            None,
1445            None,
1446        );
1447
1448        assert!(!entity.is_schema);
1449        assert!(entity.gts_id.is_some());
1450        assert_eq!(
1451            entity.gts_id.as_ref().unwrap().id,
1452            "gts.vendor.package.namespace.type.v1.0~a.b.c.d.v1"
1453        );
1454        // Chained ID should have schema_id extracted from the chain
1455        assert_eq!(
1456            entity.schema_id,
1457            Some("gts.vendor.package.namespace.type.v1.0~".to_owned())
1458        );
1459    }
1460
1461    #[test]
1462    fn test_extract_ref_strings_normalizes_gts_uri_prefix() {
1463        // $ref values with gts:// prefix should be normalized (prefix stripped)
1464        let content = json!({
1465            "$schema": "http://json-schema.org/draft-07/schema#",
1466            "$id": "gts://gts.vendor.package.namespace.type.v1.0~",
1467            "allOf": [
1468                {"$ref": "gts://gts.other.package.namespace.type.v2.0~"}
1469            ],
1470            "properties": {
1471                "nested": {
1472                    "$ref": "gts://gts.third.package.namespace.type.v3.0~"
1473                }
1474            }
1475        });
1476
1477        let cfg = GtsConfig::default();
1478        let entity = GtsEntity::new(
1479            None,
1480            None,
1481            &content,
1482            Some(&cfg),
1483            None,
1484            false,
1485            String::new(),
1486            None,
1487            None,
1488        );
1489
1490        // schema_refs should contain normalized refs (without gts:// prefix)
1491        assert!(!entity.schema_refs.is_empty());
1492        assert!(
1493            entity
1494                .schema_refs
1495                .iter()
1496                .any(|r| r.id == "gts.other.package.namespace.type.v2.0~"),
1497            "Ref should be normalized (gts:// prefix stripped)"
1498        );
1499        assert!(
1500            entity
1501                .schema_refs
1502                .iter()
1503                .any(|r| r.id == "gts.third.package.namespace.type.v3.0~"),
1504            "Nested ref should be normalized"
1505        );
1506        // Should not contain the gts:// prefix
1507        assert!(
1508            !entity
1509                .schema_refs
1510                .iter()
1511                .any(|r| r.id.starts_with("gts://")),
1512            "No ref should contain gts:// prefix"
1513        );
1514    }
1515
1516    #[test]
1517    fn test_extract_ref_strings_preserves_local_refs() {
1518        // Local JSON Pointer refs should be preserved as-is
1519        let content = json!({
1520            "$schema": "http://json-schema.org/draft-07/schema#",
1521            "$id": "gts://gts.vendor.package.namespace.type.v1.0~",
1522            "$defs": {
1523                "Base": {"type": "object"}
1524            },
1525            "allOf": [
1526                {"$ref": "#/$defs/Base"}
1527            ]
1528        });
1529
1530        let cfg = GtsConfig::default();
1531        let entity = GtsEntity::new(
1532            None,
1533            None,
1534            &content,
1535            Some(&cfg),
1536            None,
1537            false,
1538            String::new(),
1539            None,
1540            None,
1541        );
1542
1543        // Local refs should be in schema_refs
1544        assert!(
1545            entity.schema_refs.iter().any(|r| r.id == "#/$defs/Base"),
1546            "Local ref should be preserved"
1547        );
1548    }
1549
1550    #[test]
1551    fn test_instance_without_id_field_has_no_effective_id() {
1552        // Instance without id field should have no effective_id
1553        // This is the case that should return an error during registration
1554        let content = json!({
1555            "type": "gts.vendor.package.namespace.type.v1.0~",
1556            "name": "test"
1557        });
1558
1559        let cfg = GtsConfig::default();
1560        let entity = GtsEntity::new(
1561            None,
1562            None,
1563            &content,
1564            Some(&cfg),
1565            None,
1566            false,
1567            String::new(),
1568            None,
1569            None,
1570        );
1571
1572        assert!(!entity.is_schema);
1573        assert_eq!(
1574            entity.effective_id(),
1575            None,
1576            "Instance without id should have no effective_id"
1577        );
1578        assert!(entity.instance_id.is_none());
1579        assert!(entity.gts_id.is_none());
1580    }
1581}