braze-sync 0.9.2

GitOps CLI for managing Braze configuration as code
Documentation
//! Catalog Schema domain types. See IMPLEMENTATION.md ยง6.2.
//!
//! Catalog **items** (row data) are intentionally out of scope: braze-sync
//! manages Braze configuration, not runtime data. See docs/scope-boundaries.md.

use serde::{Deserialize, Serialize};

/// Name of the privileged primary-key field on every Braze catalog.
/// `POST /catalogs` rejects bodies whose `fields[0].name` is not this
/// (error id `id-not-first-column`).
pub const ID_FIELD_NAME: &str = "id";

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Catalog {
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    pub fields: Vec<CatalogField>,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct CatalogField {
    pub name: String,
    #[serde(rename = "type")]
    pub field_type: CatalogFieldType,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CatalogFieldType {
    String,
    Number,
    Boolean,
    Time,
    Object,
    Array,
    /// Catch-all for field types not yet known to this binary version.
    /// Forward-compat: prevents deserialization failures when Braze adds
    /// new field types. Round-trips as `"unknown"` โ€” the original type
    /// name is not preserved. Upgrade braze-sync for full support.
    #[serde(other)]
    Unknown,
}

impl CatalogFieldType {
    /// The lowercase wire string for this field type ("string", "number",
    /// ...). Single source of truth used by `format::table`,
    /// `format::json`, `cli::apply`, and `braze::catalog`. Matches the
    /// snake_case `Serialize` representation derived above so the wire
    /// string and the explicit method can never drift.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::String => "string",
            Self::Number => "number",
            Self::Boolean => "boolean",
            Self::Time => "time",
            Self::Object => "object",
            Self::Array => "array",
            Self::Unknown => "unknown",
        }
    }
}

impl Catalog {
    /// Return a copy with `fields` ordered for serialization: the `id`
    /// field (if present) first, then the rest sorted alphabetically by
    /// name. Used both for deterministic on-disk output and for the
    /// `POST /catalogs` request body โ€” Braze rejects creates whose
    /// `fields[0]` is not the `id` column with `id-not-first-column`.
    pub fn normalized(&self) -> Self {
        let mut sorted = self.clone();
        sorted.fields.sort_by(|a, b| {
            let a_is_id = a.name == ID_FIELD_NAME;
            let b_is_id = b.name == ID_FIELD_NAME;
            // bool false < true, so reverse to put id first.
            b_is_id.cmp(&a_is_id).then_with(|| a.name.cmp(&b.name))
        });
        sorted
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn catalog_yaml_roundtrip() {
        let cat = Catalog {
            name: "cardiology".into(),
            description: Some("Cardiology catalog".into()),
            fields: vec![
                CatalogField {
                    name: "condition_id".into(),
                    field_type: CatalogFieldType::String,
                },
                CatalogField {
                    name: "display_order".into(),
                    field_type: CatalogFieldType::Number,
                },
            ],
        };
        let yaml = serde_norway::to_string(&cat).unwrap();
        let parsed: Catalog = serde_norway::from_str(&yaml).unwrap();
        assert_eq!(cat, parsed);
    }

    #[test]
    fn catalog_field_type_serializes_snake_case() {
        let yaml = serde_norway::to_string(&CatalogFieldType::Boolean).unwrap();
        assert_eq!(yaml.trim(), "boolean");
    }

    #[test]
    fn unknown_field_type_deserializes_without_failure() {
        // Forward compat: if Braze adds a field type this binary doesn't
        // know about, the catalog should still parse โ€” the unknown type
        // round-trips as "unknown" rather than crashing the entire export.
        let yaml = "name: future\nfields:\n  - name: x\n    type: hyperlink\n";
        let cat: Catalog = serde_norway::from_str(yaml).unwrap();
        assert_eq!(cat.fields[0].field_type, CatalogFieldType::Unknown);
        assert_eq!(cat.fields[0].field_type.as_str(), "unknown");
    }

    #[test]
    fn unknown_field_type_does_not_break_known_fields() {
        // A catalog with a mix of known and unknown types should parse
        // the known types correctly.
        let yaml = "\
name: mixed
fields:
  - name: id
    type: string
  - name: fancy
    type: quantum_entanglement
  - name: score
    type: number
";
        let cat: Catalog = serde_norway::from_str(yaml).unwrap();
        assert_eq!(cat.fields.len(), 3);
        assert_eq!(cat.fields[0].field_type, CatalogFieldType::String);
        assert_eq!(cat.fields[1].field_type, CatalogFieldType::Unknown);
        assert_eq!(cat.fields[2].field_type, CatalogFieldType::Number);
    }

    #[test]
    fn description_omitted_when_none() {
        let cat = Catalog {
            name: "x".into(),
            description: None,
            fields: vec![],
        };
        let yaml = serde_norway::to_string(&cat).unwrap();
        assert!(!yaml.contains("description"));
    }

    #[test]
    fn normalized_sorts_fields_by_name() {
        let cat = Catalog {
            name: "x".into(),
            description: None,
            fields: vec![
                CatalogField {
                    name: "z".into(),
                    field_type: CatalogFieldType::String,
                },
                CatalogField {
                    name: "a".into(),
                    field_type: CatalogFieldType::String,
                },
            ],
        };
        let n = cat.normalized();
        assert_eq!(n.fields[0].name, "a");
        assert_eq!(n.fields[1].name, "z");
    }

    #[test]
    fn normalized_hoists_id_field_to_front() {
        // Braze's POST /catalogs rejects bodies whose first field is not
        // `id` of type string with error id `id-not-first-column`. The
        // hoist applies regardless of the id field's alphabetic position.
        let cat = Catalog {
            name: "x".into(),
            description: None,
            fields: vec![
                CatalogField {
                    name: "URL".into(),
                    field_type: CatalogFieldType::String,
                },
                CatalogField {
                    name: "author".into(),
                    field_type: CatalogFieldType::String,
                },
                CatalogField {
                    name: "id".into(),
                    field_type: CatalogFieldType::String,
                },
                CatalogField {
                    name: "title".into(),
                    field_type: CatalogFieldType::String,
                },
            ],
        };
        let n = cat.normalized();
        let names: Vec<_> = n.fields.iter().map(|f| f.name.as_str()).collect();
        assert_eq!(names, vec!["id", "URL", "author", "title"]);
    }

    #[test]
    fn normalized_without_id_field_is_pure_alphabetical() {
        let cat = Catalog {
            name: "x".into(),
            description: None,
            fields: vec![
                CatalogField {
                    name: "z".into(),
                    field_type: CatalogFieldType::String,
                },
                CatalogField {
                    name: "a".into(),
                    field_type: CatalogFieldType::String,
                },
            ],
        };
        let n = cat.normalized();
        assert_eq!(n.fields[0].name, "a");
        assert_eq!(n.fields[1].name, "z");
    }
}