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