Skip to main content

alembic_core/
ir.rs

1//! canonical ir types for alembic.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::BTreeMap;
6use std::fmt;
7use std::ops::{Deref, DerefMut};
8use std::path::PathBuf;
9use uuid::Uuid;
10
11/// Source location for tracking where an object was defined.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct SourceLocation {
14    /// Path to the source file.
15    pub file: PathBuf,
16    /// Line number in the file (1-indexed), if known.
17    pub line: Option<usize>,
18    /// Column number in the file (1-indexed), if known.
19    pub column: Option<usize>,
20}
21
22impl SourceLocation {
23    /// Create a source location with just a file path.
24    pub fn file(path: impl Into<PathBuf>) -> Self {
25        Self {
26            file: path.into(),
27            line: None,
28            column: None,
29        }
30    }
31
32    /// Create a source location with file and line number.
33    pub fn file_line(path: impl Into<PathBuf>, line: usize) -> Self {
34        Self {
35            file: path.into(),
36            line: Some(line),
37            column: None,
38        }
39    }
40}
41
42impl fmt::Display for SourceLocation {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        write!(f, "{}", self.file.display())?;
45        if let Some(line) = self.line {
46            write!(f, ":{}", line)?;
47            if let Some(col) = self.column {
48                write!(f, ":{}", col)?;
49            }
50        }
51        Ok(())
52    }
53}
54
55/// stable object identifier (uuid).
56pub type Uid = Uuid;
57
58/// json object wrapper for typed access and stricter boundaries.
59#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
60#[serde(transparent)]
61pub struct JsonMap(pub BTreeMap<String, Value>);
62
63impl JsonMap {
64    pub fn into_inner(self) -> BTreeMap<String, Value> {
65        self.0
66    }
67
68    pub fn is_empty(&self) -> bool {
69        self.0.is_empty()
70    }
71
72    pub fn get_str(&self, key: &str) -> Option<&str> {
73        self.get(key)?.as_str()
74    }
75
76    pub fn get_bool(&self, key: &str) -> Option<bool> {
77        self.get(key)?.as_bool()
78    }
79
80    pub fn get_i64(&self, key: &str) -> Option<i64> {
81        self.get(key)?.as_i64()
82    }
83
84    pub fn get_f64(&self, key: &str) -> Option<f64> {
85        self.get(key)?.as_f64()
86    }
87}
88
89impl Deref for JsonMap {
90    type Target = BTreeMap<String, Value>;
91
92    fn deref(&self) -> &Self::Target {
93        &self.0
94    }
95}
96
97impl DerefMut for JsonMap {
98    fn deref_mut(&mut self) -> &mut Self::Target {
99        &mut self.0
100    }
101}
102
103impl From<BTreeMap<String, Value>> for JsonMap {
104    fn from(map: BTreeMap<String, Value>) -> Self {
105        Self(map)
106    }
107}
108
109impl From<JsonMap> for BTreeMap<String, Value> {
110    fn from(map: JsonMap) -> Self {
111        map.0
112    }
113}
114
115/// structured key for object identity.
116#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
117#[serde(transparent)]
118pub struct Key(pub BTreeMap<String, Value>);
119
120impl Key {
121    pub fn into_inner(self) -> BTreeMap<String, Value> {
122        self.0
123    }
124
125    pub fn is_empty(&self) -> bool {
126        self.0.is_empty()
127    }
128}
129
130impl Deref for Key {
131    type Target = BTreeMap<String, Value>;
132
133    fn deref(&self) -> &Self::Target {
134        &self.0
135    }
136}
137
138impl DerefMut for Key {
139    fn deref_mut(&mut self) -> &mut Self::Target {
140        &mut self.0
141    }
142}
143
144impl From<BTreeMap<String, Value>> for Key {
145    fn from(map: BTreeMap<String, Value>) -> Self {
146        Self(map)
147    }
148}
149
150impl From<Key> for BTreeMap<String, Value> {
151    fn from(map: Key) -> Self {
152        map.0
153    }
154}
155
156pub fn key_string(key: &Key) -> String {
157    serde_json::to_string(&key.0).unwrap_or_default()
158}
159
160pub const ALEMBIC_UID_NAMESPACE: Uuid = Uuid::from_bytes([
161    0x45, 0x93, 0x1a, 0x5f, 0x6c, 0x2b, 0x49, 0x6a, 0x9b, 0x6f, 0x8f, 0x77, 0x7d, 0x4f, 0x3a, 0x1c,
162]);
163
164pub fn uid_v5(type_name: &str, stable: &str) -> Uid {
165    let name = format!("{type_name}:{stable}");
166    Uuid::new_v5(&ALEMBIC_UID_NAMESPACE, name.as_bytes())
167}
168
169/// canonical object type name.
170#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
171#[serde(transparent)]
172pub struct TypeName(String);
173
174impl TypeName {
175    pub fn new(name: impl Into<String>) -> Self {
176        Self(name.into())
177    }
178
179    pub fn as_str(&self) -> &str {
180        &self.0
181    }
182
183    pub fn is_empty(&self) -> bool {
184        self.0.trim().is_empty()
185    }
186}
187
188impl fmt::Display for TypeName {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        f.write_str(self.as_str())
191    }
192}
193
194/// field type definition in the schema.
195#[derive(Debug, Clone, PartialEq)]
196pub enum FieldType {
197    String,
198    Text,
199    Int,
200    Float,
201    Bool,
202    Uuid,
203    Date,
204    Datetime,
205    Time,
206    Json,
207    IpAddress,
208    Cidr,
209    Prefix,
210    Mac,
211    Slug,
212    Enum { values: Vec<String> },
213    List { item: Box<FieldType> },
214    Map { value: Box<FieldType> },
215    Ref { target: String },
216    ListRef { target: String },
217}
218
219/// format constraints for string fields.
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(rename_all = "snake_case")]
222pub enum FieldFormat {
223    Slug,
224    IpAddress,
225    Cidr,
226    Prefix,
227    Mac,
228    Uuid,
229}
230
231impl Serialize for FieldType {
232    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
233    where
234        S: serde::Serializer,
235    {
236        use serde::ser::SerializeMap;
237        match self {
238            FieldType::String => serializer.serialize_str("string"),
239            FieldType::Text => serializer.serialize_str("text"),
240            FieldType::Int => serializer.serialize_str("int"),
241            FieldType::Float => serializer.serialize_str("float"),
242            FieldType::Bool => serializer.serialize_str("bool"),
243            FieldType::Uuid => serializer.serialize_str("uuid"),
244            FieldType::Date => serializer.serialize_str("date"),
245            FieldType::Datetime => serializer.serialize_str("datetime"),
246            FieldType::Time => serializer.serialize_str("time"),
247            FieldType::Json => serializer.serialize_str("json"),
248            FieldType::IpAddress => serializer.serialize_str("ip_address"),
249            FieldType::Cidr => serializer.serialize_str("cidr"),
250            FieldType::Prefix => serializer.serialize_str("prefix"),
251            FieldType::Mac => serializer.serialize_str("mac"),
252            FieldType::Slug => serializer.serialize_str("slug"),
253            FieldType::Enum { values } => {
254                let mut map = serializer.serialize_map(Some(2))?;
255                map.serialize_entry("type", "enum")?;
256                map.serialize_entry("values", values)?;
257                map.end()
258            }
259            FieldType::List { item } => {
260                let mut map = serializer.serialize_map(Some(2))?;
261                map.serialize_entry("type", "list")?;
262                map.serialize_entry("item", item)?;
263                map.end()
264            }
265            FieldType::Map { value } => {
266                let mut map = serializer.serialize_map(Some(2))?;
267                map.serialize_entry("type", "map")?;
268                map.serialize_entry("value", value)?;
269                map.end()
270            }
271            FieldType::Ref { target } => {
272                let mut map = serializer.serialize_map(Some(2))?;
273                map.serialize_entry("type", "ref")?;
274                map.serialize_entry("target", target)?;
275                map.end()
276            }
277            FieldType::ListRef { target } => {
278                let mut map = serializer.serialize_map(Some(2))?;
279                map.serialize_entry("type", "list_ref")?;
280                map.serialize_entry("target", target)?;
281                map.end()
282            }
283        }
284    }
285}
286
287impl<'de> Deserialize<'de> for FieldType {
288    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
289    where
290        D: serde::Deserializer<'de>,
291    {
292        let value = serde_json::Value::deserialize(deserializer)?;
293        parse_field_type_value(&value).map_err(serde::de::Error::custom)
294    }
295}
296
297fn parse_field_type_value(value: &serde_json::Value) -> Result<FieldType, String> {
298    match value {
299        serde_json::Value::String(raw) => parse_simple_field_type(raw),
300        serde_json::Value::Object(map) => {
301            let raw_type = map
302                .get("type")
303                .and_then(serde_json::Value::as_str)
304                .ok_or_else(|| "field type requires a string 'type' key".to_string())?;
305            match raw_type {
306                "enum" => {
307                    let values = map
308                        .get("values")
309                        .and_then(serde_json::Value::as_array)
310                        .ok_or_else(|| "enum type requires values array".to_string())?
311                        .iter()
312                        .map(|value| {
313                            value
314                                .as_str()
315                                .map(str::to_string)
316                                .ok_or_else(|| "enum values must be strings".to_string())
317                        })
318                        .collect::<Result<Vec<_>, _>>()?;
319                    Ok(FieldType::Enum { values })
320                }
321                "list" => {
322                    let item = map
323                        .get("item")
324                        .ok_or_else(|| "list type requires item".to_string())?;
325                    Ok(FieldType::List {
326                        item: Box::new(parse_field_type_value(item)?),
327                    })
328                }
329                "map" => {
330                    let value = map
331                        .get("value")
332                        .ok_or_else(|| "map type requires value".to_string())?;
333                    Ok(FieldType::Map {
334                        value: Box::new(parse_field_type_value(value)?),
335                    })
336                }
337                "ref" => {
338                    let target = map
339                        .get("target")
340                        .and_then(serde_json::Value::as_str)
341                        .ok_or_else(|| "ref type requires target".to_string())?;
342                    Ok(FieldType::Ref {
343                        target: target.to_string(),
344                    })
345                }
346                "list_ref" => {
347                    let target = map
348                        .get("target")
349                        .and_then(serde_json::Value::as_str)
350                        .ok_or_else(|| "list_ref type requires target".to_string())?;
351                    Ok(FieldType::ListRef {
352                        target: target.to_string(),
353                    })
354                }
355                _ => {
356                    if map.len() != 1 {
357                        return Err(format!("unknown field type {raw_type}"));
358                    }
359                    parse_simple_field_type(raw_type)
360                }
361            }
362        }
363        _ => Err("field type must be a string or map".to_string()),
364    }
365}
366
367fn parse_simple_field_type(raw: &str) -> Result<FieldType, String> {
368    match raw {
369        "string" => Ok(FieldType::String),
370        "text" => Ok(FieldType::Text),
371        "int" => Ok(FieldType::Int),
372        "float" => Ok(FieldType::Float),
373        "bool" => Ok(FieldType::Bool),
374        "uuid" => Ok(FieldType::Uuid),
375        "date" => Ok(FieldType::Date),
376        "datetime" => Ok(FieldType::Datetime),
377        "time" => Ok(FieldType::Time),
378        "json" => Ok(FieldType::Json),
379        "ip_address" => Ok(FieldType::IpAddress),
380        "cidr" => Ok(FieldType::Cidr),
381        "prefix" => Ok(FieldType::Prefix),
382        "mac" => Ok(FieldType::Mac),
383        "slug" => Ok(FieldType::Slug),
384        _ => Err(format!("unknown field type {raw}")),
385    }
386}
387
388fn parse_field_format(raw: &str) -> Result<FieldFormat, String> {
389    match raw {
390        "slug" => Ok(FieldFormat::Slug),
391        "ip_address" => Ok(FieldFormat::IpAddress),
392        "cidr" => Ok(FieldFormat::Cidr),
393        "prefix" => Ok(FieldFormat::Prefix),
394        "mac" => Ok(FieldFormat::Mac),
395        "uuid" => Ok(FieldFormat::Uuid),
396        _ => Err(format!("unknown field format {raw}")),
397    }
398}
399
400/// schema metadata for a single field.
401#[derive(Debug, Clone, PartialEq, Serialize)]
402pub struct FieldSchema {
403    pub r#type: FieldType,
404    #[serde(default)]
405    pub required: bool,
406    #[serde(default)]
407    pub nullable: bool,
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub format: Option<FieldFormat>,
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub pattern: Option<String>,
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub description: Option<String>,
414}
415
416impl<'de> Deserialize<'de> for FieldSchema {
417    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
418    where
419        D: serde::Deserializer<'de>,
420    {
421        let value = serde_json::Value::deserialize(deserializer)?;
422        let map = value
423            .as_object()
424            .ok_or_else(|| serde::de::Error::custom("field schema must be an object"))?;
425
426        let required = map
427            .get("required")
428            .and_then(serde_json::Value::as_bool)
429            .unwrap_or(false);
430        let nullable = map
431            .get("nullable")
432            .and_then(serde_json::Value::as_bool)
433            .unwrap_or(false);
434        let description = map
435            .get("description")
436            .and_then(serde_json::Value::as_str)
437            .map(str::to_string);
438
439        let format = map
440            .get("format")
441            .and_then(serde_json::Value::as_str)
442            .map(|raw| parse_field_format(raw).map_err(serde::de::Error::custom))
443            .transpose()?;
444        let pattern = map
445            .get("pattern")
446            .and_then(serde_json::Value::as_str)
447            .map(str::to_string);
448
449        let type_value = map
450            .get("type")
451            .ok_or_else(|| serde::de::Error::custom("field schema requires type"))?;
452        let field_type = match type_value {
453            serde_json::Value::String(raw) => match raw.as_str() {
454                "list" => {
455                    let item = map
456                        .get("item")
457                        .ok_or_else(|| serde::de::Error::custom("list type requires item"))?;
458                    FieldType::List {
459                        item: Box::new(
460                            parse_field_type_value(item).map_err(serde::de::Error::custom)?,
461                        ),
462                    }
463                }
464                "map" => {
465                    let value = map
466                        .get("value")
467                        .ok_or_else(|| serde::de::Error::custom("map type requires value"))?;
468                    FieldType::Map {
469                        value: Box::new(
470                            parse_field_type_value(value).map_err(serde::de::Error::custom)?,
471                        ),
472                    }
473                }
474                "enum" => {
475                    let values = map
476                        .get("values")
477                        .and_then(serde_json::Value::as_array)
478                        .ok_or_else(|| serde::de::Error::custom("enum type requires values"))?
479                        .iter()
480                        .map(|value| {
481                            value.as_str().map(str::to_string).ok_or_else(|| {
482                                serde::de::Error::custom("enum values must be strings")
483                            })
484                        })
485                        .collect::<Result<Vec<_>, _>>()?;
486                    FieldType::Enum { values }
487                }
488                "ref" => {
489                    let target = map
490                        .get("target")
491                        .and_then(serde_json::Value::as_str)
492                        .ok_or_else(|| serde::de::Error::custom("ref type requires target"))?;
493                    FieldType::Ref {
494                        target: target.to_string(),
495                    }
496                }
497                "list_ref" => {
498                    let target = map
499                        .get("target")
500                        .and_then(serde_json::Value::as_str)
501                        .ok_or_else(|| serde::de::Error::custom("list_ref type requires target"))?;
502                    FieldType::ListRef {
503                        target: target.to_string(),
504                    }
505                }
506                _ => parse_simple_field_type(raw).map_err(serde::de::Error::custom)?,
507            },
508            _ => parse_field_type_value(type_value).map_err(serde::de::Error::custom)?,
509        };
510
511        Ok(FieldSchema {
512            r#type: field_type,
513            required,
514            nullable,
515            format,
516            pattern,
517            description,
518        })
519    }
520}
521
522/// schema metadata for a type.
523#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
524pub struct TypeSchema {
525    pub key: BTreeMap<String, FieldSchema>,
526    #[serde(default)]
527    pub fields: BTreeMap<String, FieldSchema>,
528}
529
530/// collection of schema definitions.
531#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
532pub struct Schema {
533    #[serde(default)]
534    pub types: BTreeMap<String, TypeSchema>,
535}
536
537/// object envelope for the ir.
538#[derive(Debug, Clone, Serialize, Deserialize)]
539pub struct Object {
540    /// stable identifier for the object.
541    pub uid: Uid,
542    /// canonical type for the object.
543    #[serde(rename = "type", alias = "kind")]
544    pub type_name: TypeName,
545    /// structured key used for matching when state is missing.
546    pub key: Key,
547    /// attributes payload for this object.
548    #[serde(default, rename = "attrs")]
549    pub attrs: JsonMap,
550    /// source location where this object was defined (not serialized).
551    #[serde(skip)]
552    pub source: Option<SourceLocation>,
553}
554
555impl PartialEq for Object {
556    fn eq(&self, other: &Self) -> bool {
557        // Source location is intentionally excluded from equality
558        self.uid == other.uid
559            && self.type_name == other.type_name
560            && self.key == other.key
561            && self.attrs == other.attrs
562    }
563}
564
565#[derive(Debug, Clone, PartialEq, Eq)]
566pub enum ObjectError {
567    MissingType,
568    MissingKey,
569}
570
571impl fmt::Display for ObjectError {
572    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
573        match self {
574            ObjectError::MissingType => f.write_str("object type must be set"),
575            ObjectError::MissingKey => f.write_str("object key must be set"),
576        }
577    }
578}
579
580impl std::error::Error for ObjectError {}
581
582impl Object {
583    /// create an object with a type name.
584    pub fn new(
585        uid: Uid,
586        type_name: TypeName,
587        key: Key,
588        attrs: JsonMap,
589    ) -> Result<Self, ObjectError> {
590        if type_name.is_empty() {
591            return Err(ObjectError::MissingType);
592        }
593        if key.is_empty() {
594            return Err(ObjectError::MissingKey);
595        }
596        Ok(Self {
597            uid,
598            type_name,
599            key,
600            attrs,
601            source: None,
602        })
603    }
604
605    /// Set the source location for this object.
606    pub fn with_source(mut self, source: SourceLocation) -> Self {
607        self.source = Some(source);
608        self
609    }
610}
611
612/// top-level inventory of objects.
613#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
614pub struct Inventory {
615    /// schema definitions for type metadata.
616    pub schema: Schema,
617    /// list of objects in this inventory.
618    #[serde(default)]
619    pub objects: Vec<Object>,
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    #[test]
627    fn object_roundtrip_json() {
628        let mut key = BTreeMap::new();
629        key.insert("slug".to_string(), serde_json::json!("fra1"));
630        let mut attrs = BTreeMap::new();
631        attrs.insert("name".to_string(), serde_json::json!("FRA1"));
632        let object = Object::new(
633            Uuid::from_u128(1),
634            TypeName::new("dcim.site"),
635            Key::from(key),
636            attrs.into(),
637        )
638        .unwrap();
639
640        let value = serde_json::to_value(&object).unwrap();
641        let decoded: Object = serde_json::from_value(value).unwrap();
642        assert_eq!(decoded.uid, object.uid);
643        assert_eq!(decoded.type_name, object.type_name);
644        assert_eq!(decoded.key, object.key);
645        assert_eq!(decoded.attrs, object.attrs);
646    }
647
648    #[test]
649    fn object_roundtrip_json_only_attrs() {
650        let mut key = BTreeMap::new();
651        key.insert("slug".to_string(), serde_json::json!("fra1"));
652        let mut attrs = BTreeMap::new();
653        attrs.insert("name".to_string(), serde_json::json!("FRA1"));
654        attrs.insert("extra".to_string(), serde_json::json!(true));
655        let object = Object::new(
656            Uuid::from_u128(2),
657            TypeName::new("dcim.site"),
658            Key::from(key),
659            attrs.into(),
660        )
661        .unwrap();
662
663        let value = serde_json::to_value(&object).unwrap();
664        let decoded: Object = serde_json::from_value(value).unwrap();
665        assert_eq!(decoded.attrs.get("extra"), Some(&serde_json::json!(true)));
666    }
667
668    #[test]
669    fn field_type_roundtrip() {
670        let cases = vec![
671            FieldType::String,
672            FieldType::Int,
673            FieldType::Enum {
674                values: vec!["a".to_string()],
675            },
676            FieldType::Ref {
677                target: "test".to_string(),
678            },
679            FieldType::List {
680                item: Box::new(FieldType::Bool),
681            },
682        ];
683        for case in cases {
684            let json = serde_json::to_string(&case).unwrap();
685            let back: FieldType = serde_json::from_str(&json).unwrap();
686            assert_eq!(back, case);
687        }
688    }
689
690    #[test]
691    fn json_map_helpers() {
692        let mut map = JsonMap::default();
693        map.insert("s".to_string(), serde_json::json!("val"));
694        map.insert("b".to_string(), serde_json::json!(true));
695        map.insert("i".to_string(), serde_json::json!(123));
696        map.insert("f".to_string(), serde_json::json!(1.23));
697
698        assert_eq!(map.get_str("s"), Some("val"));
699        assert_eq!(map.get_bool("b"), Some(true));
700        assert_eq!(map.get_i64("i"), Some(123));
701        assert_eq!(map.get_f64("f"), Some(1.23));
702
703        assert_eq!(map.get_str("none"), None);
704        assert_eq!(map.get_str("b"), None); // wrong type
705    }
706
707    #[test]
708    fn test_key_string() {
709        let mut k = BTreeMap::new();
710        k.insert("a".to_string(), serde_json::json!(1));
711        k.insert("b".to_string(), serde_json::json!("s"));
712        let key = Key::from(k);
713        let s = key_string(&key);
714        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
715        let expected = serde_json::json!({"a": 1, "b": "s"});
716        assert_eq!(parsed, expected);
717    }
718
719    #[test]
720    fn field_schema_deserialization() {
721        // Simple type
722        let json = serde_json::json!({ "type": "string" });
723        let schema: FieldSchema = serde_json::from_value(json).unwrap();
724        assert_eq!(schema.r#type, FieldType::String);
725
726        // Map type
727        let json = serde_json::json!({
728            "type": "map",
729            "value": "int"
730        });
731        let schema: FieldSchema = serde_json::from_value(json).unwrap();
732        assert_eq!(
733            schema.r#type,
734            FieldType::Map {
735                value: Box::new(FieldType::Int)
736            }
737        );
738
739        // Enum type
740        let json = serde_json::json!({
741            "type": "enum",
742            "values": ["a", "b"]
743        });
744        let schema: FieldSchema = serde_json::from_value(json).unwrap();
745        assert_eq!(
746            schema.r#type,
747            FieldType::Enum {
748                values: vec!["a".to_string(), "b".to_string()]
749            }
750        );
751
752        // Complex nested
753        let json = serde_json::json!({
754            "type": "list",
755            "item": { "type": "ref", "target": "test" }
756        });
757        let schema: FieldSchema = serde_json::from_value(json).unwrap();
758        assert_eq!(
759            schema.r#type,
760            FieldType::List {
761                item: Box::new(FieldType::Ref {
762                    target: "test".to_string()
763                })
764            }
765        );
766    }
767
768    #[test]
769    fn field_schema_format_and_pattern() {
770        let json = serde_json::json!({
771            "type": "string",
772            "format": "slug",
773            "pattern": "^[a-z0-9-]+$"
774        });
775        let schema: FieldSchema = serde_json::from_value(json).unwrap();
776        assert_eq!(schema.format, Some(FieldFormat::Slug));
777        assert_eq!(schema.pattern.as_deref(), Some("^[a-z0-9-]+$"));
778    }
779
780    #[test]
781    fn test_type_name() {
782        let t = TypeName::new("test");
783        assert_eq!(t.as_str(), "test");
784        assert!(!t.is_empty());
785        assert_eq!(format!("{}", t), "test");
786
787        let empty = TypeName::new("");
788        assert!(empty.is_empty());
789    }
790
791    #[test]
792    fn test_field_schema_defaults() {
793        let json = serde_json::json!({ "type": "string" });
794        let schema: FieldSchema = serde_json::from_value(json).unwrap();
795        assert!(!schema.required);
796        assert!(!schema.nullable);
797        assert!(schema.format.is_none());
798        assert!(schema.pattern.is_none());
799        assert!(schema.description.is_none());
800    }
801
802    #[test]
803    fn field_type_all_simple_variants() {
804        let simple_types = vec![
805            ("string", FieldType::String),
806            ("int", FieldType::Int),
807            ("float", FieldType::Float),
808            ("bool", FieldType::Bool),
809            ("uuid", FieldType::Uuid),
810            ("date", FieldType::Date),
811            ("datetime", FieldType::Datetime),
812            ("time", FieldType::Time),
813            ("json", FieldType::Json),
814            ("ip_address", FieldType::IpAddress),
815            ("cidr", FieldType::Cidr),
816            ("prefix", FieldType::Prefix),
817            ("mac", FieldType::Mac),
818            ("slug", FieldType::Slug),
819        ];
820        for (name, expected) in simple_types {
821            let json = serde_json::json!({ "type": name });
822            let schema: FieldSchema = serde_json::from_value(json).unwrap();
823            assert_eq!(schema.r#type, expected, "failed for {}", name);
824        }
825    }
826
827    #[test]
828    fn field_type_list_ref() {
829        let json = serde_json::json!({
830            "type": "list_ref",
831            "target": "dcim.device"
832        });
833        let schema: FieldSchema = serde_json::from_value(json).unwrap();
834        assert_eq!(
835            schema.r#type,
836            FieldType::ListRef {
837                target: "dcim.device".to_string()
838            }
839        );
840    }
841
842    #[test]
843    fn field_type_unknown_errors() {
844        let json = serde_json::json!({ "type": "unknown_type" });
845        let result: Result<FieldSchema, _> = serde_json::from_value(json);
846        assert!(result.is_err());
847    }
848
849    #[test]
850    fn field_type_enum_missing_values_errors() {
851        let json = serde_json::json!({ "type": "enum" });
852        let result: Result<FieldSchema, _> = serde_json::from_value(json);
853        assert!(result.is_err());
854    }
855
856    #[test]
857    fn field_type_list_missing_item_errors() {
858        let json = serde_json::json!({ "type": "list" });
859        let result: Result<FieldSchema, _> = serde_json::from_value(json);
860        assert!(result.is_err());
861    }
862
863    #[test]
864    fn field_type_map_missing_value_errors() {
865        let json = serde_json::json!({ "type": "map" });
866        let result: Result<FieldSchema, _> = serde_json::from_value(json);
867        assert!(result.is_err());
868    }
869
870    #[test]
871    fn field_type_ref_missing_target_errors() {
872        let json = serde_json::json!({ "type": "ref" });
873        let result: Result<FieldSchema, _> = serde_json::from_value(json);
874        assert!(result.is_err());
875    }
876
877    #[test]
878    fn key_into_inner_and_is_empty() {
879        let key = Key::default();
880        assert!(key.is_empty());
881        let inner = key.into_inner();
882        assert!(inner.is_empty());
883
884        let mut k = BTreeMap::new();
885        k.insert("a".to_string(), serde_json::json!(1));
886        let key = Key::from(k);
887        assert!(!key.is_empty());
888    }
889
890    #[test]
891    fn json_map_into_inner_and_is_empty() {
892        let map = JsonMap::default();
893        assert!(map.is_empty());
894        let inner = map.into_inner();
895        assert!(inner.is_empty());
896    }
897
898    #[test]
899    fn object_with_empty_key_errors() {
900        let key = Key::default();
901        let attrs = JsonMap::default();
902        let result = Object::new(Uuid::from_u128(1), TypeName::new("dcim.site"), key, attrs);
903        assert!(result.is_err());
904    }
905
906    #[test]
907    fn object_with_empty_type_errors() {
908        let mut k = BTreeMap::new();
909        k.insert("slug".to_string(), serde_json::json!("x"));
910        let result = Object::new(
911            Uuid::from_u128(1),
912            TypeName::new(""),
913            Key::from(k),
914            JsonMap::default(),
915        );
916        assert_eq!(result.unwrap_err(), ObjectError::MissingType);
917    }
918
919    #[test]
920    fn object_with_whitespace_only_type_errors() {
921        let mut k = BTreeMap::new();
922        k.insert("slug".to_string(), serde_json::json!("x"));
923        let result = Object::new(
924            Uuid::from_u128(1),
925            TypeName::new("   "),
926            Key::from(k),
927            JsonMap::default(),
928        );
929        assert_eq!(result.unwrap_err(), ObjectError::MissingType);
930    }
931
932    #[test]
933    fn object_error_display() {
934        assert_eq!(
935            ObjectError::MissingType.to_string(),
936            "object type must be set"
937        );
938        assert_eq!(
939            ObjectError::MissingKey.to_string(),
940            "object key must be set"
941        );
942    }
943
944    #[test]
945    fn object_with_source() {
946        let mut k = BTreeMap::new();
947        k.insert("slug".to_string(), serde_json::json!("x"));
948        let obj = Object::new(
949            Uuid::from_u128(1),
950            TypeName::new("dcim.site"),
951            Key::from(k),
952            JsonMap::default(),
953        )
954        .unwrap()
955        .with_source(SourceLocation::file_line("test.yaml", 42));
956        assert_eq!(obj.source.as_ref().unwrap().line, Some(42));
957    }
958
959    #[test]
960    fn object_equality_ignores_source() {
961        let mut k = BTreeMap::new();
962        k.insert("slug".to_string(), serde_json::json!("x"));
963        let a = Object::new(
964            Uuid::from_u128(1),
965            TypeName::new("dcim.site"),
966            Key::from(k.clone()),
967            JsonMap::default(),
968        )
969        .unwrap()
970        .with_source(SourceLocation::file("a.yaml"));
971        let b = Object::new(
972            Uuid::from_u128(1),
973            TypeName::new("dcim.site"),
974            Key::from(k),
975            JsonMap::default(),
976        )
977        .unwrap()
978        .with_source(SourceLocation::file("b.yaml"));
979        assert_eq!(a, b);
980    }
981
982    #[test]
983    fn object_deserialize_kind_alias() {
984        let json = serde_json::json!({
985            "uid": "00000000-0000-0000-0000-000000000001",
986            "kind": "dcim.site",
987            "key": {"slug": "x"}
988        });
989        let obj: Object = serde_json::from_value(json).unwrap();
990        assert_eq!(obj.type_name.as_str(), "dcim.site");
991    }
992
993    #[test]
994    fn object_source_not_serialized() {
995        let mut k = BTreeMap::new();
996        k.insert("slug".to_string(), serde_json::json!("x"));
997        let obj = Object::new(
998            Uuid::from_u128(1),
999            TypeName::new("dcim.site"),
1000            Key::from(k),
1001            JsonMap::default(),
1002        )
1003        .unwrap()
1004        .with_source(SourceLocation::file_line("test.yaml", 10));
1005        let value = serde_json::to_value(&obj).unwrap();
1006        assert!(value.get("source").is_none());
1007    }
1008
1009    #[test]
1010    fn source_location_display_file_only() {
1011        let loc = SourceLocation::file("test.yaml");
1012        assert_eq!(loc.to_string(), "test.yaml");
1013        assert!(loc.line.is_none());
1014        assert!(loc.column.is_none());
1015    }
1016
1017    #[test]
1018    fn source_location_display_file_and_line() {
1019        let loc = SourceLocation::file_line("test.yaml", 42);
1020        assert_eq!(loc.to_string(), "test.yaml:42");
1021    }
1022
1023    #[test]
1024    fn source_location_display_file_line_column() {
1025        let loc = SourceLocation {
1026            file: "test.yaml".into(),
1027            line: Some(42),
1028            column: Some(7),
1029        };
1030        assert_eq!(loc.to_string(), "test.yaml:42:7");
1031    }
1032
1033    #[test]
1034    fn uid_v5_deterministic() {
1035        let a = uid_v5("dcim.site", "fra1");
1036        let b = uid_v5("dcim.site", "fra1");
1037        assert_eq!(a, b);
1038    }
1039
1040    #[test]
1041    fn uid_v5_different_inputs() {
1042        let a = uid_v5("dcim.site", "fra1");
1043        let b = uid_v5("dcim.site", "fra2");
1044        let c = uid_v5("dcim.device", "fra1");
1045        assert_ne!(a, b);
1046        assert_ne!(a, c);
1047    }
1048
1049    #[test]
1050    fn json_map_serde_transparent() {
1051        let mut map = JsonMap::default();
1052        map.insert("k".to_string(), serde_json::json!("v"));
1053        let json = serde_json::to_value(&map).unwrap();
1054        assert_eq!(json, serde_json::json!({"k": "v"}));
1055        let back: JsonMap = serde_json::from_value(json).unwrap();
1056        assert_eq!(back, map);
1057    }
1058
1059    #[test]
1060    fn key_serde_transparent() {
1061        let mut k = BTreeMap::new();
1062        k.insert("slug".to_string(), serde_json::json!("x"));
1063        let key = Key::from(k);
1064        let json = serde_json::to_value(&key).unwrap();
1065        assert_eq!(json, serde_json::json!({"slug": "x"}));
1066        let back: Key = serde_json::from_value(json).unwrap();
1067        assert_eq!(back, key);
1068    }
1069
1070    #[test]
1071    fn type_name_serde_transparent() {
1072        let t = TypeName::new("dcim.site");
1073        let json = serde_json::to_value(&t).unwrap();
1074        assert_eq!(json, serde_json::json!("dcim.site"));
1075        let back: TypeName = serde_json::from_value(json).unwrap();
1076        assert_eq!(back, t);
1077    }
1078
1079    #[test]
1080    fn field_type_roundtrip_all_complex_variants() {
1081        let cases = vec![
1082            FieldType::Text,
1083            FieldType::Float,
1084            FieldType::Uuid,
1085            FieldType::Date,
1086            FieldType::Datetime,
1087            FieldType::Time,
1088            FieldType::Json,
1089            FieldType::IpAddress,
1090            FieldType::Cidr,
1091            FieldType::Prefix,
1092            FieldType::Mac,
1093            FieldType::Slug,
1094            FieldType::Map {
1095                value: Box::new(FieldType::String),
1096            },
1097            FieldType::ListRef {
1098                target: "dcim.device".to_string(),
1099            },
1100            FieldType::Enum {
1101                values: vec!["active".to_string(), "planned".to_string()],
1102            },
1103            FieldType::List {
1104                item: Box::new(FieldType::List {
1105                    item: Box::new(FieldType::Int),
1106                }),
1107            },
1108        ];
1109        for case in cases {
1110            let json = serde_json::to_string(&case).unwrap();
1111            let back: FieldType = serde_json::from_str(&json).unwrap();
1112            assert_eq!(back, case, "roundtrip failed for {:?}", case);
1113        }
1114    }
1115
1116    #[test]
1117    fn field_format_serde_roundtrip() {
1118        let formats = vec![
1119            FieldFormat::Slug,
1120            FieldFormat::IpAddress,
1121            FieldFormat::Cidr,
1122            FieldFormat::Prefix,
1123            FieldFormat::Mac,
1124            FieldFormat::Uuid,
1125        ];
1126        for fmt in formats {
1127            let json = serde_json::to_value(&fmt).unwrap();
1128            let back: FieldFormat = serde_json::from_value(json).unwrap();
1129            assert_eq!(back, fmt);
1130        }
1131    }
1132
1133    #[test]
1134    fn field_schema_with_all_fields_set() {
1135        let json = serde_json::json!({
1136            "type": "string",
1137            "required": true,
1138            "nullable": true,
1139            "format": "slug",
1140            "pattern": "^[a-z]+$",
1141            "description": "a slug field"
1142        });
1143        let schema: FieldSchema = serde_json::from_value(json).unwrap();
1144        assert!(schema.required);
1145        assert!(schema.nullable);
1146        assert_eq!(schema.format, Some(FieldFormat::Slug));
1147        assert_eq!(schema.pattern.as_deref(), Some("^[a-z]+$"));
1148        assert_eq!(schema.description.as_deref(), Some("a slug field"));
1149    }
1150
1151    #[test]
1152    fn field_schema_roundtrip() {
1153        let schema = FieldSchema {
1154            r#type: FieldType::Ref {
1155                target: "dcim.site".to_string(),
1156            },
1157            required: true,
1158            nullable: false,
1159            format: None,
1160            pattern: None,
1161            description: Some("site ref".to_string()),
1162        };
1163        let json = serde_json::to_value(&schema).unwrap();
1164        let back: FieldSchema = serde_json::from_value(json).unwrap();
1165        assert_eq!(back, schema);
1166    }
1167
1168    #[test]
1169    fn field_schema_unknown_format_errors() {
1170        let json = serde_json::json!({
1171            "type": "string",
1172            "format": "nope"
1173        });
1174        let result: Result<FieldSchema, _> = serde_json::from_value(json);
1175        assert!(result.is_err());
1176    }
1177
1178    #[test]
1179    fn field_type_list_ref_missing_target_errors() {
1180        let json = serde_json::json!({ "type": "list_ref" });
1181        let result: Result<FieldSchema, _> = serde_json::from_value(json);
1182        assert!(result.is_err());
1183    }
1184
1185    #[test]
1186    fn field_type_invalid_value_type_errors() {
1187        let result = parse_field_type_value(&serde_json::json!(42));
1188        assert!(result.is_err());
1189        assert!(result.unwrap_err().contains("string or map"));
1190    }
1191
1192    #[test]
1193    fn field_type_object_simple_fallback() {
1194        let json = serde_json::json!({ "type": "int" });
1195        let schema: FieldSchema = serde_json::from_value(json).unwrap();
1196        assert_eq!(schema.r#type, FieldType::Int);
1197    }
1198
1199    #[test]
1200    fn type_schema_roundtrip() {
1201        let json = serde_json::json!({
1202            "key": {
1203                "slug": { "type": "string" }
1204            },
1205            "fields": {
1206                "name": { "type": "string", "required": true },
1207                "status": { "type": "enum", "values": ["active", "planned"] }
1208            }
1209        });
1210        let schema: TypeSchema = serde_json::from_value(json.clone()).unwrap();
1211        assert!(schema.key.contains_key("slug"));
1212        assert!(schema.fields.contains_key("name"));
1213        assert!(schema.fields.contains_key("status"));
1214        let back = serde_json::to_value(&schema).unwrap();
1215        let back_schema: TypeSchema = serde_json::from_value(back).unwrap();
1216        assert_eq!(back_schema, schema);
1217    }
1218
1219    #[test]
1220    fn inventory_roundtrip() {
1221        let json = serde_json::json!({
1222            "schema": {
1223                "types": {
1224                    "dcim.site": {
1225                        "key": { "slug": { "type": "string" } },
1226                        "fields": { "name": { "type": "string" } }
1227                    }
1228                }
1229            },
1230            "objects": [
1231                {
1232                    "uid": "00000000-0000-0000-0000-000000000001",
1233                    "type": "dcim.site",
1234                    "key": { "slug": "fra1" },
1235                    "attrs": { "name": "FRA1" }
1236                }
1237            ]
1238        });
1239        let inv: Inventory = serde_json::from_value(json).unwrap();
1240        assert_eq!(inv.schema.types.len(), 1);
1241        assert_eq!(inv.objects.len(), 1);
1242        assert_eq!(inv.objects[0].type_name.as_str(), "dcim.site");
1243        let back = serde_json::to_value(&inv).unwrap();
1244        let back_inv: Inventory = serde_json::from_value(back).unwrap();
1245        assert_eq!(back_inv, inv);
1246    }
1247
1248    #[test]
1249    fn inventory_empty_objects_default() {
1250        let json = serde_json::json!({
1251            "schema": { "types": {} }
1252        });
1253        let inv: Inventory = serde_json::from_value(json).unwrap();
1254        assert!(inv.objects.is_empty());
1255    }
1256
1257    #[test]
1258    fn key_string_empty() {
1259        let key = Key::default();
1260        let s = key_string(&key);
1261        assert_eq!(s, "{}");
1262    }
1263}